feat(aihr): add M1 broadcast targeting and mandatory reads
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user