Files
prop-ai-hr/frontend/src/views/index.vue
T

780 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="demo-home">
<section class="toolbar-meta">
<span class="snapshot-time">数据截至:{{ displayDashboard.snapshotTime || '暂无实时数据' }}</span>
<el-button text :icon="Refresh" :loading="dashboardLoading" @click="loadDashboard">刷新</el-button>
</section>
<section class="hero-grid">
<article v-for="item in heroEntries" :key="item.title" class="entry-card">
<div class="entry-main">
<div class="entry-icon">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<div>
<h2>{{ item.title }}</h2>
<p>{{ item.desc }}</p>
<span class="status-pill" :class="`pill-${item.type}`">{{ item.status }}</span>
</div>
</div>
<div class="entry-actions">
<el-button type="danger" :disabled="!item.path" @click="goFeature(item.path)">进入功能</el-button>
</div>
</article>
</section>
<section class="content-grid">
<article class="panel progress-panel">
<div class="panel-title">
<h2>选 用 育 留 案例沉淀 流程进度</h2>
</div>
<div class="stage-track">
<div v-for="stage in stages" :key="stage.name" class="stage-item" :class="stage.state">
<div class="stage-node">
<el-icon v-if="stage.state === 'done'"><Check /></el-icon>
<span v-else>{{ stage.step }}</span>
</div>
<div class="stage-copy">
<strong>{{ stage.name }}</strong>
<span>{{ stage.desc }}</span>
<span class="status-pill" :class="`pill-${stage.tagType}`">{{ stage.label }}</span>
</div>
</div>
</div>
</article>
<article class="panel risk-panel">
<div class="panel-title">
<h2>验收待办与风险</h2>
</div>
<ul class="risk-list">
<li v-for="risk in risks" :key="risk.name">
<div class="risk-icon">
<el-icon><component :is="risk.icon" /></el-icon>
</div>
<span class="risk-name">{{ risk.name }}</span>
<span class="risk-owner">负责人:{{ risk.owner }}</span>
<span class="status-pill risk-status" :class="`pill-${risk.type}`">{{ risk.status }}</span>
</li>
</ul>
</article>
</section>
<section class="bottom-grid">
<article class="panel record-panel">
<div class="panel-title compact">
<h2>最近训练记录</h2>
</div>
<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" />
<el-table-column label="状态" width="88">
<template #default="{ row }">
<span class="status-dot" :class="row.state"></span>
{{ row.status }}
</template>
</el-table-column>
<el-table-column prop="time" label="时间" width="112" />
</el-table>
<button type="button" class="panel-link" @click="goFeature('/train/practice')">
查看更多 <el-icon><ArrowRight /></el-icon>
</button>
</article>
<article class="panel pipeline-panel">
<div class="panel-title compact">
<h2>案例沉淀 Pipeline</h2>
</div>
<div class="pipeline-steps">
<div v-for="(step, index) in pipeline" :key="step.name" class="pipeline-step">
<div class="pipeline-icon">
<el-icon><component :is="step.icon" /></el-icon>
</div>
<strong>{{ step.name }}</strong>
<span>{{ step.count }}</span>
<el-icon v-if="index < pipeline.length - 1" class="pipeline-arrow"><ArrowRight /></el-icon>
</div>
</div>
<div class="pipeline-summary">
<span>今日新增:{{ pipelineSummary.todayNew }}</span>
<span>待审核:{{ pipelineSummary.pending }}</span>
<span>已入库:{{ pipelineSummary.completed }}</span>
</div>
<button type="button" class="panel-link" @click="goFeature('/knowledge/cases')">
进入案例沉淀 <el-icon><ArrowRight /></el-icon>
</button>
</article>
<article class="panel metrics-panel">
<div class="panel-title compact">
<h2>验收指标</h2>
</div>
<div class="metric-grid">
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
<el-icon><component :is="metric.icon" /></el-icon>
<div>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.target }}</small>
</div>
</div>
</div>
<button type="button" class="panel-link" @click="goFeature('/competency/growth')">
查看指标详情 <el-icon><ArrowRight /></el-icon>
</button>
</article>
</section>
</div>
</template>
<script setup name="Index" lang="ts">
import {
ArrowRight,
Check,
Cpu,
DataLine,
DocumentChecked,
Finished,
FolderChecked,
Headset,
Histogram,
Microphone,
Notebook,
Operation,
Reading,
Refresh,
Switch,
Tickets,
TrendCharts,
UserFilled
} 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 emptyDashboard: DashboardOverview = {
snapshotTime: '',
heroEntries: [],
stages: [
{ step: 1, name: '选:AI面试', desc: '题目与评估', label: '可试用', state: 'done', tagType: 'success' },
{ 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: '可试用',
type: 'success',
icon: DocumentChecked,
path: '/recruit/interview'
},
{
key: 'practice',
title: '三角色对练',
desc: '简历生成 + 面试对象 + 结果评估闭环',
status: '待跑通',
type: 'warning',
icon: UserFilled,
path: '/train/practice'
},
{
key: 'case',
title: '案例语音整理',
desc: '语音上传,AI 整理为结构化案例',
status: '待入库',
type: 'warning',
icon: Microphone,
path: '/knowledge/cases'
},
{
key: 'sop',
title: 'SOP知识库',
desc: '知识库检索 + RAG问答,支持员工赋能',
status: '待入库',
type: 'warning',
icon: Reading,
path: '/knowledge/sop'
}
];
const goFeature = (path?: string) => {
if (!path) return;
router.push(path);
};
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 stages = computed(() => displayDashboard.value.stages);
const records = computed(() => displayDashboard.value.records);
const pipelineSummary = computed(() => displayDashboard.value.pipelineSummary);
const pipelineIcons = [FolderChecked, Histogram, Cpu, Tickets, Notebook];
const pipeline = computed(() =>
displayDashboard.value.pipeline.map((step, index) => ({
...step,
icon: pipelineIcons[index] ?? FolderChecked
}))
);
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 riskIcon = (risk: DashboardRisk) => {
if (risk.name.includes('SOP')) return Tickets;
if (risk.name.includes('ASR')) return Headset;
if (risk.name.includes('审核')) return Finished;
return Notebook;
};
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>
.demo-home {
min-height: calc(100vh - 84px);
padding: 22px;
background: #f5f7fb;
color: #162033;
}
.toolbar-meta {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-bottom: 12px;
color: #657184;
font-size: 13px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
line-height: 24px;
font-weight: 700;
white-space: nowrap;
}
.pill-danger {
color: #c80f1f;
background: #ffe8e9;
}
.pill-success {
color: #15844a;
background: #e5f8ee;
}
.pill-warning {
color: #c46b00;
background: #fff2dc;
}
.pill-primary {
color: #1467bd;
background: #e7f2ff;
}
.pill-info {
color: #64748b;
background: #eef2f7;
}
.hero-grid,
.content-grid,
.bottom-grid {
display: grid;
gap: 12px;
}
.hero-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 12px;
}
.content-grid {
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
margin-bottom: 12px;
}
.bottom-grid {
grid-template-columns: minmax(350px, 1fr) minmax(420px, 1.1fr) minmax(330px, 0.9fr);
}
.panel,
.entry-card {
border: 1px solid #e4e9f2;
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 24px rgba(21, 30, 45, 0.04);
}
.entry-card {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 188px;
padding: 22px 22px 20px;
}
.entry-main {
display: flex;
gap: 16px;
align-items: flex-start;
h2 {
margin: 2px 0 8px;
font-size: 18px;
line-height: 1.25;
font-weight: 700;
}
p {
min-height: 40px;
margin: 0 0 12px;
color: #3f4b5f;
font-size: 13px;
line-height: 1.55;
}
}
.entry-icon {
display: grid;
width: 58px;
height: 58px;
flex: 0 0 58px;
place-items: center;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #ff6b72, #d90f20);
font-size: 32px;
box-shadow: 0 10px 20px rgba(216, 15, 32, 0.22);
}
.entry-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 22px;
.el-button {
margin: 0;
}
}
.panel {
padding: 18px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
&.compact {
margin-bottom: 12px;
}
h2 {
margin: 0;
font-size: 17px;
line-height: 1.35;
font-weight: 700;
}
}
.stage-track {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.stage-item {
position: relative;
display: grid;
justify-items: center;
gap: 12px;
min-height: 150px;
text-align: center;
&:not(:last-child)::after {
position: absolute;
top: 22px;
right: -50%;
width: 100%;
height: 0;
border-top: 2px dashed #c7ced8;
content: '';
}
&.done:not(:last-child)::after {
border-color: #31b367;
}
&.active:not(:last-child)::after {
border-color: #d70f20;
}
}
.stage-node {
z-index: 1;
display: grid;
width: 48px;
height: 48px;
place-items: center;
border: 2px solid #d3d9e3;
border-radius: 50%;
background: #fff;
color: #69758a;
font-size: 18px;
font-weight: 700;
}
.stage-item.done .stage-node {
border-color: #31b367;
color: #fff;
background: #31b367;
}
.stage-item.active .stage-node {
border-color: #d70f20;
color: #fff;
background: #d70f20;
}
.stage-copy {
display: grid;
justify-items: center;
gap: 8px;
strong {
font-size: 14px;
}
span {
min-height: 34px;
color: #303b4c;
font-size: 13px;
line-height: 1.35;
}
}
.risk-list {
display: grid;
gap: 0;
padding: 0;
margin: 0;
list-style: none;
li {
display: grid;
grid-template-columns: 34px minmax(110px, 1fr) minmax(120px, 0.8fr) 82px;
align-items: center;
gap: 12px;
min-height: 43px;
border-bottom: 1px solid #edf0f5;
&:last-child {
border-bottom: 0;
}
}
}
.risk-icon {
color: #5d6878;
font-size: 22px;
}
.risk-name {
font-weight: 700;
}
.risk-owner {
color: #637085;
font-size: 13px;
}
.record-panel {
:deep(.el-table__inner-wrapper::before) {
display: none;
}
}
.record-table {
border: 1px solid #edf0f5;
border-radius: 8px;
overflow: hidden;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
vertical-align: middle;
&.done {
background: #31b367;
}
&.active {
background: #e6a23c;
}
&.danger {
background: #d70f20;
}
}
.pipeline-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
padding: 8px 0 18px;
}
.pipeline-step {
position: relative;
display: grid;
justify-items: center;
gap: 8px;
text-align: center;
strong {
font-size: 13px;
}
span {
color: #0f7be8;
font-size: 13px;
font-weight: 700;
}
}
.pipeline-icon {
display: grid;
width: 56px;
height: 56px;
place-items: center;
border: 1px solid #dce2ec;
border-radius: 50%;
background: #f8fafc;
color: #1d2b3f;
font-size: 30px;
}
.pipeline-arrow {
position: absolute;
top: 22px;
right: -18px;
color: #aab3c2;
}
.pipeline-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding-top: 14px;
border-top: 1px solid #edf0f5;
color: #526178;
span {
position: relative;
&:not(:last-child)::after {
position: absolute;
top: 3px;
right: -9px;
width: 1px;
height: 12px;
background: #cbd3df;
content: '';
}
}
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.metric-card {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 86px;
padding: 12px;
border: 1px solid #e5eaf3;
border-radius: 8px;
.el-icon {
color: #d70f20;
font-size: 34px;
}
span,
small {
display: block;
color: #5f6c80;
font-size: 12px;
}
strong {
display: block;
margin: 2px 0;
color: #142033;
font-size: 24px;
line-height: 1.1;
}
}
.panel-link {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 14px;
margin-left: auto;
padding: 0;
border: 0;
background: transparent;
color: #d70f20;
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
@media (max-width: 1360px) {
.hero-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content-grid,
.bottom-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.demo-home {
padding: 14px;
}
.page-heading {
align-items: flex-start;
flex-direction: column;
}
.hero-grid,
.stage-track,
.pipeline-steps,
.metric-grid {
grid-template-columns: 1fr;
}
.stage-item {
justify-items: start;
grid-template-columns: 48px minmax(0, 1fr);
min-height: auto;
text-align: left;
&::after {
display: none;
}
}
.stage-copy {
justify-items: start;
}
.risk-list li {
grid-template-columns: 28px minmax(0, 1fr);
}
.risk-owner,
.risk-status {
grid-column: 2;
}
.pipeline-step {
grid-template-columns: 56px minmax(0, 1fr);
justify-items: start;
text-align: left;
}
.pipeline-arrow {
display: none;
}
.pipeline-summary span::after {
display: none;
}
}
</style>