feat(aihr): complete company message R1-R3
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
@@ -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[] } };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user