feat(aihr): switch interview and case flows to real-model-first pipeline

AI 面试 /start 动态出题、/finish 按真实回答结构化评分(未配模型用本地
Rubric 估分);案例沉淀 /upload 改 multipart 真实语音+ASR 转写,
/organize 用真实 transcript 调 chat 模型整理;seed service 重构为
AihrInterviewService/AihrCaseService;前端面试/案例/对练页与移动端、
demo-check、验收文档同步真实链路口径。
This commit is contained in:
2026-07-03 23:45:20 +08:00
parent 4fa23617a4
commit 77aa992872
18 changed files with 1475 additions and 389 deletions
+721 -9
View File
@@ -159,7 +159,7 @@
</template>
<template v-else>
<Card title="今日任务" action="查看全部" @action="tap('查看全部')">
<Card title="今日任务" action="查看全部" @action="openUserAction('tasks')">
<div class="task-card">
<div class="task-copy">
<strong>{{ workerRole.primary.badge }}</strong>
@@ -174,7 +174,7 @@
<button type="button" @click="handlePrimaryAction">{{ workerRole.primary.cta }}</button>
</div>
<button class="review-card" type="button" @click="tap(workerRole.review.title)">
<button class="review-card" type="button" @click="openUserAction('sop')">
<IconBubble icon="document" tone="amber" />
<span>
<b>{{ workerRole.review.title }}</b>
@@ -223,10 +223,201 @@
</Card>
<Card :title="workerRole.toolTitle">
<ToolGrid :items="workerRole.tools" />
<ToolGrid :items="workerRole.tools" :handler="openUserTool" />
</Card>
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="tap(workerRole.recommend)">
<Card v-if="roleKey === 'user' && activeUserPanel" :title="userPanelTitle">
<div class="user-panel">
<template v-if="activeUserPanel === 'tasks'">
<button class="panel-row" type="button" @click="startMobilePractice">
<IconBubble icon="headset" tone="teal" />
<span>
<b>{{ workerRole.primary.title }}</b>
<small>{{ workerRole.primary.desc }}</small>
</span>
<Icon name="chevron-right" size="24" />
</button>
<button class="panel-row" type="button" @click="openUserAction('sop')">
<IconBubble icon="document" tone="amber" />
<span>
<b>{{ workerRole.review.title }}</b>
<small>{{ workerRole.review.desc }}</small>
</span>
<Icon name="chevron-right" size="24" />
</button>
<button class="panel-row" type="button" @click="openUserAction('cases')">
<IconBubble icon="folder" tone="orange" />
<span>
<b>案例学习</b>
<small>查看优秀服务案例和可复用话术</small>
</span>
<Icon name="chevron-right" size="24" />
</button>
</template>
<template v-else-if="activeUserPanel === 'sop' || activeUserPanel === 'cases'">
<form class="knowledge-search" @submit.prevent="searchKnowledge()">
<input v-model.trim="knowledgeQuery" :placeholder="activeUserPanel === 'cases' ? '搜索案例关键词' : '搜索SOP关键词'" />
<button type="submit" :disabled="knowledgeLoading">{{ knowledgeLoading ? '查询中' : '查询' }}</button>
</form>
<em v-if="knowledgeMessage" class="panel-message">{{ knowledgeMessage }}</em>
<div v-if="knowledgeResult" class="knowledge-result">
<strong>{{ knowledgeResult.answer }}</strong>
<p v-if="knowledgeResult.reference">{{ knowledgeResult.reference }}</p>
<div v-if="knowledgeResult.training?.length" class="panel-tags">
<span v-for="item in knowledgeResult.training" :key="item">{{ item }}</span>
</div>
<div v-if="knowledgeResult.docs?.length" class="result-list">
<h3>命中文档</h3>
<p v-for="doc in knowledgeResult.docs" :key="`${doc.title}-${doc.hit}`">
<b>{{ doc.title }}</b>
<small>{{ doc.desc }} · {{ doc.hit }}</small>
</p>
</div>
<div v-if="knowledgeResult.snippets?.length" class="snippet-list">
<h3>引用片段</h3>
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
<b>{{ item.title }}</b>
<span>{{ item.text }}</span>
</blockquote>
</div>
<div v-if="knowledgeResult.records?.length" class="result-list">
<h3>{{ activeUserPanel === 'cases' ? '可学习案例' : '知识记录' }}</h3>
<p v-for="record in knowledgeResult.records" :key="`${record.title}-${record.version}`">
<b>{{ record.title }}</b>
<small>{{ record.summary }} · {{ record.status }}</small>
</p>
</div>
</div>
</template>
<template v-else-if="activeUserPanel === 'submit'">
<label class="file-picker">
<span>{{ materialFile?.name || '选择语音素材' }}</span>
<small>支持 mp3 / wav / m4a / webm / ogg,上传后自动转写并整理案例</small>
<input type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" @change="handleMaterialFile" />
</label>
<button class="panel-primary" type="button" :disabled="materialUploading" @click="submitMaterial">
{{ materialUploading ? '提交中' : '上传并整理' }}
</button>
<em v-if="materialMessage" class="panel-message">{{ materialMessage }}</em>
<div v-if="materialResult" class="case-result">
<div v-if="materialResult.upload">
<h3>ASR转写</h3>
<p>{{ materialResult.upload.transcript }}</p>
<div class="panel-tags">
<span v-for="tag in materialResult.upload.tags" :key="tag.text">{{ tag.text }}</span>
</div>
</div>
<div v-if="materialResult.organize">
<h3>案例整理</h3>
<p v-if="materialResult.organize.aiSummary">{{ materialResult.organize.aiSummary }}</p>
<dl>
<template v-for="item in materialResult.organize.summary" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.text }}</dd>
</template>
</dl>
</div>
<div v-if="materialResult.curate?.records?.length">
<h3>入库记录</h3>
<p v-for="record in materialResult.curate.records" :key="record.title">
<b>{{ record.title }}</b>
<small>{{ record.summary }}</small>
</p>
</div>
</div>
</template>
<template v-else-if="activeUserPanel === 'profile'">
<div v-if="competencyProfile" class="profile-card panel-profile">
<span>
<small>综合能力</small>
<b>{{ competencyProfile.score }}</b>
</span>
<span>
<small>训练历史</small>
<b>{{ competencyProfile.completed }}</b>
</span>
<span>
<small>待复盘</small>
<b>{{ competencyProfile.pendingReview }}</b>
</span>
<div class="profile-dims">
<p v-for="item in competencyProfile.dimensions" :key="item.label">
<span>{{ item.label }}</span>
<b>{{ item.value }}</b>
<small>{{ item.note }}</small>
</p>
</div>
</div>
<div v-if="practiceHistory.length" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
</template>
<template v-else-if="activeUserPanel === 'history'">
<div v-if="practiceHistory.length && !userReviewDetail" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
<em v-if="userReviewMessage" class="panel-message">{{ userReviewMessage }}</em>
<div v-if="userReviewDetail" class="review-detail">
<div class="review-detail-head">
<span>
<b>{{ userReviewDetail.scene }} · {{ userReviewDetail.score }}分</b>
<small>{{ userReviewDetail.time }} · {{ userReviewDetail.status }}</small>
</span>
<em>{{ userReviewDetail.status }}</em>
</div>
<div class="review-score-grid">
<p v-for="item in userReviewDetail.scoreItems" :key="item.label">
<span>{{ item.label }}</span>
<b>{{ item.value }}</b>
<small>{{ item.note }}</small>
</p>
</div>
<div class="practice-dialogue review-dialogue">
<p v-for="(turn, index) in userReviewDetail.dialogue" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</div>
<div class="review-summary">
<b>导师改写</b>
<p>{{ userReviewDetail.mentorRewrite }}</p>
<b>AI点评</b>
<p>{{ userReviewDetail.aiComment }}</p>
</div>
</div>
</template>
<div class="panel-actions">
<button type="button" @click="activeUserPanel = ''">收起</button>
</div>
</div>
</Card>
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="openUserAction('recommend')">
<div class="progress-grid">
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
</div>
@@ -255,7 +446,7 @@
<div v-if="practiceHistory.length" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="tap(item.scene)">
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
@@ -267,7 +458,7 @@
</template>
<nav class="tabbar" aria-label="底部导航">
<button v-for="tab in role.tabs" :key="tab.label" type="button" :class="{ active: tab.active }" @click="tap(tab.label)">
<button v-for="tab in role.tabs" :key="tab.label" type="button" :class="{ active: tab.active }" @click="openTab(tab.label)">
<Icon :name="tab.icon" size="28" />
<span>{{ tab.label }}</span>
</button>
@@ -282,6 +473,7 @@ import { computed, defineComponent, h, ref, watch } from 'vue';
type RoleKey = 'user' | 'candidate' | 'supervisor';
type Tone = 'teal' | 'blue' | 'orange' | 'purple' | 'amber';
type UserPanelKey = '' | 'tasks' | 'sop' | 'cases' | 'submit' | 'profile' | 'history' | 'recommend';
type IconName =
| 'bars'
| 'book'
@@ -453,6 +645,85 @@ interface CompetencyProfile {
dimensions: CompetencyDimension[];
}
interface KnowledgeDoc {
title: string;
desc: string;
hit: string;
type: string;
}
interface KnowledgeSnippet {
title: string;
text: string;
}
interface KnowledgeRecord {
title: string;
category: string;
version: string;
status: string;
owner: string;
summary: string;
}
interface KnowledgeSearchResponse {
queryText: string;
category: string;
answer: string;
reference: string;
docs: KnowledgeDoc[];
snippets: KnowledgeSnippet[];
training: string[];
records: KnowledgeRecord[];
}
interface CaseTag {
text: string;
type: string;
}
interface CaseSummaryItem {
label: string;
text: string;
}
interface CaseUploadResponse {
caseId: string;
fileName: string;
transcript: string;
tags: CaseTag[];
source: string;
}
interface CaseOrganizeResponse {
caseId: string;
summary: CaseSummaryItem[];
tags: CaseTag[];
aiSummary: string;
source: string;
}
interface CaseRecord {
title: string;
type: string;
status: string;
time: string;
owner: string;
summary: string;
}
interface CaseCurateResponse {
selectedCaseIds: string[];
records: CaseRecord[];
sampleHint: string;
}
interface MaterialResult {
upload: CaseUploadResponse;
organize: CaseOrganizeResponse;
curate: CaseCurateResponse;
}
const aliases: Record<string, RoleKey> = {
h5: 'user',
user: 'user',
@@ -499,9 +770,20 @@ const practiceMessage = ref('');
const practiceHistory = ref<PracticeRecord[]>([]);
const practiceReviews = ref<PracticeRecord[]>([]);
const selectedReview = ref<PracticeReviewDetail | null>(null);
const userReviewDetail = ref<PracticeReviewDetail | null>(null);
const userReviewMessage = ref('');
const reviewMessage = ref('');
const markingReviewed = ref(false);
const competencyProfile = ref<CompetencyProfile | null>(null);
const activeUserPanel = ref<UserPanelKey>('');
const knowledgeQuery = ref('住宅投诉处理流程');
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const knowledgeLoading = ref(false);
const knowledgeMessage = ref('');
const materialFile = ref<File | null>(null);
const materialUploading = ref(false);
const materialMessage = ref('');
const materialResult = ref<MaterialResult | null>(null);
const voiceEnabled = ref(true);
const recording = ref(false);
const asrBusy = ref(false);
@@ -519,6 +801,19 @@ const cycleRole = () => {
syncPath();
};
const userPanelTitle = computed(() => {
const titles: Record<Exclude<UserPanelKey, ''>, string> = {
tasks: '今日任务',
sop: 'SOP查询',
cases: '案例学习',
submit: '提交素材',
profile: '我的能力',
history: '训练复盘',
recommend: '专项推荐'
};
return activeUserPanel.value ? titles[activeUserPanel.value] : '员工工具';
});
interface ApiPayload<T> {
code?: number;
msg?: string;
@@ -562,7 +857,7 @@ const playCustomerVoice = async (text: string) => {
);
customerAudio?.pause();
customerAudio = new Audio(data.audioUrl);
void customerAudio.play();
void customerAudio.play().catch(() => undefined);
} catch {
// TTS 未配置或失败时静默降级为纯文本
}
@@ -622,6 +917,186 @@ const handlePrimaryAction = () => {
void startMobilePractice();
};
const searchKnowledge = async (query = knowledgeQuery.value, category = activeUserPanel.value === 'cases' ? 'case' : 'sop') => {
const cleaned = query.trim();
if (!cleaned) {
knowledgeMessage.value = '请输入关键词';
return;
}
knowledgeQuery.value = cleaned;
knowledgeLoading.value = true;
knowledgeMessage.value = '';
try {
knowledgeResult.value = await readApi<KnowledgeSearchResponse>(
await fetch('/dev-api/api/knowledge/search', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ queryText: cleaned, category, limit: 4 })
})
);
knowledgeMessage.value = '已从知识库检索';
} catch (error) {
knowledgeResult.value = null;
knowledgeMessage.value = error instanceof Error ? error.message : '知识库查询失败';
} finally {
knowledgeLoading.value = false;
}
};
const openUserAction = (panel: UserPanelKey) => {
if (roleKey.value !== 'user') {
tap(panel || roleKey.value);
return;
}
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
if (panel === 'sop') {
knowledgeQuery.value = '住宅投诉处理流程';
void searchKnowledge(knowledgeQuery.value, 'sop');
return;
}
if (panel === 'cases') {
knowledgeQuery.value = '投诉接待优秀案例';
void searchKnowledge(knowledgeQuery.value, 'case');
return;
}
if (panel === 'recommend') {
activeUserPanel.value = 'sop';
knowledgeQuery.value = '催费沟通专项';
void searchKnowledge(knowledgeQuery.value, 'sop');
return;
}
if (panel === 'profile' || panel === 'history') {
void fetchPracticeData();
}
};
const openUserTool = (item: ToolItem) => {
if (roleKey.value !== 'user') {
tap(item.title);
return;
}
if (item.title === '每日一练') {
void startMobilePractice();
return;
}
if (item.title === '查SOP') {
openUserAction('sop');
return;
}
if (item.title === '案例学习') {
openUserAction('cases');
return;
}
if (item.title === '提交素材') {
openUserAction('submit');
return;
}
tap(item.title);
};
const openTab = (label: string) => {
if (roleKey.value !== 'user') {
tap(label);
return;
}
if (label === '首页') {
activeUserPanel.value = '';
return;
}
if (label === '训练') {
void startMobilePractice();
return;
}
if (label === '知识') {
openUserAction('sop');
return;
}
if (label === '我的') {
openUserAction('profile');
return;
}
tap(label);
};
const openUserHistory = async (item: PracticeRecord) => {
if (!item.id) {
activeUserPanel.value = 'history';
userReviewMessage.value = '这条训练记录暂无复盘详情';
userReviewDetail.value = null;
return;
}
activeUserPanel.value = 'history';
userReviewDetail.value = null;
userReviewMessage.value = '';
try {
userReviewDetail.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
);
} catch (error) {
userReviewMessage.value = error instanceof Error ? error.message : '训练复盘加载失败';
}
};
const handleMaterialFile = (event: Event) => {
const input = event.target as HTMLInputElement;
materialFile.value = input.files?.[0] || null;
materialResult.value = null;
materialMessage.value = materialFile.value ? '已选择语音素材' : '';
};
const submitMaterial = async () => {
if (!materialFile.value) {
materialMessage.value = '请先选择语音素材';
return;
}
materialUploading.value = true;
materialMessage.value = '';
materialResult.value = null;
try {
const form = new FormData();
form.append('file', materialFile.value);
form.append('projectExtOrgId', phone.value || 'mobile-user');
const upload = await readApi<CaseUploadResponse>(
await fetch('/dev-api/api/knowledge/case/upload', {
method: 'POST',
headers: authOnlyHeaders(),
body: form
})
);
const organize = await readApi<CaseOrganizeResponse>(
await fetch('/dev-api/api/knowledge/case/organize', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ caseId: upload.caseId })
})
);
const curate = await readApi<CaseCurateResponse>(
await fetch('/dev-api/api/knowledge/case/curate', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ caseId: upload.caseId, criteria: '员工端提交素材', limit: 1 })
})
);
materialResult.value = { upload, organize, curate };
materialMessage.value = '素材已完成转写、整理和入库';
} catch (error) {
materialMessage.value = error instanceof Error ? error.message : '素材提交失败';
} finally {
materialUploading.value = false;
}
};
const appendPracticeTurn = (role: PracticeRole, text: string) => {
const labels: Record<PracticeRole, string> = {
customer: 'AI业主',
@@ -979,6 +1454,7 @@ const fetchPracticeData = async () => {
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
userReviewDetail.value = null;
competencyProfile.value = null;
return;
}
@@ -1005,6 +1481,7 @@ const fetchPracticeData = async () => {
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
userReviewDetail.value = null;
competencyProfile.value = null;
}
};
@@ -1014,6 +1491,11 @@ watch(
() => {
apiHome.value = null;
reviewMessage.value = '';
userReviewMessage.value = '';
if (roleKey.value !== 'user') {
activeUserPanel.value = '';
userReviewDetail.value = null;
}
void fetchMobileHome();
void fetchPracticeData();
},
@@ -1211,7 +1693,8 @@ const IconBubble = defineComponent({
const ToolGrid = defineComponent({
props: {
items: { type: Array as PropType<ToolItem[]>, required: true }
items: { type: Array as PropType<ToolItem[]>, required: true },
handler: { type: Function as PropType<(item: ToolItem) => void>, default: undefined }
},
setup(props) {
return () =>
@@ -1219,7 +1702,7 @@ const ToolGrid = defineComponent({
'div',
{ class: 'tool-grid' },
props.items.map((item) =>
h('button', { type: 'button', class: `tone-${item.tone}`, onClick: () => tap(item.title) }, [
h('button', { type: 'button', class: `tone-${item.tone}`, onClick: () => (props.handler ? props.handler(item) : tap(item.title)) }, [
h(IconBubble, { icon: item.icon, tone: item.tone }),
h('span', [h('b', item.title), h('small', item.desc)]),
h(Icon, { name: 'chevron-right', size: 24 })
@@ -1852,6 +2335,235 @@ p {
font-weight: 800;
}
.user-panel {
display: grid;
gap: 12px;
}
.panel-row {
width: 100%;
min-height: 66px;
padding: 10px 11px;
border: 1px solid rgba(213, 225, 233, 0.9);
border-radius: 12px;
background: #fff;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
text-align: left;
}
.panel-row b,
.knowledge-result strong,
.case-result h3,
.result-list h3,
.snippet-list h3 {
color: #101826;
font-weight: 900;
}
.panel-row b {
display: block;
font-size: 15px;
line-height: 1.25;
}
.panel-row small {
display: block;
margin-top: 4px;
color: #607086;
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.knowledge-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 78px;
gap: 8px;
}
.knowledge-search input {
min-width: 0;
height: 42px;
border: 1px solid rgba(20, 177, 169, 0.24);
border-radius: 10px;
padding: 0 11px;
color: #111827;
background: #fff;
font: inherit;
font-size: 14px;
font-weight: 700;
outline: none;
}
.knowledge-search button,
.panel-primary,
.panel-actions button {
min-height: 42px;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 7px 15px rgba(9, 151, 144, 0.18);
font-size: 14px;
font-weight: 900;
}
.knowledge-search button:disabled,
.panel-primary:disabled {
opacity: 0.55;
}
.panel-message {
color: #0a9a94;
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.knowledge-result,
.case-result {
display: grid;
gap: 10px;
}
.knowledge-result strong {
font-size: 16px;
line-height: 1.35;
}
.knowledge-result > p,
.case-result p,
.case-result dd {
margin: 0;
color: #506078;
font-size: 13px;
line-height: 1.45;
font-weight: 650;
}
.panel-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.panel-tags span {
max-width: 100%;
padding: 5px 8px;
border-radius: 999px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
font-size: 12px;
line-height: 1.2;
font-weight: 850;
}
.result-list,
.snippet-list {
display: grid;
gap: 8px;
}
.result-list h3,
.snippet-list h3,
.case-result h3 {
margin: 0;
font-size: 15px;
}
.result-list p,
.snippet-list blockquote,
.case-result > div {
margin: 0;
padding: 10px;
border: 1px solid rgba(213, 225, 233, 0.9);
border-radius: 10px;
background: #fff;
}
.result-list b,
.snippet-list b,
.case-result b {
display: block;
color: #101826;
font-size: 14px;
line-height: 1.25;
font-weight: 900;
}
.result-list small,
.snippet-list span {
display: block;
margin-top: 4px;
color: #607086;
font-size: 12px;
line-height: 1.4;
font-weight: 650;
}
.file-picker {
min-height: 86px;
padding: 13px;
border: 1px dashed rgba(20, 177, 169, 0.38);
border-radius: 12px;
background: #f8ffff;
display: grid;
align-content: center;
gap: 6px;
position: relative;
overflow: hidden;
}
.file-picker span {
color: #101826;
font-size: 15px;
font-weight: 900;
}
.file-picker small {
color: #607086;
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.file-picker input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.case-result dl {
margin: 0;
display: grid;
gap: 6px;
}
.case-result dt {
color: #0a9a94;
font-size: 13px;
font-weight: 900;
}
.panel-profile {
margin-top: 0;
}
.panel-actions {
display: flex;
justify-content: flex-end;
}
.panel-actions button {
min-width: 76px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
box-shadow: none;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));