feat(aihr): add M1 broadcast targeting and mandatory reads

This commit is contained in:
2026-07-23 12:11:41 +08:00
parent cd94f4fe9f
commit f6609808df
14 changed files with 925 additions and 69 deletions
+171 -7
View File
@@ -3,7 +3,7 @@
<section class="page-head">
<div>
<h1>银城大喇叭</h1>
<p>向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。</p>
<p>向当前租户全员公开发布公司通知;可额外定向提醒项目、岗位或职级人员,并设置必读。撤回会停止员工端展示,但保留发布记录。</p>
</div>
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
</section>
@@ -38,6 +38,16 @@
<el-table-column label="内容摘要" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.content }}</template>
</el-table-column>
<el-table-column label="阅读与范围" min-width="164">
<template #default="{ row }">
<div class="target-summary">
<el-tag v-if="row.requiredRead" type="danger" size="small">必读</el-tag>
<el-tag v-if="row.targetRuleCount" type="warning" size="small">定向提醒</el-tag>
<span v-if="row.targetRuleCount" class="target-count">已匹配 {{ row.matchedRecipientCount }} 人<span v-if="row.unmatchedTargetCount">,待匹配 {{ row.unmatchedTargetCount }} 人</span></span>
<span v-else class="muted">全员公开</span>
</div>
</template>
</el-table-column>
<el-table-column label="版本" width="64" align="center">
<template #default="{ row }">v{{ row.currentVersion }}</template>
</el-table-column>
@@ -125,6 +135,59 @@
placeholder="请输入完整通知正文"
/>
</el-form-item>
<el-form-item label="阅读要求">
<el-checkbox v-model="form.requiredRead" :disabled="publishing || publishPending">设为必读(员工端会优先显示未读内容)</el-checkbox>
</el-form-item>
<el-form-item label="定向提醒(可选)">
<el-alert
class="targeting-note"
type="info"
:closable="false"
show-icon
title="不选择范围时仍是全员公开。选择多个值时,同一维度按“或”匹配,不同维度按“且”匹配;未匹配账号不会丢失公开消息。"
/>
<div class="target-selects">
<el-select
v-model="form.targets.projectCodes"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:loading="targetOptionsLoading"
:disabled="publishing || publishPending"
placeholder="按项目定向"
>
<el-option v-for="option in targetOptions.projects" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select
v-model="form.targets.positionNames"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:loading="targetOptionsLoading"
:disabled="publishing || publishPending"
placeholder="按岗位定向"
>
<el-option v-for="option in targetOptions.positionNames" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select
v-model="form.targets.positionLevels"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:loading="targetOptionsLoading"
:disabled="publishing || publishPending"
placeholder="按职级定向"
>
<el-option v-for="option in targetOptions.positionLevels" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="publishing" @click="requestClosePublish">{{ publishPending ? '放弃并关闭' : '取消' }}</el-button>
@@ -141,9 +204,12 @@ import type { FormInstance } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import {
listBroadcastAdminMessages,
loadBroadcastTargetOptions,
publishBroadcast,
withdrawBroadcast,
type BroadcastAdminMessage,
type BroadcastTargetRequest,
type BroadcastTargetOptions,
type BroadcastStatus
} from '@/api/aihr/broadcast';
import { useUserStore } from '@/store/modules/user';
@@ -165,7 +231,15 @@ const publishing = ref(false);
const publishPending = ref(false);
const withdrawingId = ref<number>();
const publishFormRef = ref<FormInstance>();
const form = reactive({ requestId: '', title: '', content: '' });
const targetOptionsLoading = ref(false);
const targetOptions = ref<BroadcastTargetOptions>({ projects: [], positionNames: [], positionLevels: [] });
const form = reactive({
requestId: '',
title: '',
content: '',
requiredRead: false,
targets: { projectCodes: [] as string[], positionNames: [] as string[], positionLevels: [] as string[] }
});
const userStore = useUserStore();
const scopeTenantId = ref('');
const draftTenantId = ref('');
@@ -178,6 +252,22 @@ const rules = {
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—');
const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
const emptyTargets = (): BroadcastTargetRequest => ({ projectCodes: [], positionNames: [], positionLevels: [] });
const copyTargets = (targets: BroadcastTargetRequest): BroadcastTargetRequest => ({
projectCodes: [...targets.projectCodes],
positionNames: [...targets.positionNames],
positionLevels: [...targets.positionLevels]
});
const isValidTargetValues = (values: unknown, maximumCount: number, maximumLength: number): values is string[] =>
Array.isArray(values) && values.length <= maximumCount
&& values.every((value) => typeof value === 'string' && !!value.trim() && value.trim().length <= maximumLength);
const isValidTargets = (targets: unknown): targets is BroadcastTargetRequest => {
if (!targets || typeof targets !== 'object') return false;
const candidate = targets as Partial<BroadcastTargetRequest>;
return isValidTargetValues(candidate.projectCodes, 50, 50)
&& isValidTargetValues(candidate.positionNames, 50, 100)
&& isValidTargetValues(candidate.positionLevels, 20, 30);
};
const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): candidate is PendingBroadcastPublish =>
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
@@ -185,7 +275,8 @@ const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): can
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
/^[A-Za-z0-9._:-]{1,100}$/.test(candidate.requestId) &&
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000;
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000 &&
typeof candidate.requiredRead === 'boolean' && isValidTargets(candidate.targets);
const pendingPublishUserId = () => String(userStore.userId || '').trim();
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
@@ -211,7 +302,9 @@ const persistPendingPublish = () => {
tenantId: pendingTenantId.value,
requestId: form.requestId,
title: form.title,
content: form.content
content: form.content,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
};
try {
if (!pendingPublishStorageKey()) throw new Error('missing tenant scope');
@@ -263,6 +356,10 @@ const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
form.requestId = candidate.requestId;
form.title = candidate.title;
form.content = candidate.content;
form.requiredRead = candidate.requiredRead;
form.targets.projectCodes = [...candidate.targets.projectCodes];
form.targets.positionNames = [...candidate.targets.positionNames];
form.targets.positionLevels = [...candidate.targets.positionLevels];
publishPending.value = true;
publishVisible.value = true;
};
@@ -275,6 +372,32 @@ const restorePendingPublish = () => {
return true;
};
const resetTargetForm = () => {
const targets = emptyTargets();
form.requiredRead = false;
form.targets.projectCodes = targets.projectCodes;
form.targets.positionNames = targets.positionNames;
form.targets.positionLevels = targets.positionLevels;
};
const loadTargetOptions = async () => {
if (targetOptionsLoading.value) return;
targetOptionsLoading.value = true;
try {
const response = await loadBroadcastTargetOptions();
targetOptions.value = {
projects: Array.isArray(response.data?.projects) ? response.data.projects : [],
positionNames: Array.isArray(response.data?.positionNames) ? response.data.positionNames : [],
positionLevels: Array.isArray(response.data?.positionLevels) ? response.data.positionLevels : []
};
} catch {
targetOptions.value = { projects: [], positionNames: [], positionLevels: [] };
ElMessage.warning('定向范围暂不可读取;仍可按全员公开方式发布。');
} finally {
targetOptionsLoading.value = false;
}
};
const loadMessages = async (allowWhilePublishing = false) => {
if (publishing.value && !allowWhilePublishing) return;
loading.value = true;
@@ -297,6 +420,7 @@ const loadMessages = async (allowWhilePublishing = false) => {
form.requestId = '';
form.title = '';
form.content = '';
resetTargetForm();
}
scopeTenantId.value = resolvedTenantId;
restorePendingPublish();
@@ -327,6 +451,7 @@ const openPublish = () => {
ElMessage.warning('正在确认当前租户,请稍候。');
return;
}
void loadTargetOptions();
if (restorePendingPublish()) {
ElMessage.warning('检测到同一租户存在结果未确认的发布,请先重试或明确放弃。');
return;
@@ -336,6 +461,7 @@ const openPublish = () => {
draftTenantId.value = scopeTenantId.value;
form.title = '';
form.content = '';
resetTargetForm();
publishVisible.value = true;
};
@@ -346,6 +472,7 @@ const resetPublishForm = () => {
form.requestId = '';
form.title = '';
form.content = '';
resetTargetForm();
publishFormRef.value?.clearValidate();
return true;
};
@@ -395,6 +522,7 @@ const submitPublish = async () => {
form.requestId = '';
form.title = '';
form.content = '';
resetTargetForm();
draftTenantId.value = '';
return;
}
@@ -415,18 +543,26 @@ const submitPublish = async () => {
}
publishing.value = true;
try {
await publishBroadcast({
const publishedWithTargeting = form.targets.projectCodes.length > 0
|| form.targets.positionNames.length > 0 || form.targets.positionLevels.length > 0;
const response = await publishBroadcast({
requestId: form.requestId,
title: form.title,
content: form.content,
expectedTenantId: pendingTenantId.value
expectedTenantId: pendingTenantId.value,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
});
if (!resetPublishForm()) {
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
return;
}
publishVisible.value = false;
ElMessage.success('消息已发布');
const matchedRecipientCount = Number(response.data?.matchedRecipientCount) || 0;
const unmatchedTargetCount = Number(response.data?.unmatchedTargetCount) || 0;
ElMessage.success(publishedWithTargeting
? `消息已发布,已定向提醒 ${matchedRecipientCount} 名员工${unmatchedTargetCount ? `,另有 ${unmatchedTargetCount} 名待匹配` : ''}`
: '消息已发布');
pageNum.value = 1;
await loadMessages(true);
} catch {
@@ -522,6 +658,18 @@ onMounted(() => {
margin-bottom: 12px;
}
.targeting-note {
width: 100%;
margin-bottom: 10px;
}
.target-selects {
display: grid;
width: 100%;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.table-card {
padding: 18px;
border-radius: 8px;
@@ -558,6 +706,18 @@ onMounted(() => {
}
}
.target-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.target-count {
color: #687080;
font-size: 12px;
}
p {
margin: 6px 0 0;
}
@@ -594,5 +754,9 @@ onMounted(() => {
.broadcast-page {
padding: 16px;
}
.target-selects {
grid-template-columns: 1fr;
}
}
</style>