feat(aihr): complete company message R1-R3

This commit is contained in:
2026-07-24 23:25:15 +08:00
parent 5d864a1444
commit 342f84a9de
37 changed files with 2479 additions and 42 deletions
+32
View File
@@ -42,6 +42,17 @@ export type BroadcastAdminMessage = {
unmatchedTargetCount: number;
};
export type BroadcastAttachment = {
id: number;
fileName: string;
fileSize: number;
contentType: string;
status: 'QUEUED' | 'PROCESSING' | 'READY' | 'FAILED';
summary?: string;
errorMessage?: string;
downloadUrl?: string;
};
export type BroadcastAdminPage = {
/** Effective request tenant, including an administrator's dynamic-tenant selection. */
tenantId: string;
@@ -58,6 +69,7 @@ export type PublishBroadcastRequest = {
expectedTenantId: string;
requiredRead: boolean;
targets: BroadcastTargetRequest;
attachmentId?: number;
};
export type WithdrawBroadcastRequest = {
@@ -84,6 +96,26 @@ export function loadBroadcastTargetOptions(): Promise<ApiResult<BroadcastTargetO
});
}
export function uploadBroadcastAttachment(file: File, expectedTenantId: string): Promise<ApiResult<BroadcastAttachment>> {
const data = new FormData();
data.append('file', file);
return request({
url: '/api/aihr/broadcast/admin/attachments',
method: 'post',
params: { expectedTenantId },
data,
timeout: 120000,
headers: { repeatSubmit: false }
});
}
export function getBroadcastAttachment(id: number): Promise<ApiResult<BroadcastAttachment>> {
return request({
url: `/api/aihr/broadcast/admin/attachments/${id}`,
method: 'get'
});
}
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{
id: number;
version: number;
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request';
type ApiResult<T> = { code: number; msg: string; data: T };
export type DirectFeedbackStatus = 'SUBMITTED' | 'REPLIED';
export type DirectChannelCode = 'PRESIDENT' | 'FINANCE' | 'HR' | 'AUDIT' | 'OPERATIONS';
export type DirectAdminFeedback = {
id: number;
channelCode: DirectChannelCode;
channelName: string;
senderDisplayName: string;
anonymous: boolean;
content: string;
status: DirectFeedbackStatus;
replyContent?: string;
repliedAt?: string;
createdAt: string;
};
export type DirectAdminPage = {
tenantId: string;
total: number;
pageNum: number;
pageSize: number;
rows: DirectAdminFeedback[];
};
export const listDirectAdminFeedback = (params: {
pageNum: number;
pageSize: number;
status?: DirectFeedbackStatus;
channelCode?: DirectChannelCode;
}): Promise<ApiResult<DirectAdminPage>> =>
request({
url: '/api/aihr/direct/admin/feedback',
method: 'get',
params
});
export const replyDirectFeedback = (
id: number,
data: { content: string; expectedTenantId: string }
): Promise<ApiResult<DirectAdminFeedback>> =>
request({
url: `/api/aihr/direct/admin/feedback/${id}/reply`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
+9
View File
@@ -1,4 +1,13 @@
export const operationsConsoleRoles = ['superadmin', 'hr_operator'];
export const directHandlerRoles = [
'superadmin',
'direct_president',
'direct_finance',
'direct_hr',
'direct_audit',
'direct_operations'
];
export const contentConsoleRoles = [...new Set([...operationsConsoleRoles, ...directHandlerRoles])];
export const tenantGovernanceRoles = ['superadmin'];
type MatchedRoute = { meta?: { roles?: readonly string[] } };
+10 -4
View File
@@ -1,9 +1,9 @@
import { createWebHistory, createRouter, RouteRecordRaw } from 'vue-router';
/* Layout */
import Layout from '@/layout/index.vue';
import { operationsConsoleRoles, tenantGovernanceRoles } from './access';
import { contentConsoleRoles, directHandlerRoles, operationsConsoleRoles, tenantGovernanceRoles } from './access';
export { operationsConsoleRoles, tenantGovernanceRoles } from './access';
export { contentConsoleRoles, directHandlerRoles, operationsConsoleRoles, tenantGovernanceRoles } from './access';
/**
* Note: 路由配置项
@@ -213,13 +213,19 @@ export const constantRoutes: RouteRecordRaw[] = [
component: Layout,
redirect: '/content/broadcast',
alwaysShow: true,
meta: { title: '内容运营', icon: 'message', roles: operationsConsoleRoles },
meta: { title: '内容运营', icon: 'message', roles: contentConsoleRoles },
children: [
{
path: 'broadcast',
component: () => import('@/views/content/broadcast.vue'),
name: 'ContentBroadcast',
meta: { title: '银城大喇叭', icon: 'message' }
meta: { title: '银城大喇叭', icon: 'message', roles: operationsConsoleRoles }
},
{
path: 'direct',
component: () => import('@/views/content/direct.vue'),
name: 'ContentDirect',
meta: { title: '直达反馈', icon: 'message', roles: directHandlerRoles }
}
]
},
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
describe('公司文件提炼发布契约', () => {
it('上传后轮询提炼状态,并将同一附件ID纳入幂等发布', () => {
const api = read('src/api/aihr/broadcast.ts');
const page = read('src/views/content/broadcast.vue');
const pending = read('src/utils/broadcast-pending.ts');
expect(api).toContain('/api/aihr/broadcast/admin/attachments');
expect(api).toContain('attachmentId?: number');
expect(page).toContain('上传并提炼文件');
expect(page).toContain('refreshAttachment');
expect(page).toContain("attachment.value?.status !== 'READY'");
expect(page).toContain('attachmentId: form.attachmentId');
expect(pending).toContain('attachmentId?: number');
});
});
+5 -1
View File
@@ -9,6 +9,7 @@ export interface PendingBroadcastPublish {
content: string;
requiredRead: boolean;
targets: BroadcastTargetRequest;
attachmentId?: number;
}
export interface BroadcastPendingStorageOptions {
@@ -73,7 +74,10 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde
title: candidate.title,
content: candidate.content,
requiredRead,
targets
targets,
attachmentId: Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0
? Number(candidate.attachmentId)
: undefined
};
} catch {
return undefined;
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
describe('直达反馈管理端契约', () => {
it('按授权角色进入,匿名不暴露身份,正式回复携带租户确认', () => {
const api = read('src/api/aihr/direct.ts');
const page = read('src/views/content/direct.vue');
const access = read('src/router/access.ts');
const router = read('src/router/index.ts');
expect(api).toContain('/api/aihr/direct/admin/feedback');
expect(api).toContain('expectedTenantId');
expect(page).toContain('senderDisplayName');
expect(page).toContain('匿名反馈不会显示提交人');
expect(page).toContain('回复后不可覆盖');
expect(access).toContain('direct_audit');
expect(router).toContain("path: 'direct'");
expect(router).toContain('roles: directHandlerRoles');
});
});
+154 -4
View File
@@ -135,6 +135,47 @@
placeholder="请输入完整通知正文"
/>
</el-form-item>
<el-form-item label="公司文件(可选,仅 1 个)">
<div class="attachment-field">
<el-upload
v-if="!attachment"
:show-file-list="false"
:http-request="uploadAttachment"
accept=".txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
:disabled="publishing || publishPending || attachmentUploading"
>
<el-button :loading="attachmentUploading">上传并提炼文件</el-button>
</el-upload>
<div v-else class="attachment-state">
<div>
<strong>{{ attachment.fileName }}</strong>
<p>
{{ attachment.status === 'READY' ? '提炼完成' : attachment.status === 'FAILED' ? '提炼失败' : '正在提炼…' }}
· {{ formatFileSize(attachment.fileSize) }}
</p>
</div>
<el-tag :type="attachment.status === 'READY' ? 'success' : attachment.status === 'FAILED' ? 'danger' : 'warning'">
{{ attachment.status }}
</el-tag>
<el-button v-if="!publishPending" link type="danger" @click="clearAttachment">移除</el-button>
</div>
<el-alert
v-if="attachment?.summary"
type="success"
:closable="false"
show-icon
:title="`提炼摘要:${attachment.summary}`"
/>
<el-alert
v-if="attachment?.status === 'FAILED'"
type="error"
:closable="false"
show-icon
:title="attachment.errorMessage || '文件提炼失败,请移除后重新上传'"
/>
<p class="attachment-note">支持 TXT、Markdown、PDF、Word、Excel、PPT,最大 100MB;提炼完成后才能随消息发布。</p>
</div>
</el-form-item>
<el-form-item label="阅读要求">
<el-checkbox v-model="form.requiredRead" :disabled="publishing || publishPending">设为必读(员工端会优先显示未读内容)</el-checkbox>
</el-form-item>
@@ -200,13 +241,16 @@
<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 type { FormInstance, UploadRequestOptions } from 'element-plus';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import {
getBroadcastAttachment,
listBroadcastAdminMessages,
loadBroadcastTargetOptions,
publishBroadcast,
uploadBroadcastAttachment,
withdrawBroadcast,
type BroadcastAttachment,
type BroadcastAdminMessage,
type BroadcastTargetRequest,
type BroadcastTargetOptions,
@@ -233,10 +277,14 @@ const withdrawingId = ref<number>();
const publishFormRef = ref<FormInstance>();
const targetOptionsLoading = ref(false);
const targetOptions = ref<BroadcastTargetOptions>({ projects: [], positionNames: [], positionLevels: [] });
const attachment = ref<BroadcastAttachment>();
const attachmentUploading = ref(false);
let attachmentPollTimer: number | undefined;
const form = reactive({
requestId: '',
title: '',
content: '',
attachmentId: undefined as number | undefined,
requiredRead: false,
targets: { projectCodes: [] as string[], positionNames: [] as string[], positionLevels: [] as string[] }
});
@@ -276,7 +324,9 @@ const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): can
/^[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.requiredRead === 'boolean' && isValidTargets(candidate.targets);
typeof candidate.requiredRead === 'boolean' &&
(candidate.attachmentId === undefined || (Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0)) &&
isValidTargets(candidate.targets);
const pendingPublishUserId = () => String(userStore.userId || '').trim();
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
@@ -303,6 +353,7 @@ const persistPendingPublish = () => {
requestId: form.requestId,
title: form.title,
content: form.content,
attachmentId: form.attachmentId,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
};
@@ -356,12 +407,14 @@ const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
form.requestId = candidate.requestId;
form.title = candidate.title;
form.content = candidate.content;
form.attachmentId = candidate.attachmentId;
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;
if (candidate.attachmentId) void refreshAttachment(candidate.attachmentId);
};
const restorePendingPublish = () => {
@@ -420,6 +473,7 @@ const loadMessages = async (allowWhilePublishing = false) => {
form.requestId = '';
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
}
scopeTenantId.value = resolvedTenantId;
@@ -461,6 +515,7 @@ const openPublish = () => {
draftTenantId.value = scopeTenantId.value;
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
publishVisible.value = true;
};
@@ -472,6 +527,7 @@ const resetPublishForm = () => {
form.requestId = '';
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
publishFormRef.value?.clearValidate();
return true;
@@ -522,6 +578,7 @@ const submitPublish = async () => {
form.requestId = '';
form.title = '';
form.content = '';
clearAttachment();
resetTargetForm();
draftTenantId.value = '';
return;
@@ -534,6 +591,10 @@ const submitPublish = async () => {
return;
}
if (!form.requestId) form.requestId = createRequestId();
if (form.attachmentId && attachment.value?.status !== 'READY') {
ElMessage.warning('公司文件仍在提炼中,完成后才能发布。');
return;
}
form.title = form.title.trim();
form.content = form.content.trim();
publishPending.value = true;
@@ -551,7 +612,8 @@ const submitPublish = async () => {
content: form.content,
expectedTenantId: pendingTenantId.value,
requiredRead: form.requiredRead,
targets: copyTargets(form.targets)
targets: copyTargets(form.targets),
attachmentId: form.attachmentId
});
if (!resetPublishForm()) {
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
@@ -606,9 +668,60 @@ const handleWithdraw = async (message: BroadcastAdminMessage) => {
}
};
const formatFileSize = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
const stopAttachmentPolling = () => {
if (attachmentPollTimer !== undefined) window.clearTimeout(attachmentPollTimer);
attachmentPollTimer = undefined;
};
const refreshAttachment = async (id: number, attemptsLeft = 80) => {
stopAttachmentPolling();
try {
const response = await getBroadcastAttachment(id);
attachment.value = response.data;
form.attachmentId = response.data.id;
if ((response.data.status === 'QUEUED' || response.data.status === 'PROCESSING') && attemptsLeft > 0) {
attachmentPollTimer = window.setTimeout(() => void refreshAttachment(id, attemptsLeft - 1), 1500);
}
} catch {
attachment.value = undefined;
form.attachmentId = undefined;
ElMessage.error('公司文件状态读取失败,请重新上传。');
}
};
const uploadAttachment = async (options: UploadRequestOptions) => {
if (!scopeTenantId.value || attachmentUploading.value) return;
attachmentUploading.value = true;
try {
const response = await uploadBroadcastAttachment(options.file, scopeTenantId.value);
attachment.value = response.data;
form.attachmentId = response.data.id;
ElMessage.success('文件已上传,正在提炼内容');
void refreshAttachment(response.data.id);
} catch (error) {
options.onError(error instanceof Error ? error : new Error('文件上传失败'));
ElMessage.error('公司文件上传失败');
} finally {
attachmentUploading.value = false;
}
};
const clearAttachment = () => {
stopAttachmentPolling();
attachment.value = undefined;
form.attachmentId = undefined;
};
onMounted(() => {
void loadMessages();
});
onUnmounted(stopAttachmentPolling);
</script>
<style scoped lang="scss">
@@ -670,6 +783,43 @@ onMounted(() => {
gap: 10px;
}
.attachment-field {
display: grid;
width: 100%;
gap: 10px;
}
.attachment-state {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e4e7ec;
border-radius: 8px;
div {
flex: 1;
min-width: 0;
}
strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 4px 0 0;
}
}
.attachment-note {
margin: 0;
color: #858991;
font-size: 12px;
}
.table-card {
padding: 18px;
border-radius: 8px;
+196
View File
@@ -0,0 +1,196 @@
<template>
<div class="direct-admin-page">
<section class="page-head">
<div>
<h1>直达反馈</h1>
<p>按当前账号被授权的部门查看和回复员工点对点反馈;匿名反馈不会显示提交人。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</section>
<el-alert
type="info"
:closable="false"
show-icon
title="每条反馈只允许一条正式回复。回复后不可覆盖,避免处理口径被静默修改。"
/>
<section class="filter-panel panel">
<el-select v-model="channelCode" clearable placeholder="全部授权通道" @change="resetAndLoad">
<el-option v-for="item in channels" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="resetAndLoad">
<el-option label="待处理" value="SUBMITTED" />
<el-option label="已回复" value="REPLIED" />
</el-select>
</section>
<section class="panel table-panel">
<el-table v-loading="loading" :data="items" empty-text="暂无直达反馈" row-key="id">
<el-table-column prop="channelName" label="直达对象" width="120" />
<el-table-column prop="senderDisplayName" label="提交人" width="120">
<template #default="{ row }">
<el-tag :type="row.anonymous ? 'warning' : 'info'">{{ row.senderDisplayName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="反馈内容" min-width="320" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'REPLIED' ? 'success' : 'danger'">
{{ row.status === 'REPLIED' ? '已回复' : '待处理' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="170">
<template #default="{ row }">{{ displayTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openFeedback(row)">
{{ row.status === 'REPLIED' ? '查看' : '回复' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
class="pagination"
v-model:current-page="pageNum"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="load"
/>
</section>
<el-drawer v-model="drawerVisible" title="直达反馈详情" size="560px">
<template v-if="selected">
<div class="detail-meta">
<el-tag>{{ selected.channelName }}</el-tag>
<el-tag :type="selected.anonymous ? 'warning' : 'info'">{{ selected.senderDisplayName }}</el-tag>
<el-tag :type="selected.status === 'REPLIED' ? 'success' : 'danger'">
{{ selected.status === 'REPLIED' ? '已回复' : '待处理' }}
</el-tag>
</div>
<p class="detail-time">{{ displayTime(selected.createdAt) }}</p>
<section class="feedback-copy">{{ selected.content }}</section>
<template v-if="selected.status === 'REPLIED'">
<h3>正式回复</h3>
<section class="reply-copy">{{ selected.replyContent }}</section>
<p class="detail-time">回复于 {{ displayTime(selected.repliedAt) }}</p>
</template>
<template v-else>
<el-form label-position="top" class="reply-form">
<el-form-item label="正式回复">
<el-input
v-model="replyContent"
type="textarea"
:rows="7"
maxlength="5000"
show-word-limit
placeholder="请输入可直接回传给员工的正式答复"
/>
</el-form-item>
</el-form>
<el-button type="danger" :loading="replying" :disabled="!replyContent.trim()" @click="submitReply">
确认回复
</el-button>
</template>
</template>
</el-drawer>
</div>
</template>
<script setup name="ContentDirect" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { onMounted, ref } from 'vue';
import {
listDirectAdminFeedback,
replyDirectFeedback,
type DirectAdminFeedback,
type DirectChannelCode,
type DirectFeedbackStatus
} from '@/api/aihr/direct';
const channels: Array<{ value: DirectChannelCode; label: string }> = [
{ value: 'PRESIDENT', label: '总裁直达' },
{ value: 'FINANCE', label: '财务直达' },
{ value: 'HR', label: '人力直达' },
{ value: 'AUDIT', label: '审计直达' },
{ value: 'OPERATIONS', label: '运营直达' }
];
const items = ref<DirectAdminFeedback[]>([]);
const total = ref(0);
const pageNum = ref(1);
const pageSize = 20;
const tenantId = ref('');
const status = ref<DirectFeedbackStatus | ''>('');
const channelCode = ref<DirectChannelCode | ''>('');
const loading = ref(false);
const drawerVisible = ref(false);
const selected = ref<DirectAdminFeedback>();
const replyContent = ref('');
const replying = ref(false);
const displayTime = (value?: string) => value ? value.replace('T', ' ').slice(0, 16) : '—';
const load = async () => {
loading.value = true;
try {
const result = await listDirectAdminFeedback({
pageNum: pageNum.value,
pageSize,
status: status.value || undefined,
channelCode: channelCode.value || undefined
});
tenantId.value = result.data.tenantId;
items.value = result.data.rows || [];
total.value = result.data.total || 0;
} catch {
items.value = [];
total.value = 0;
ElMessage.error('直达反馈读取失败');
} finally {
loading.value = false;
}
};
const resetAndLoad = () => {
pageNum.value = 1;
void load();
};
const openFeedback = (item: DirectAdminFeedback) => {
selected.value = item;
replyContent.value = '';
drawerVisible.value = true;
};
const submitReply = async () => {
if (!selected.value || !replyContent.value.trim() || replying.value) return;
await ElMessageBox.confirm('回复后不能覆盖或修改,确认提交这条正式回复?', '确认回复', {
type: 'warning',
confirmButtonText: '确认回复',
cancelButtonText: '取消'
});
replying.value = true;
try {
const result = await replyDirectFeedback(selected.value.id, {
content: replyContent.value.trim(),
expectedTenantId: tenantId.value
});
selected.value = result.data;
ElMessage.success('正式回复已回传给员工');
await load();
} finally {
replying.value = false;
}
};
onMounted(load);
</script>
<style scoped>
.direct-admin-page{padding:24px;color:#172033}.page-head,.filter-panel,.detail-meta{display:flex;align-items:center;gap:12px}.page-head{justify-content:space-between;margin-bottom:16px}.page-head h1{margin:0;font-size:28px}.page-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.filter-panel{margin-top:14px;padding:14px}.filter-panel .el-select{width:180px}.table-panel{margin-top:14px;padding:8px}.pagination{justify-content:flex-end;padding:14px}.detail-meta{flex-wrap:wrap}.detail-time{color:#667085;font-size:13px}.feedback-copy,.reply-copy{white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border-radius:10px;background:#f7f8fa;line-height:1.8}.reply-copy{border-left:3px solid #c91825;background:#fff7f7}.reply-form{margin-top:24px}@media(max-width:900px){.direct-admin-page{padding:16px}.page-head{align-items:flex-start}.filter-panel{flex-wrap:wrap}}
</style>