feat(release): close handler and microphone gaps

This commit is contained in:
key
2026-07-30 12:01:45 +08:00
parent 8934f13811
commit c16a3e83d7
30 changed files with 830 additions and 49 deletions
+56
View File
@@ -26,6 +26,26 @@ export type DirectAdminPage = {
rows: DirectAdminFeedback[];
};
export type DirectHandlerUser = {
userId: number;
userName: string;
nickName?: string;
};
export type DirectHandlerChannel = {
channelCode: DirectChannelCode;
channelName: string;
roleKey: string;
roleAvailable: boolean;
users: DirectHandlerUser[];
};
export type DirectHandlerConfig = {
tenantId: string;
minimumHandlers: number;
channels: DirectHandlerChannel[];
};
export const listDirectAdminFeedback = (params: {
pageNum: number;
pageSize: number;
@@ -48,3 +68,39 @@ export const replyDirectFeedback = (
data,
headers: { repeatSubmit: false }
});
export const getDirectHandlerConfig = (): Promise<ApiResult<DirectHandlerConfig>> =>
request({
url: '/api/aihr/direct/admin/handlers',
method: 'get'
});
export const listDirectHandlerCandidates = (
channelCode: DirectChannelCode,
keyword = ''
): Promise<ApiResult<DirectHandlerUser[]>> =>
request({
url: `/api/aihr/direct/admin/handlers/${channelCode}/candidates`,
method: 'get',
params: { keyword }
});
export const assignDirectHandlers = (
channelCode: DirectChannelCode,
data: { userIds: number[]; expectedTenantId: string }
): Promise<ApiResult<DirectHandlerConfig>> =>
request({
url: `/api/aihr/direct/admin/handlers/${channelCode}`,
method: 'post',
data
});
export const removeDirectHandler = (
channelCode: DirectChannelCode,
data: { userId: number; expectedTenantId: string }
): Promise<ApiResult<DirectHandlerConfig>> =>
request({
url: `/api/aihr/direct/admin/handlers/${channelCode}/remove`,
method: 'post',
data
});
@@ -14,7 +14,9 @@ describe('operations sidebar navigation contract', () => {
expect(sidebar).toContain('<router-link');
expect(sidebar).toContain('canAccessOperations');
expect(sidebar).toContain('operationsConsoleRoles');
expect(router).toContain("export { operationsConsoleRoles, tenantGovernanceRoles } from './access';");
expect(router).toContain(
"export { contentConsoleRoles, directHandlerRoles, operationsConsoleRoles, tenantGovernanceRoles } from './access';"
);
expect(router).toContain('roles: operationsConsoleRoles');
expect(guard).toContain('canAccessRoute(to)');
expect(sidebar).toContain('aria-labelledby');
@@ -16,6 +16,11 @@ describe('直达反馈管理端契约', () => {
expect(page).toContain('senderDisplayName');
expect(page).toContain('匿名反馈不会显示提交人');
expect(page).toContain('回复后不可覆盖');
expect(page).toContain('五通道处理人配置');
expect(api).toContain('/api/aihr/direct/admin/handlers');
expect(page).toContain('移动端员工账号和超级管理员不会进入候选');
expect(page).toContain('assignDirectHandlers');
expect(page).toContain('removeDirectHandler');
expect(access).toContain('direct_audit');
expect(router).toContain("path: 'direct'");
expect(router).toContain('roles: directHandlerRoles');
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import {
directHandlerDefinitions,
directHandlerReadinessText,
directHandlerReady,
minimumDirectHandlers,
normalizeDirectHandlerMinimum
} from './direct-handler-readiness';
describe('五通道处理人就绪规则', () => {
it('固定五个业务通道且每个通道至少需要主处理人和替补', () => {
expect(directHandlerDefinitions.map((item) => item.roleKey)).toEqual([
'direct_president',
'direct_finance',
'direct_hr',
'direct_audit',
'direct_operations'
]);
expect(minimumDirectHandlers).toBe(2);
});
it('少于两名有效处理人时明确提示缺口', () => {
expect(directHandlerReady(0)).toBe(false);
expect(directHandlerReadinessText(0)).toBe('尚未配置');
expect(directHandlerReadinessText(1)).toBe('还差 1 人');
expect(directHandlerReady(2)).toBe(true);
expect(directHandlerReadinessText(3)).toBe('已配置 3 人');
});
it('以后端返回的最低人数为准,异常值回退到默认规则', () => {
expect(directHandlerReady(2, 3)).toBe(false);
expect(directHandlerReadinessText(2, 3)).toBe('还差 1 人');
expect(normalizeDirectHandlerMinimum(3)).toBe(3);
expect(normalizeDirectHandlerMinimum(0)).toBe(minimumDirectHandlers);
expect(normalizeDirectHandlerMinimum(undefined)).toBe(minimumDirectHandlers);
});
});
@@ -0,0 +1,32 @@
export const minimumDirectHandlers = 2;
export const directHandlerDefinitions = [
{ channelCode: 'PRESIDENT', channelName: '总裁直达', roleKey: 'direct_president' },
{ channelCode: 'FINANCE', channelName: '财务直达', roleKey: 'direct_finance' },
{ channelCode: 'HR', channelName: '人力直达', roleKey: 'direct_hr' },
{ channelCode: 'AUDIT', channelName: '审计直达', roleKey: 'direct_audit' },
{ channelCode: 'OPERATIONS', channelName: '运营直达', roleKey: 'direct_operations' }
] as const;
export type DirectHandlerDefinition = (typeof directHandlerDefinitions)[number];
export const normalizeDirectHandlerMinimum = (minimumHandlers: unknown): number =>
typeof minimumHandlers === 'number' && Number.isInteger(minimumHandlers) && minimumHandlers > 0
? minimumHandlers
: minimumDirectHandlers;
export const directHandlerReady = (
activeHandlerCount: number,
minimumHandlers: number = minimumDirectHandlers
): boolean => activeHandlerCount >= normalizeDirectHandlerMinimum(minimumHandlers);
export const directHandlerReadinessText = (
activeHandlerCount: number,
minimumHandlers: number = minimumDirectHandlers
): string => {
const requiredHandlers = normalizeDirectHandlerMinimum(minimumHandlers);
if (directHandlerReady(activeHandlerCount, requiredHandlers)) return `已配置 ${activeHandlerCount} 人`;
return activeHandlerCount === 0
? '尚未配置'
: `还差 ${requiredHandlers - activeHandlerCount} 人`;
};
+240 -4
View File
@@ -15,6 +15,104 @@
title="每条反馈只允许一条正式回复。回复后不可覆盖,避免处理口径被静默修改。"
/>
<section v-if="isSuperAdmin" class="handler-panel panel">
<div class="handler-head">
<div>
<h2>五通道处理人配置</h2>
<p>每个通道至少配置 {{ handlerMinimumHandlers }} 名有效后台用户,分别承担主处理和替补;配置角色不会自动产生回复或工单。</p>
</div>
<el-button :icon="Refresh" :loading="handlerLoading" @click="loadHandlerConfig">刷新配置</el-button>
</div>
<el-alert
:type="allHandlersReady ? 'success' : 'warning'"
:closable="false"
show-icon
:title="allHandlersReady ? `五个通道均已配置至少 ${handlerMinimumHandlers} 名有效处理人` : `${readyHandlerChannelCount}/5 个通道达到处理人配置要求`"
/>
<div v-loading="handlerLoading" class="handler-grid">
<article v-for="item in handlerConfigs" :key="item.roleKey" class="handler-card">
<div class="handler-card-head">
<div>
<strong>{{ item.channelName }}</strong>
<small>{{ item.roleKey }}</small>
</div>
<el-tag :type="directHandlerReady(item.users.length, handlerMinimumHandlers) ? 'success' : 'danger'">
{{ directHandlerReadinessText(item.users.length, handlerMinimumHandlers) }}
</el-tag>
</div>
<div v-if="item.users.length" class="handler-users">
<div v-for="user in item.users" :key="user.userId" class="handler-user">
<span>{{ user.nickName || user.userName }}</span>
<small>{{ user.userName }}</small>
<el-button
link
type="danger"
:disabled="handlerMutating"
@click="removeHandler(item, user)"
>
解除
</el-button>
</div>
</div>
<div v-else class="handler-empty">尚无有效处理人,员工提交后将无人接收。</div>
<el-button
type="primary"
plain
:disabled="!item.roleAvailable || handlerMutating"
@click="openHandlerSelector(item)"
>
添加处理人
</el-button>
<small v-if="!item.roleAvailable" class="handler-role-missing">角色不存在或已停用,请先检查系统角色。</small>
</article>
</div>
<el-dialog
v-model="handlerDialogVisible"
:title="selectedHandlerChannel ? `添加${selectedHandlerChannel.channelName}处理人` : '添加处理人'"
width="620px"
destroy-on-close
>
<div class="candidate-toolbar">
<el-input
v-model="candidateKeyword"
clearable
placeholder="按用户名或昵称搜索后台用户"
@keyup.enter="loadHandlerCandidates"
/>
<el-button :loading="candidateLoading" @click="loadHandlerCandidates">搜索</el-button>
</div>
<el-alert
type="info"
:closable="false"
show-icon
title="仅列出当前租户已启用的后台用户;移动端员工账号和超级管理员不会进入候选。"
/>
<el-checkbox-group v-model="selectedCandidateUserIds" class="candidate-list">
<el-checkbox
v-for="candidate in handlerCandidates"
:key="candidate.userId"
:value="candidate.userId"
border
>
{{ candidate.nickName || candidate.userName }}
<small>{{ candidate.userName }}</small>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!candidateLoading && !handlerCandidates.length" description="暂无可添加的后台用户" />
<template #footer>
<el-button @click="handlerDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="handlerMutating"
:disabled="!selectedCandidateUserIds.length"
@click="assignSelectedHandlers"
>
确认添加
</el-button>
</template>
</el-dialog>
</section>
<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" />
@@ -104,14 +202,31 @@
<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 { computed, onMounted, ref } from 'vue';
import {
assignDirectHandlers,
getDirectHandlerConfig,
listDirectHandlerCandidates,
listDirectAdminFeedback,
removeDirectHandler,
replyDirectFeedback,
type DirectAdminFeedback,
type DirectChannelCode,
type DirectFeedbackStatus
type DirectFeedbackStatus,
type DirectHandlerChannel,
type DirectHandlerConfig as DirectHandlerConfigResponse,
type DirectHandlerUser
} from '@/api/aihr/direct';
import { useUserStore } from '@/store/modules/user';
import {
directHandlerDefinitions,
directHandlerReadinessText,
directHandlerReady,
minimumDirectHandlers,
normalizeDirectHandlerMinimum
} from '@/utils/direct-handler-readiness';
type DirectHandlerConfig = DirectHandlerChannel;
const channels: Array<{ value: DirectChannelCode; label: string }> = [
{ value: 'PRESIDENT', label: '总裁直达' },
@@ -132,9 +247,127 @@ const drawerVisible = ref(false);
const selected = ref<DirectAdminFeedback>();
const replyContent = ref('');
const replying = ref(false);
const userStore = useUserStore();
const isSuperAdmin = computed(() => userStore.roles.includes('superadmin'));
const handlerLoading = ref(false);
const handlerMutating = ref(false);
const handlerTenantId = ref('');
const handlerMinimumHandlers = ref(minimumDirectHandlers);
const handlerDialogVisible = ref(false);
const selectedHandlerChannel = ref<DirectHandlerConfig>();
const candidateKeyword = ref('');
const candidateLoading = ref(false);
const handlerCandidates = ref<DirectHandlerUser[]>([]);
const selectedCandidateUserIds = ref<number[]>([]);
const handlerConfigs = ref<DirectHandlerConfig[]>(
directHandlerDefinitions.map((item) => ({ ...item, roleAvailable: false, users: [] }))
);
const readyHandlerChannelCount = computed(() =>
handlerConfigs.value.filter((item) =>
directHandlerReady(item.users.length, handlerMinimumHandlers.value)
).length
);
const allHandlersReady = computed(() => readyHandlerChannelCount.value === directHandlerDefinitions.length);
const displayTime = (value?: string) => value ? value.replace('T', ' ').slice(0, 16) : '—';
const applyHandlerConfig = (config: DirectHandlerConfigResponse) => {
handlerTenantId.value = config.tenantId;
handlerMinimumHandlers.value = normalizeDirectHandlerMinimum(config.minimumHandlers);
handlerConfigs.value = config.channels || [];
};
const loadHandlerConfig = async () => {
if (!isSuperAdmin.value || handlerLoading.value) return;
handlerLoading.value = true;
try {
const result = await getDirectHandlerConfig();
applyHandlerConfig(result.data);
} catch {
handlerTenantId.value = '';
handlerMinimumHandlers.value = minimumDirectHandlers;
handlerConfigs.value = directHandlerDefinitions.map((item) => ({ ...item, roleAvailable: false, users: [] }));
ElMessage.error('五通道处理人配置读取失败');
} finally {
handlerLoading.value = false;
}
};
const openHandlerSelector = (item: DirectHandlerConfig) => {
if (!item.roleAvailable) return;
selectedHandlerChannel.value = item;
candidateKeyword.value = '';
selectedCandidateUserIds.value = [];
handlerCandidates.value = [];
handlerDialogVisible.value = true;
void loadHandlerCandidates();
};
const loadHandlerCandidates = async () => {
if (!selectedHandlerChannel.value || candidateLoading.value) return;
candidateLoading.value = true;
try {
const result = await listDirectHandlerCandidates(
selectedHandlerChannel.value.channelCode,
candidateKeyword.value.trim()
);
handlerCandidates.value = result.data || [];
selectedCandidateUserIds.value = selectedCandidateUserIds.value.filter((userId) =>
handlerCandidates.value.some((candidate) => candidate.userId === userId)
);
} catch {
handlerCandidates.value = [];
selectedCandidateUserIds.value = [];
ElMessage.error('候选处理人读取失败');
} finally {
candidateLoading.value = false;
}
};
const assignSelectedHandlers = async () => {
if (!selectedHandlerChannel.value || !selectedCandidateUserIds.value.length || handlerMutating.value) return;
handlerMutating.value = true;
try {
const result = await assignDirectHandlers(selectedHandlerChannel.value.channelCode, {
userIds: selectedCandidateUserIds.value,
expectedTenantId: handlerTenantId.value
});
applyHandlerConfig(result.data);
handlerDialogVisible.value = false;
ElMessage.success('处理人已绑定;新角色将在该用户下次登录后生效');
} finally {
handlerMutating.value = false;
}
};
const removeHandler = async (item: DirectHandlerConfig, user: DirectHandlerUser) => {
if (!item.roleAvailable || handlerMutating.value) return;
const remainingHandlers = Math.max(0, item.users.length - 1);
const readinessWarning = directHandlerReady(remainingHandlers, handlerMinimumHandlers.value)
? ''
: `\n解除后该通道仅剩 ${remainingHandlers} 名有效处理人,不满足主处理和替补要求。`;
await ElMessageBox.confirm(
`确认解除“${user.nickName || user.userName}”的${item.channelName}处理权限?${readinessWarning}`,
'解除处理人',
{
type: 'warning',
confirmButtonText: '确认解除',
cancelButtonText: '取消'
}
);
handlerMutating.value = true;
try {
const result = await removeDirectHandler(item.channelCode, {
userId: user.userId,
expectedTenantId: handlerTenantId.value
});
applyHandlerConfig(result.data);
ElMessage.success('处理人权限已解除;已登录会话可能需要重新登录后更新');
} finally {
handlerMutating.value = false;
}
};
const load = async () => {
loading.value = true;
try {
@@ -188,9 +421,12 @@ const submitReply = async () => {
}
};
onMounted(load);
onMounted(() => {
void load();
void loadHandlerConfig();
});
</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}}
.direct-admin-page{padding:24px;color:#172033}.page-head,.filter-panel,.detail-meta,.handler-head,.handler-card-head,.handler-user,.candidate-toolbar{display:flex;align-items:center;gap:12px}.page-head,.handler-head{justify-content:space-between;margin-bottom:16px}.page-head h1,.handler-head h2{margin:0}.page-head h1{font-size:28px}.handler-head h2{font-size:20px}.page-head p,.handler-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)}.handler-panel{margin-top:14px;padding:18px}.handler-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;min-height:120px;margin-top:14px}.handler-card{display:flex;min-width:0;flex-direction:column;gap:12px;padding:14px;border:1px solid #e4e7ec;border-radius:10px;background:#f8fafc}.handler-card-head{align-items:flex-start;justify-content:space-between}.handler-card-head strong,.handler-card-head small{display:block}.handler-card-head small,.handler-user small,.handler-role-missing,.candidate-list small{color:#667085;font-size:11px}.handler-users{display:grid;gap:7px}.handler-user{min-width:0;padding:7px 8px;border-radius:8px;background:#fff}.handler-user span{min-width:0;overflow:hidden;flex:1;text-overflow:ellipsis;white-space:nowrap}.handler-user small{display:none}.handler-empty{min-height:38px;color:#b42318;font-size:12px;line-height:1.55}.candidate-toolbar{margin-bottom:12px}.candidate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:14px}.candidate-list :deep(.el-checkbox){box-sizing:border-box;width:100%;height:auto;min-height:44px;margin:0}.candidate-list small{display:block;margin-top:2px}.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:1400px){.handler-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:900px){.direct-admin-page{padding:16px}.page-head,.handler-head{align-items:flex-start}.filter-panel{flex-wrap:wrap}.handler-grid,.candidate-list{grid-template-columns:1fr}}
</style>
+2 -1
View File
@@ -3,7 +3,8 @@ import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
const src = (...parts: string[]) =>
readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8').replace(/\r\n/g, '\n');
describe('knowledge space administration contract', () => {
it('registers the controlled route and sidebar entry', () => {