599 lines
19 KiB
Vue
599 lines
19 KiB
Vue
<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>
|