feat(aihr): 重构运营后台与租户知识治理

- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
2026-07-23 01:24:22 +08:00
parent 333c365c6f
commit 163b08ecfe
94 changed files with 8610 additions and 1114 deletions
+597
View File
@@ -0,0 +1,597 @@
<template>
<div class="broadcast-page">
<section class="page-head">
<div>
<h1>银城大喇叭</h1>
<p>向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。</p>
</div>
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
</section>
<el-alert
v-if="loadError"
class="data-alert"
type="warning"
:closable="false"
show-icon
title="消息管理暂不可读取。发布与管理仅限 superadmin 或 hr_operator,请确认当前账号权限后重试。"
/>
<section class="table-card">
<div class="section-title">
<div>
<h2>消息管理</h2>
<p>当前支持立即发布、查看版本号和撤回;发布重试不会重复推送,撤回会保留初次审计原因。</p>
</div>
<div class="table-actions">
<el-select v-model="status" class="status-select" placeholder="全部状态" :disabled="publishing" @change="handleStatusChange">
<el-option label="全部状态" value="" />
<el-option label="已发布" value="PUBLISHED" />
<el-option label="已撤回" value="WITHDRAWN" />
</el-select>
<el-button :icon="Refresh" :loading="loading" :disabled="publishing" @click="() => void loadMessages()">刷新</el-button>
</div>
</div>
<el-table v-loading="loading" :data="messages" size="small" empty-text="暂无公司消息">
<el-table-column prop="title" label="标题" min-width="170" show-overflow-tooltip />
<el-table-column label="内容摘要" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.content }}</template>
</el-table-column>
<el-table-column label="版本" width="64" align="center">
<template #default="{ row }">v{{ row.currentVersion }}</template>
</el-table-column>
<el-table-column label="状态" width="76" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'PUBLISHED' ? 'success' : 'info'" size="small">
{{ row.status === 'PUBLISHED' ? '已发布' : '已撤回' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="发布信息" width="140">
<template #default="{ row }">
<div class="audit-info">
<span>管理员 #{{ row.publishedBy }}</span>
<small class="audit-time">{{ displayTime(row.publishedAt) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="撤回信息" width="180">
<template #default="{ row }">
<div v-if="row.withdrawnAt" class="withdraw-info">
<span>管理员 #{{ row.withdrawnBy }}</span>
<small class="audit-time">{{ displayTime(row.withdrawnAt) }}</small>
<small v-if="row.withdrawReason">原因:{{ row.withdrawReason }}</small>
</div>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="76" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'PUBLISHED'" link type="danger" :loading="withdrawingId === row.id" @click="handleWithdraw(row)">
撤回
</el-button>
<span v-else class="muted">已撤回</span>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:page="pageNum" v-model:limit="pageSize" :total="total" @pagination="() => void loadMessages()" />
</section>
<el-dialog
v-model="publishVisible"
title="发布银城大喇叭消息"
width="680px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="!publishing"
:before-close="beforeClosePublish"
>
<el-alert
class="publish-alert"
type="warning"
:closable="false"
show-icon
title="消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。"
/>
<el-alert
v-if="publishPending"
class="publish-alert"
type="error"
:closable="false"
show-icon
title="上一次发布结果尚未确认。系统已保留原请求和内容;请重试发布,或明确放弃后再新建消息。"
/>
<el-form ref="publishFormRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="标题" prop="title">
<el-input
v-model="form.title"
:disabled="publishing || publishPending"
maxlength="200"
show-word-limit
placeholder="例如:夏季服务标准通知"
/>
</el-form-item>
<el-form-item label="正文" prop="content">
<el-input
v-model="form.content"
:disabled="publishing || publishPending"
type="textarea"
:rows="10"
maxlength="10000"
show-word-limit
placeholder="请输入完整通知正文"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="publishing" @click="requestClosePublish">{{ publishPending ? '放弃并关闭' : '取消' }}</el-button>
<el-button type="danger" :loading="publishing" @click="submitPublish">{{ publishPending ? '重试发布' : '确认发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="ContentBroadcast" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import {
listBroadcastAdminMessages,
publishBroadcast,
withdrawBroadcast,
type BroadcastAdminMessage,
type BroadcastStatus
} from '@/api/aihr/broadcast';
import { useUserStore } from '@/store/modules/user';
const messages = ref<BroadcastAdminMessage[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = ref(20);
const status = ref<BroadcastStatus | ''>('');
const loading = ref(false);
const loadError = ref(false);
const publishVisible = ref(false);
const publishing = ref(false);
const publishPending = ref(false);
const withdrawingId = ref<number>();
const publishFormRef = ref<FormInstance>();
const form = reactive({ requestId: '', title: '', content: '' });
const userStore = useUserStore();
const scopeTenantId = ref('');
const draftTenantId = ref('');
const pendingTenantId = ref('');
const storageAvailable = ref(true);
const rules = {
title: [{ required: true, whitespace: true, message: '请输入消息标题', trigger: 'blur' }],
content: [{ required: true, whitespace: true, message: '请输入消息正文', trigger: 'blur' }]
};
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 pendingPublishStoragePrefix = 'aihr.broadcast.pending.v2';
type PendingPublish = { tenantId: string; requestId: string; title: string; content: string };
const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is PendingPublish =>
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 &&
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;
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
tenantId ? `${pendingPublishStoragePrefix}:${tenantId}:${userStore.userId || 'unknown'}` : '';
const safelyReadStorage = (storageKey: string) => {
try {
return storageKey ? localStorage.getItem(storageKey) : null;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
return null;
}
};
const safelyRemoveStorage = (storageKey: string) => {
try {
if (storageKey) localStorage.removeItem(storageKey);
return true;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
return false;
}
};
const persistPendingPublish = () => {
pendingTenantId.value = draftTenantId.value;
const storageKey = pendingPublishStorageKey();
const pending: PendingPublish = {
tenantId: pendingTenantId.value,
requestId: form.requestId,
title: form.title,
content: form.content
};
try {
if (!storageKey) throw new Error('missing tenant scope');
localStorage.setItem(storageKey, JSON.stringify(pending));
return true;
} catch {
storageAvailable.value = false;
pendingTenantId.value = '';
ElMessage.error('无法保存待确认发布记录,请检查浏览器存储权限后重试。');
return false;
}
};
const clearPendingPublish = () => {
const cleared = safelyRemoveStorage(pendingPublishStorageKey());
if (cleared) pendingTenantId.value = '';
return cleared;
};
const readPendingPublish = (): PendingPublish | undefined => {
const storageKey = pendingPublishStorageKey(scopeTenantId.value);
const stored = safelyReadStorage(storageKey);
if (!stored) return undefined;
try {
const candidate = JSON.parse(stored) as Partial<PendingPublish>;
if (!isValidPendingPublish(candidate)) {
throw new Error('invalid pending publish');
}
return {
tenantId: candidate.tenantId,
requestId: candidate.requestId,
title: candidate.title,
content: candidate.content
};
} catch {
safelyRemoveStorage(storageKey);
return undefined;
}
};
const applyPendingPublish = (candidate: PendingPublish) => {
draftTenantId.value = candidate.tenantId;
pendingTenantId.value = candidate.tenantId;
form.requestId = candidate.requestId;
form.title = candidate.title;
form.content = candidate.content;
publishPending.value = true;
publishVisible.value = true;
};
const restorePendingPublish = () => {
if (publishPending.value && pendingTenantId.value === scopeTenantId.value) return false;
const candidate = readPendingPublish();
if (!candidate) return false;
applyPendingPublish(candidate);
return true;
};
const loadMessages = async (allowWhilePublishing = false) => {
if (publishing.value && !allowWhilePublishing) return;
loading.value = true;
loadError.value = false;
try {
const response = await listBroadcastAdminMessages({
pageNum: pageNum.value,
pageSize: pageSize.value,
status: status.value || undefined
});
const resolvedTenantId = String(response.data?.tenantId ?? '').trim();
if (!resolvedTenantId) throw new Error('missing broadcast tenant scope');
if (scopeTenantId.value && scopeTenantId.value !== resolvedTenantId && publishVisible.value) {
// A tenant switch invalidates both an unresolved request and an open draft. Keep an
// unresolved request only in its original v2 storage key; never retarget its content.
publishPending.value = false;
publishVisible.value = false;
draftTenantId.value = '';
pendingTenantId.value = '';
form.requestId = '';
form.title = '';
form.content = '';
}
scopeTenantId.value = resolvedTenantId;
restorePendingPublish();
messages.value = response.data?.rows ?? [];
total.value = response.data?.total ?? 0;
} catch {
messages.value = [];
total.value = 0;
loadError.value = true;
} finally {
loading.value = false;
}
};
const handleStatusChange = () => {
if (publishing.value) return;
pageNum.value = 1;
void loadMessages();
};
const openPublish = () => {
if (publishing.value || publishPending.value) return;
if (!storageAvailable.value) {
ElMessage.error('浏览器存储不可用,无法安全发起公司消息发布。');
return;
}
if (!scopeTenantId.value) {
ElMessage.warning('正在确认当前租户,请稍候。');
return;
}
if (restorePendingPublish()) {
ElMessage.warning('检测到同一租户存在结果未确认的发布,请先重试或明确放弃。');
return;
}
if (!storageAvailable.value) return;
form.requestId = createRequestId();
draftTenantId.value = scopeTenantId.value;
form.title = '';
form.content = '';
publishVisible.value = true;
};
const resetPublishForm = () => {
if (publishPending.value && !clearPendingPublish()) return false;
publishPending.value = false;
draftTenantId.value = '';
form.requestId = '';
form.title = '';
form.content = '';
publishFormRef.value?.clearValidate();
return true;
};
const confirmDiscardPendingPublish = async () => {
if (!publishPending.value) return true;
try {
await ElMessageBox.confirm(
'上一次发布结果尚未确认。继续重试会复用同一请求;放弃后若该请求其实已成功,重新发布可能产生另一条消息。',
'确认放弃未确认发布?',
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续重试', type: 'warning' }
);
return true;
} catch {
return false;
}
};
const requestClosePublish = async () => {
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
if (!resetPublishForm()) return;
publishVisible.value = false;
};
const beforeClosePublish = (done: () => void) => {
void (async () => {
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
if (!resetPublishForm()) return;
done();
})();
};
const submitPublish = async () => {
if (!publishFormRef.value) return;
const valid = await publishFormRef.value
.validate()
.then(() => true)
.catch(() => false);
if (!valid) return;
if (!scopeTenantId.value) {
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
return;
}
if (!draftTenantId.value || draftTenantId.value !== scopeTenantId.value) {
ElMessage.warning('当前租户已变化,已取消这条未发布草稿;请刷新后重新填写。');
publishVisible.value = false;
form.requestId = '';
form.title = '';
form.content = '';
draftTenantId.value = '';
return;
}
const otherPending = readPendingPublish();
if (!storageAvailable.value) return;
if (otherPending && otherPending.requestId !== form.requestId) {
applyPendingPublish(otherPending);
ElMessage.warning('检测到另一窗口存在结果未确认的发布,请先重试或明确放弃。');
return;
}
if (!form.requestId) form.requestId = createRequestId();
form.title = form.title.trim();
form.content = form.content.trim();
publishPending.value = true;
if (!persistPendingPublish()) {
publishPending.value = false;
return;
}
publishing.value = true;
try {
await publishBroadcast({
requestId: form.requestId,
title: form.title,
content: form.content,
expectedTenantId: pendingTenantId.value
});
if (!resetPublishForm()) {
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
return;
}
publishVisible.value = false;
ElMessage.success('消息已发布');
pageNum.value = 1;
await loadMessages(true);
} catch {
ElMessage.error('发布结果暂未确认,请使用“重试发布”复用原请求,或确认放弃。');
} finally {
publishing.value = false;
}
};
const handleWithdraw = async (message: BroadcastAdminMessage) => {
let reason = '';
try {
const result = await ElMessageBox.prompt(`撤回“${message.title}”后,员工端将不再展示该消息。请填写撤回原因。`, '确认撤回', {
confirmButtonText: '确认撤回',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '例如:内容需要修订后重新发布',
inputValidator: (value) => {
const normalized = String(value ?? '').trim();
if (!normalized) return '请填写撤回原因';
if (normalized.length > 500) return '撤回原因不能超过 500 个字符';
return true;
},
type: 'warning'
});
reason = String(result.value ?? '').trim();
} catch {
return;
}
withdrawingId.value = message.id;
try {
if (!scopeTenantId.value) {
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
return;
}
await withdrawBroadcast(message.id, { reason, expectedTenantId: scopeTenantId.value });
ElMessage.success('消息已撤回');
await loadMessages();
} finally {
withdrawingId.value = undefined;
}
};
onMounted(() => {
void loadMessages();
});
</script>
<style scoped lang="scss">
.broadcast-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #172033;
}
.page-head,
.section-title,
.table-actions {
display: flex;
align-items: center;
}
.page-head,
.section-title {
justify-content: space-between;
}
.page-head {
align-items: flex-start;
gap: 18px;
margin-bottom: 18px;
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 24px;
}
p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
}
.data-alert,
.publish-alert {
margin-bottom: 12px;
}
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
.section-title {
gap: 16px;
margin-bottom: 16px;
}
h2 {
margin: 0;
font-size: 18px;
}
p,
.muted {
color: #858991;
font-size: 13px;
}
.audit-info,
.withdraw-info {
display: grid;
gap: 3px;
small {
color: #858991;
font-size: 12px;
}
.audit-time {
white-space: nowrap;
}
}
p {
margin: 6px 0 0;
}
:deep(.el-table th.el-table__cell) {
color: #667085;
font-weight: 500;
background: #fafbfc;
}
}
.table-actions {
flex: none;
gap: 10px;
}
.status-select {
width: 126px;
}
@media (max-width: 900px) {
.page-head,
.section-title {
align-items: stretch;
flex-direction: column;
}
.table-actions {
justify-content: space-between;
}
}
@media (max-width: 760px) {
.broadcast-page {
padding: 16px;
}
}
</style>