Files
prop-ai-hr/frontend/src/views/content/broadcast.vue
T

599 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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';
import {
broadcastPendingStorageKey,
createPendingBroadcastStorage,
type PendingBroadcastPublish
} from '@/utils/broadcast-pending';
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 isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): candidate is PendingBroadcastPublish =>
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 pendingPublishUserId = () => String(userStore.userId || '').trim();
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
broadcastPendingStorageKey(tenantId, pendingPublishUserId());
const pendingStorage = () => {
if (typeof window === 'undefined') throw new Error('browser storage unavailable');
let legacyLocalStorage: Storage | undefined;
try {
legacyLocalStorage = window.localStorage;
} catch {
// A browser may deny persistent storage while still allowing session storage.
}
return createPendingBroadcastStorage({
sessionStorage: window.sessionStorage,
legacyLocalStorage
});
};
const persistPendingPublish = () => {
pendingTenantId.value = draftTenantId.value;
const pending: PendingBroadcastPublish = {
tenantId: pendingTenantId.value,
requestId: form.requestId,
title: form.title,
content: form.content
};
try {
if (!pendingPublishStorageKey()) throw new Error('missing tenant scope');
pendingStorage().write(pendingPublishUserId(), pending);
return true;
} catch {
storageAvailable.value = false;
pendingTenantId.value = '';
ElMessage.error('无法保存待确认发布记录,请检查浏览器存储权限后重试。');
return false;
}
};
const clearPendingPublish = () => {
try {
const tenantId = pendingTenantId.value || scopeTenantId.value;
if (!pendingPublishStorageKey(tenantId)) throw new Error('missing tenant scope');
pendingStorage().remove(pendingPublishUserId(), tenantId);
pendingTenantId.value = '';
return true;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
return false;
}
};
const readPendingPublish = (): PendingBroadcastPublish | undefined => {
const tenantId = scopeTenantId.value;
try {
if (!pendingPublishStorageKey(tenantId)) return undefined;
const candidate = pendingStorage().read(pendingPublishUserId(), tenantId);
if (!candidate) return undefined;
if (!isValidPendingPublish(candidate)) {
pendingStorage().remove(pendingPublishUserId(), tenantId);
return undefined;
}
return candidate;
} catch {
storageAvailable.value = false;
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
return undefined;
}
};
const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
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>