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
+36 -1
View File
@@ -8,6 +8,23 @@ type ApiResult<T> = {
export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN';
export type BroadcastTargetRequest = {
projectCodes: string[];
positionNames: string[];
positionLevels: string[];
};
export type BroadcastTargetOption = {
value: string;
label: string;
};
export type BroadcastTargetOptions = {
projects: BroadcastTargetOption[];
positionNames: BroadcastTargetOption[];
positionLevels: BroadcastTargetOption[];
};
export type BroadcastAdminMessage = {
id: number;
title: string;
@@ -19,6 +36,10 @@ export type BroadcastAdminMessage = {
withdrawnBy?: number;
withdrawnAt?: string;
withdrawReason?: string;
requiredRead: boolean;
targetRuleCount: number;
matchedRecipientCount: number;
unmatchedTargetCount: number;
};
export type BroadcastAdminPage = {
@@ -35,6 +56,8 @@ export type PublishBroadcastRequest = {
title: string;
content: string;
expectedTenantId: string;
requiredRead: boolean;
targets: BroadcastTargetRequest;
};
export type WithdrawBroadcastRequest = {
@@ -54,7 +77,19 @@ export function listBroadcastAdminMessages(params: {
});
}
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{ id: number; version: number }>> {
export function loadBroadcastTargetOptions(): Promise<ApiResult<BroadcastTargetOptions>> {
return request({
url: '/api/aihr/broadcast/admin/target-options',
method: 'get'
});
}
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{
id: number;
version: number;
matchedRecipientCount: number;
unmatchedTargetCount: number;
}>> {
return request({
url: '/api/aihr/broadcast/messages',
method: 'post',
+7 -1
View File
@@ -39,7 +39,13 @@ const pending: PendingBroadcastPublish = {
tenantId: 'tenant-a',
requestId: 'request-1',
title: '夏季服务标准通知',
content: '请按最新标准执行。'
content: '请按最新标准执行。',
requiredRead: true,
targets: {
projectCodes: ['PRJ-FCW'],
positionNames: ['物业管家'],
positionLevels: []
}
};
describe('pending broadcast publish storage', () => {
+36 -1
View File
@@ -1,3 +1,5 @@
import type { BroadcastTargetRequest } from '@/api/aihr/broadcast';
export const pendingBroadcastStoragePrefix = 'aihr.broadcast.pending.v2';
export interface PendingBroadcastPublish {
@@ -5,6 +7,8 @@ export interface PendingBroadcastPublish {
requestId: string;
title: string;
content: string;
requiredRead: boolean;
targets: BroadcastTargetRequest;
}
export interface BroadcastPendingStorageOptions {
@@ -14,6 +18,31 @@ export interface BroadcastPendingStorageOptions {
const accountKey = (userId: string | number) => String(userId).trim();
const emptyTargets = (): BroadcastTargetRequest => ({
projectCodes: [],
positionNames: [],
positionLevels: []
});
const parseTargetValues = (value: unknown, maximumCount: number, maximumLength: number) => {
if (!Array.isArray(value) || value.length > maximumCount) return undefined;
const normalized = value.map((entry) => typeof entry === 'string' ? entry.trim() : '');
if (normalized.some((entry) => !entry || entry.length > maximumLength)) return undefined;
return [...new Set(normalized)];
};
const parseTargets = (value: unknown): BroadcastTargetRequest | undefined => {
if (value === undefined) return emptyTargets();
if (!value || typeof value !== 'object') return undefined;
const candidate = value as Partial<BroadcastTargetRequest>;
const projectCodes = parseTargetValues(candidate.projectCodes, 50, 50);
const positionNames = parseTargetValues(candidate.positionNames, 50, 100);
const positionLevels = parseTargetValues(candidate.positionLevels, 20, 30);
return projectCodes && positionNames && positionLevels
? { projectCodes, positionNames, positionLevels }
: undefined;
};
export const broadcastPendingStorageKey = (tenantId: string, userId: string | number) => {
const normalizedTenantId = String(tenantId).trim();
const normalizedUserId = accountKey(userId);
@@ -26,11 +55,15 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde
if (!raw) return undefined;
try {
const candidate = JSON.parse(raw) as Partial<PendingBroadcastPublish>;
const targets = parseTargets(candidate.targets);
const requiredRead = candidate.requiredRead === undefined ? false : candidate.requiredRead;
if (
typeof candidate.tenantId !== 'string'
|| typeof candidate.requestId !== 'string'
|| typeof candidate.title !== 'string'
|| typeof candidate.content !== 'string'
|| typeof requiredRead !== 'boolean'
|| !targets
) {
return undefined;
}
@@ -38,7 +71,9 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde
tenantId: candidate.tenantId,
requestId: candidate.requestId,
title: candidate.title,
content: candidate.content
content: candidate.content,
requiredRead,
targets
};
} catch {
return undefined;
+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>