feat(aihr): admin dashboard with real aggregates

新增 /api/aihr/dashboard 聚合面试/对练/知识库/组织快照真实数据,
管理端首页从静态卡片改为 API 优先 + fallback。
This commit is contained in:
2026-07-04 16:05:50 +08:00
parent 0539adf612
commit d622c7fb7e
5 changed files with 540 additions and 45 deletions
+74
View File
@@ -0,0 +1,74 @@
import request from '@/utils/request';
type ApiResult<T> = {
code: number;
msg: string;
data: T;
};
export type DashboardHeroEntry = {
key: string;
status: string;
type: string;
note: string;
};
export type DashboardStage = {
step: number;
name: string;
desc: string;
label: string;
state: string;
tagType: string;
};
export type DashboardRisk = {
name: string;
owner: string;
status: string;
type: string;
};
export type DashboardTrainingRecord = {
name: string;
scene: string;
score: number;
status: string;
state: string;
time: string;
};
export type DashboardPipelineStep = {
name: string;
count: string;
state: string;
};
export type DashboardMetric = {
label: string;
value: string;
target: string;
type: string;
};
export type DashboardOverview = {
snapshotTime: string;
heroEntries: DashboardHeroEntry[];
stages: DashboardStage[];
risks: DashboardRisk[];
records: DashboardTrainingRecord[];
pipeline: DashboardPipelineStep[];
pipelineSummary: {
todayNew: string;
pending: string;
completed: string;
};
metrics: DashboardMetric[];
};
export function getDashboardOverview(): Promise<ApiResult<DashboardOverview>> {
return request({
url: '/api/aihr/dashboard/overview',
method: 'get'
});
}
+98 -45
View File
@@ -1,8 +1,8 @@
<template>
<div class="demo-home">
<section class="toolbar-meta">
<span class="snapshot-time">数据截至:2026-07-02 10:00:00</span>
<el-button text :icon="Refresh">刷新</el-button>
<span class="snapshot-time">数据截至:{{ displayDashboard.snapshotTime || '暂无实时数据' }}</span>
<el-button text :icon="Refresh" :loading="dashboardLoading" @click="loadDashboard">刷新</el-button>
</section>
<section class="hero-grid">
@@ -66,7 +66,7 @@
<div class="panel-title compact">
<h2>最近训练记录</h2>
</div>
<el-table :data="records" size="small" class="record-table">
<el-table :data="records" size="small" class="record-table" empty-text="暂无训练记录">
<el-table-column prop="name" label="人员" min-width="92" />
<el-table-column prop="scene" label="场景" min-width="118" />
<el-table-column prop="score" label="得分" width="68" />
@@ -98,9 +98,9 @@
</div>
</div>
<div class="pipeline-summary">
<span>今日新增:12 条</span>
<span>待审核:2 条</span>
<span>已入库:128 条</span>
<span>今日新增:{{ pipelineSummary.todayNew }}</span>
<span>待审核:{{ pipelineSummary.pending }}</span>
<span>已入库:{{ pipelineSummary.completed }}</span>
</div>
<div class="panel-link">
进入案例沉淀 <el-icon><ArrowRight /></el-icon>
@@ -151,12 +151,52 @@ import {
UserFilled,
VideoCamera
} from '@element-plus/icons-vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getDashboardOverview, type DashboardMetric, type DashboardOverview, type DashboardRisk } from '@/api/aihr/dashboard';
const router = useRouter();
const heroEntries = [
const emptyDashboard: DashboardOverview = {
snapshotTime: '',
heroEntries: [],
stages: [
{ step: 1, name: '选:AI面试', desc: '题目与评估', label: '待接入', state: 'idle', tagType: 'info' },
{ step: 2, name: '用:岗位适配', desc: '组织快照', label: '待同步', state: 'idle', tagType: 'info' },
{ step: 3, name: '育:三角色对练', desc: '实战陪练', label: '待演示', state: 'active', tagType: 'danger' },
{ step: 4, name: '留:成长追踪', desc: '能力画像', label: '待积累', state: 'idle', tagType: 'info' },
{ step: 5, name: '案例沉淀', desc: '知识与复用', label: '待入库', state: 'idle', tagType: 'info' }
],
risks: [
{ name: '住宅 SOP 素材', owner: '培训组', status: '0/5', type: 'danger' },
{ name: '方言 ASR', owner: '技术组', status: '未配置', type: 'warning' },
{ name: '候选资料审核', owner: 'HR组', status: '无待审', type: 'success' },
{ name: '移动训练复盘', owner: '主管', status: '无待复盘', type: 'success' }
],
records: [],
pipeline: [
{ name: '语音上传', count: '0 条', state: 'todo' },
{ name: 'ASR 转写', count: '0 条', state: 'todo' },
{ name: 'AI 整理', count: '0 条', state: 'todo' },
{ name: '审核入库', count: '0 条', state: 'todo' },
{ name: '样片展示', count: '0 条', state: 'todo' }
],
pipelineSummary: { todayNew: '0 条', pending: '0 条', completed: '0 条' },
metrics: [
{ label: '端到端路径', value: '0 条', target: 'L0 目标 >= 1 条', type: 'danger' },
{ label: 'SOP已入库', value: '0 份', target: 'B1 目标 >= 5 流程', type: 'danger' },
{ label: '训练记录', value: '0 条', target: '育:至少 1 类场景', type: 'danger' },
{ label: '试点人数', value: '0 人', target: 'D1 目标 >= 20 人', type: 'danger' }
]
};
const dashboard = ref<DashboardOverview | null>(null);
const dashboardLoading = ref(false);
const displayDashboard = computed(() => dashboard.value ?? emptyDashboard);
const baseHeroEntries = [
{
key: 'interview',
title: 'AI面试出题',
desc: '基于岗位画像与 JD,一键生成面试题目',
status: '可演示',
@@ -165,14 +205,16 @@ const heroEntries = [
path: '/recruit/interview'
},
{
key: 'practice',
title: '三角色对练',
desc: '简历生成 + 面试对象 + 结果评估闭环',
status: '可演示',
type: 'success',
status: '待跑通',
type: 'warning',
icon: UserFilled,
path: '/train/practice'
},
{
key: 'case',
title: '案例语音整理',
desc: '语音上传,AI 整理为结构化案例',
status: '样片兜底',
@@ -181,57 +223,68 @@ const heroEntries = [
path: '/knowledge/cases'
},
{
key: 'sop',
title: 'SOP知识库',
desc: '知识库检索 + RAG问答,支持员工赋能',
status: '可演示',
type: 'success',
status: '待入库',
type: 'warning',
icon: Reading,
path: '/knowledge/sop'
}
] as const;
];
const goFeature = (path?: string) => {
if (!path) return;
router.push(path);
};
const stages = [
{ step: 1, name: '选:AI面试', desc: '题目与评估', label: '已完成', state: 'done', tagType: 'success' },
{ step: 2, name: '用:岗位适配', desc: '与录用评估', label: '已完成', state: 'done', tagType: 'success' },
{ step: 3, name: '育:三角色对练', desc: '实战陪练', label: '进行中', state: 'active', tagType: 'danger' },
{ step: 4, name: '留:成长追踪', desc: '与能力提升', label: '待启动', state: 'idle', tagType: 'info' },
{ step: 5, name: '案例沉淀', desc: '知识与复用', label: '待启动', state: 'idle', tagType: 'info' }
] as const;
const heroEntries = computed(() => {
const statusMap = new Map(displayDashboard.value.heroEntries.map((item) => [item.key, item]));
return baseHeroEntries.map((item) => ({ ...item, ...(statusMap.get(item.key) ?? {}) }));
});
const risks = [
{ name: '住宅 SOP 素材', owner: '培训组', status: '待确认', type: 'danger', icon: Tickets },
{ name: '方言 ASR', owner: '技术组', status: '样片兜底', type: 'warning', icon: Headset },
{ name: 'PII 出境评估', owner: '合规组', status: '评估中', type: 'warning', icon: Finished },
{ name: '录屏兜底方案', owner: '产品组', status: '已准备', type: 'primary', icon: VideoCamera }
] as const;
const stages = computed(() => displayDashboard.value.stages);
const records = computed(() => displayDashboard.value.records);
const pipelineSummary = computed(() => displayDashboard.value.pipelineSummary);
const records = [
{ name: '张三', scene: '客服应对投诉', score: 86, status: '已完成', state: 'done', time: '07-02 09:32' },
{ name: '李四', scene: '催缴沟通技巧', score: 78, status: '已完成', state: 'done', time: '07-02 09:12' },
{ name: '王五', scene: '突发事件处理', score: 72, status: '进行中', state: 'active', time: '07-02 08:47' },
{ name: '赵六', scene: '业主沟通谈判', score: 90, status: '已完成', state: 'done', time: '07-02 08:15' },
{ name: '孙七', scene: '服务礼仪规范', score: 65, status: '未达标', state: 'danger', time: '07-01 18:33' }
];
const pipelineIcons = [FolderChecked, Histogram, Cpu, Tickets, VideoCamera];
const pipeline = computed(() =>
displayDashboard.value.pipeline.map((step, index) => ({
...step,
icon: pipelineIcons[index] ?? FolderChecked
}))
);
const pipeline = [
{ name: '语音上传', count: '12 条', icon: FolderChecked },
{ name: 'ASR 转写', count: '12 条', icon: Histogram },
{ name: 'AI 整理', count: '10 条', icon: Cpu },
{ name: '审核入库', count: '8 条', icon: Tickets },
{ name: '样片展示', count: '5 条', icon: VideoCamera }
] as const;
const risks = computed(() => displayDashboard.value.risks.map((risk) => ({ ...risk, icon: riskIcon(risk) })));
const metrics = computed(() => displayDashboard.value.metrics.map((metric) => ({ ...metric, icon: metricIcon(metric) })));
const metrics = [
{ label: '端到端路径', value: '1 条', target: '可完整演示', icon: Switch },
{ label: 'RAG 可用率', value: '95%', target: '目标 > 90%', icon: Operation },
{ label: '评分一致率', value: '92%', target: '目标 > 85%', icon: TrendCharts },
{ label: '试点人数', value: '120 人', target: '目标 > 100 人', icon: DataLine }
] as const;
const riskIcon = (risk: DashboardRisk) => {
if (risk.name.includes('SOP')) return Tickets;
if (risk.name.includes('ASR')) return Headset;
if (risk.name.includes('审核')) return Finished;
return VideoCamera;
};
const metricIcon = (metric: DashboardMetric) => {
if (metric.label.includes('路径')) return Switch;
if (metric.label.includes('SOP')) return Operation;
if (metric.label.includes('训练')) return TrendCharts;
return DataLine;
};
const loadDashboard = async () => {
dashboardLoading.value = true;
try {
const { data } = await getDashboardOverview();
dashboard.value = data;
} catch {
dashboard.value = null;
} finally {
dashboardLoading.value = false;
}
};
onMounted(loadDashboard);
</script>
<style lang="scss" scoped>