feat(aihr): structured digest answer with layered mobile presentation

用户反馈「检索返回一堆文字不便总结」修复。后端:检索命中后新增
RAG 生成环节 searchDigest——chat 模型 temperature=0 把 top5 片段压
成结构化概要(直接回答≤60字+3-6条可执行要点+0-3条注意红线),硬约束
不得编造、片段答不了则明示无依据;模型未配置/超时/解析失败回退
片段原文拼接,15s 超时不拖垮检索。SearchResponse 增 keyPoints/
cautions 字段。移动端:sopPanel 结果改三层——概要卡(回答大字+要点
列表+警示色块)默认唯一展开;「查看依据」折叠原文片段,单条3行截断
点击展开;「听一遍」按钮把概要送 TTS 播报(独立播放状态,关面板即停,
未配 tts 提示降级)。候选人端知识 tab 同套概要卡+依据折叠,消除
原文倾泻。实测:「客服接待基础流程」4s 返回概要+5要点+3红线。
This commit is contained in:
2026-07-04 23:27:08 +08:00
parent e26e3c75cc
commit 4c5c343aca
3 changed files with 613 additions and 160 deletions
@@ -65,7 +65,9 @@ public final class AihrSopDto {
List<DocResponse> docs,
List<SnippetResponse> snippets,
List<String> training,
List<RecordResponse> records
List<RecordResponse> records,
List<String> keyPoints,
List<String> cautions
) {
}
@@ -123,7 +123,9 @@ public class AihrSopSeedService {
seed.docs(),
seed.snippets(),
seed.training(),
records()
records(),
List.of(),
List.of()
);
}
@@ -1726,7 +1728,7 @@ public class AihrSopSeedService {
}
}
private static SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits) {
private SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits) {
KnowledgeHit first = hits.get(0);
String responseCategory = isBlank(category) ? first.category() : category;
List<SnippetResponse> snippets = hits.stream()
@@ -1736,18 +1738,119 @@ public class AihrSopSeedService {
for (KnowledgeHit hit : hits) {
docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success"));
}
SearchDigest digest = searchDigest(queryText, hits);
return new SearchResponse(
queryText,
responseCategory,
"根据已入库 SOP:" + first.content(),
digest != null ? digest.answer() : "根据已入库 SOP:" + first.content(),
"引用 " + docs.size() + " 份文档 / " + snippets.size() + " 个片段",
List.copyOf(docs.values()),
snippets,
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
records()
records(),
digest != null ? digest.keyPoints() : List.of(),
digest != null ? digest.cautions() : List.of()
);
}
/**
* RAG 生成环节:把命中片段交给 chat 模型压成「直接回答+要点+注意事项」结构化概要。
* 模型未配置、超时或解析失败返回 null,由调用方回退片段原文拼接——检索永不因概要失败而报错。
*/
private SearchDigest searchDigest(String queryText, List<KnowledgeHit> hits) {
Optional<ChatRuntime> runtime = chatRuntime();
if (runtime.isEmpty()) {
return null;
}
StringBuilder context = new StringBuilder();
int index = 1;
for (KnowledgeHit hit : hits.stream().limit(5).toList()) {
context.append("片段").append(index++).append("(来源:").append(hit.title()).append("):\n")
.append(truncate(hit.content(), 1500)).append("\n\n");
}
try {
String content = callDigestModel(runtime.get(), queryText, context.toString());
JsonNode root = objectMapper.readTree(jsonObjectText(content));
String answer = root.path("answer").asText("");
if (isBlank(answer)) {
return null;
}
return new SearchDigest(
truncate(answer, 200),
jsonStrings(root.path("keyPoints"), 6, 120),
jsonStrings(root.path("cautions"), 3, 120)
);
} catch (Exception e) {
log.warn("search digest failed, keep raw answer: {}", e.getMessage());
return null;
}
}
private String callDigestModel(ChatRuntime runtime, String queryText, String context) throws Exception {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
body.put("temperature", 0);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 是对问题的直接回答(不超过60字,含关键数字/时限);keyPoints 是3到6条可执行要点(每条一句话,含具体数字);"
+ "cautions 是0到3条注意事项或红线。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。");
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", """
员工的问题:%s
检索命中的 SOP 片段:
%s
""".formatted(queryText, context));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
if (!isBlank(runtime.apiKey())) {
builder.header("Authorization", "Bearer " + runtime.apiKey());
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (!ok(response.statusCode())) {
throw new IllegalStateException("digest HTTP " + response.statusCode());
}
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
if (!choices.isArray() || choices.isEmpty()) {
throw new IllegalStateException("digest response missing choices");
}
return choices.get(0).path("message").path("content").asText();
}
private static List<String> jsonStrings(JsonNode node, int maxItems, int maxChars) {
if (!node.isArray()) {
return List.of();
}
List<String> values = new ArrayList<>();
for (JsonNode item : node) {
String text = item.asText("").strip();
if (!text.isEmpty()) {
values.add(truncate(text, maxChars));
}
if (values.size() >= maxItems) {
break;
}
}
return List.copyOf(values);
}
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) {
}
private static String dbCategory(String category) {
return "sop".equalsIgnoreCase(Optional.ofNullable(category).orElse("").trim()) ? "" : category;
}
+503 -155
View File
@@ -52,7 +52,7 @@
</p>
</header>
<Card v-if="roleKey !== 'user' && isHomeTab" :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
<Card v-if="isHomeTab" :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
<div class="flow-steps">
<button v-for="item in roleFlowSteps" :key="`${item.phase}-${item.title}`" type="button" :class="{ active: isFlowActive(item) }" @click="openFlowStep(item)">
<em>{{ item.phase }}</em>
@@ -118,44 +118,6 @@
<em>{{ item.status }}</em>
</button>
</div>
<em v-if="reviewMessage && !selectedReview" class="review-message">{{ reviewMessage }}</em>
<div v-if="selectedReview" class="review-detail">
<div class="review-detail-head">
<span>
<b>{{ selectedReview.trainee }} · {{ selectedReview.score }}分</b>
<small>{{ selectedReview.time }} · {{ selectedReview.scene }}</small>
</span>
<em>{{ selectedReview.status }}</em>
</div>
<div class="review-score-grid">
<p v-for="item in selectedReview.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 selectedReview.dialogue" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</div>
<div class="review-summary">
<b>导师改写</b>
<p>{{ selectedReview.mentorRewrite }}</p>
<b>AI点评</b>
<p>{{ selectedReview.aiComment }}</p>
</div>
<button type="button" :disabled="markingReviewed || selectedReview.status !== '待复盘'" @click="markReviewDone">
{{ selectedReview.status === '待复盘' ? (markingReviewed ? '处理中' : '标记已复盘') : '已复盘' }}
</button>
<em v-if="reviewMessage">{{ reviewMessage }}</em>
</div>
</Card>
<Card :title="supervisorRole.toolTitle">
@@ -192,7 +154,7 @@
</template>
<template v-else-if="activeSupervisorPanel === 'reviews' || activeSupervisorPanel === 'assign'">
<div v-if="practiceReviews.length" class="record-list">
<div v-if="practiceReviews.length && !selectedReview" class="record-list">
<h3>{{ activeSupervisorPanel === 'assign' ? '建议指派对象' : '待复盘对练' }}</h3>
<button v-for="item in practiceReviews" :key="item.id || `${item.time}-${item.trainee}-${item.scene}`" type="button" @click="openPracticeReview(item)">
<span>
@@ -202,8 +164,10 @@
<em>{{ item.status }}</em>
</button>
</div>
<em v-else class="panel-message">暂无待复盘记录</em>
<em v-else-if="!selectedReview" class="panel-message">暂无待复盘记录</em>
<em v-if="reviewMessage && !selectedReview" class="review-message">{{ reviewMessage }}</em>
<div v-if="selectedReview" class="review-detail">
<button class="detail-back" type="button" @click="closeSupervisorReviewDetail">返回列表</button>
<div class="review-detail-head">
<span>
<b>{{ selectedReview.trainee }} · {{ selectedReview.score }}分</b>
@@ -239,7 +203,7 @@
</button>
<em v-if="reviewMessage">{{ reviewMessage }}</em>
</div>
<div class="result-list">
<div v-if="!selectedReview" class="result-list">
<h3>专项建议</h3>
<p>
<b>催费沟通专项</b>
@@ -255,14 +219,26 @@
</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 class="digest-card">
<strong>{{ knowledgeResult.answer }}</strong>
<ol v-if="knowledgeResult.keyPoints?.length" class="digest-points">
<li v-for="point in knowledgeResult.keyPoints" :key="point">{{ point }}</li>
</ol>
<div v-if="knowledgeResult.cautions?.length" class="digest-cautions">
<p v-for="item in knowledgeResult.cautions" :key="item">⚠ {{ item }}</p>
</div>
<p v-if="knowledgeResult.reference" class="digest-reference">{{ knowledgeResult.reference }}</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>
<button class="snippet-toggle" type="button" @click="knowledgeSnippetsOpen = !knowledgeSnippetsOpen">
{{ knowledgeSnippetsOpen ? '收起依据' : `查看依据(${knowledgeResult.snippets.length} 条原文片段)` }}
</button>
<template v-if="knowledgeSnippetsOpen">
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
<b>{{ item.title }}</b>
<span>{{ item.text }}</span>
</blockquote>
</template>
</div>
</div>
</template>
@@ -345,44 +321,6 @@
</button>
</div>
<div v-if="practiceStatus !== 'idle'" class="practice-panel">
<div class="practice-panel-head">
<span>三角色对练</span>
<b v-if="practiceResult">{{ practiceResult.totalScore }}分</b>
<small v-else>{{ practiceStatus === 'starting' ? '准备中' : '进行中' }}</small>
</div>
<div v-if="practiceResult" class="practice-result">
<strong>{{ practiceResult.aiComment }}</strong>
<p>{{ practiceResult.mentorRewrite }}</p>
<div class="practice-result-actions">
<button type="button" @click="startMobilePractice">再练一次</button>
<button type="button" @click="goSupervisor">查看主管端</button>
</div>
</div>
<div v-else class="practice-live">
<div class="practice-dialogue">
<p v-for="(turn, index) in practiceTurns" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</div>
<textarea v-model.trim="practiceDraft" rows="3" placeholder="输入你的回复" />
<div class="practice-actions">
<button type="button" :disabled="asrBusy" @click="toggleRecording">
{{ recording ? '停止录音' : asrBusy ? '识别中' : useAudioFileFallback ? '选择语音' : '语音输入' }}
</button>
<input ref="practiceAudioInput" class="practice-audio-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" capture @change="handlePracticeAudioFile" />
<button type="button" @click="toggleVoice">{{ voiceEnabled ? '播报:开' : '播报:关' }}</button>
<button type="button" @click="fillPracticeReply">填入建议回复</button>
<button type="button" :disabled="practiceStatus === 'starting' || practiceStatus === 'submitting'" @click="submitMobilePractice">
{{ practiceStatus === 'submitting' ? '提交中' : '提交本轮' }}
</button>
</div>
</div>
<em v-if="practiceMessage">{{ practiceMessage }}</em>
</div>
</section>
<section class="scenario-section">
@@ -549,6 +487,9 @@
</span>
<Icon name="chevron-right" size="24" />
</button>
<button v-if="roleKey === 'candidate' && interviewStatus === 'idle'" class="panel-primary" type="button" @click="handlePrimaryAction">
{{ workerRole.primary.cta }}
</button>
<button class="panel-row" type="button" @click="openUserAction('sop')">
<IconBubble icon="document" tone="amber" />
<span>
@@ -825,38 +766,6 @@
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
</div>
<div v-if="competencyProfile" class="profile-card">
<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>
</Card>
</template>
@@ -882,20 +791,44 @@
{{ sopRecording ? '停止录音' : sopAsrBusy ? '识别中' : sopUseAudioFileFallback ? '选择语音' : '语音提问' }}
</button>
<input ref="sopAudioInput" class="practice-audio-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" capture @change="handleSopAudioFile" />
<button class="sop-submit" type="submit" :disabled="sopLoading">{{ sopLoading ? '查询中' : '搜索' }}</button>
<button class="sop-submit" type="submit" :disabled="sopLoading">{{ sopLoading ? '总结中' : '搜索' }}</button>
</div>
</form>
<em v-if="sopMessage" class="panel-message">{{ sopMessage }}</em>
<div v-if="sopResult" class="knowledge-result sop-result">
<strong>{{ sopResult.answer }}</strong>
<p v-if="sopResult.reference">{{ sopResult.reference }}</p>
<div class="digest-card">
<div class="digest-head">
<strong>{{ sopResult.answer }}</strong>
<button class="digest-speak" type="button" :disabled="sopSpeakLoading" @click="toggleSopSpeak">
{{ sopSpeaking ? '停止' : sopSpeakLoading ? '合成中' : '听一遍' }}
</button>
</div>
<ol v-if="sopResult.keyPoints?.length" class="digest-points">
<li v-for="point in sopResult.keyPoints" :key="point">{{ point }}</li>
</ol>
<div v-if="sopResult.cautions?.length" class="digest-cautions">
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
</div>
<p v-if="sopResult.reference" class="digest-reference">{{ sopResult.reference }}</p>
</div>
<div v-if="sopResult.snippets?.length" class="snippet-list">
<h3>引用片段</h3>
<blockquote v-for="item in sopResult.snippets" :key="`${item.title}-${item.text}`">
<b>{{ item.title }}</b>
<span>{{ item.text }}</span>
</blockquote>
<button class="snippet-toggle" type="button" @click="sopSnippetsOpen = !sopSnippetsOpen">
{{ sopSnippetsOpen ? '收起依据' : `查看依据(${sopResult.snippets.length} 条原文片段)` }}
</button>
<template v-if="sopSnippetsOpen">
<blockquote
v-for="(item, index) in sopResult.snippets"
:key="`${item.title}-${index}`"
:class="{ expanded: expandedSnippets.has(index) }"
@click="toggleSnippet(index)"
>
<b>{{ item.title }}</b>
<span>{{ item.text }}</span>
<em v-if="!expandedSnippets.has(index)">点击展开全文</em>
</blockquote>
</template>
</div>
</div>
</div>
@@ -1161,6 +1094,8 @@ interface KnowledgeSearchResponse {
snippets: KnowledgeSnippet[];
training: string[];
records: KnowledgeRecord[];
keyPoints?: string[];
cautions?: string[];
}
interface CaseTag {
@@ -1236,9 +1171,53 @@ const rolePaths: Record<RoleKey, string> = {
supervisor: '/h5/supervisor'
};
interface MobileRoute {
role: RoleKey;
page: string;
detail: string;
}
const pathParts = () => window.location.pathname.split('/').filter(Boolean);
const readRole = (): RoleKey => {
const last = window.location.pathname.split('/').filter(Boolean).at(-1) || 'user';
return aliases[last] || 'user';
const parts = pathParts();
const part = parts[0] === 'h5' ? parts[1] : parts.find((item) => aliases[item]);
return aliases[part || 'user'] || 'user';
};
const readRoute = (): MobileRoute => {
const parts = pathParts();
const offset = parts[0] === 'h5' ? 2 : 1;
return { role: readRole(), page: parts[offset] || '', detail: parts[offset + 1] || '' };
};
const userPanelSegments: Record<Exclude<UserPanelKey, ''>, string> = {
tasks: 'training',
sop: 'knowledge',
cases: 'cases',
submit: 'submit',
profile: 'profile',
history: 'history',
recommend: 'recommend'
};
const candidatePanelSegments: Record<Exclude<UserPanelKey, ''>, string> = {
tasks: 'interview',
sop: 'sop',
cases: 'cases',
submit: 'materials',
profile: 'profile',
history: 'history',
recommend: 'recommend'
};
const supervisorPanelSegments: Record<Exclude<SupervisorPanelKey, ''>, string> = {
team: 'team',
reviews: 'reviews',
assign: 'assign',
weakness: 'weakness',
case: 'cases',
sop: 'sop',
profile: 'profile'
};
const clampPercent = (value: number) => `${Math.max(0, Math.min(value, 100))}%`;
@@ -1285,10 +1264,12 @@ const activeUserPanel = ref<UserPanelKey>('');
const activeSupervisorPanel = ref<SupervisorPanelKey>('');
const knowledgeQuery = ref('住宅投诉处理流程');
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const knowledgeSnippetsOpen = ref(false);
const knowledgeLoading = ref(false);
const knowledgeMessage = ref('');
const sopPanelOpen = ref(false);
const sopReturnTabLabel = ref('首页');
const sopReturnPath = ref(rolePaths[roleKey.value]);
const sopQuery = ref('');
const sopResult = ref<KnowledgeSearchResponse | null>(null);
const sopLoading = ref(false);
@@ -1297,6 +1278,11 @@ const sopRecording = ref(false);
const sopAsrBusy = ref(false);
const sopUseAudioFileFallback = ref(false);
const sopAudioInput = ref<HTMLInputElement>();
const sopSnippetsOpen = ref(false);
const expandedSnippets = ref(new Set<number>());
const sopSpeaking = ref(false);
const sopSpeakLoading = ref(false);
let sopAudio: HTMLAudioElement | null = null;
const materialFile = ref<File | null>(null);
const materialUploading = ref(false);
const materialMessage = ref('');
@@ -1313,8 +1299,19 @@ let recordedChunks: Blob[] = [];
let sopMediaRecorder: MediaRecorder | null = null;
let sopRecordedChunks: Blob[] = [];
let customerAudio: HTMLAudioElement | null = null;
let applyingRoute = false;
const syncPath = () => window.history.pushState({}, '', rolePaths[roleKey.value]);
const navigatePath = (path: string, replace = false) => {
if (window.location.pathname === path) return;
window.history[replace ? 'replaceState' : 'pushState']({}, '', path);
};
const syncPath = () => navigatePath(rolePaths[roleKey.value]);
const userPanelPath = (panel: UserPanelKey) =>
panel ? `${rolePaths.user}/${userPanelSegments[panel]}` : rolePaths.user;
const candidatePanelPath = (panel: UserPanelKey) =>
panel ? `${rolePaths.candidate}/${candidatePanelSegments[panel]}` : rolePaths.candidate;
const supervisorPanelPath = (panel: SupervisorPanelKey) =>
panel ? `${rolePaths.supervisor}/${supervisorPanelSegments[panel]}` : rolePaths.supervisor;
const scrollToTop = () => {
window.requestAnimationFrame(() => window.scrollTo({ top: 0 }));
};
@@ -1521,18 +1518,23 @@ const transcribeRecording = async (blob: Blob, filename = 'practice.webm') => {
const openSopPanel = () => {
sopReturnTabLabel.value = activeTabLabel.value;
sopReturnPath.value = window.location.pathname.endsWith('/sop') ? rolePaths[roleKey.value] : window.location.pathname;
sopPanelOpen.value = true;
sopQuery.value = '';
sopResult.value = null;
sopMessage.value = '';
navigatePath(`${rolePaths[roleKey.value]}/sop`);
};
const closeSopPanel = () => {
if (sopRecording.value) {
sopMediaRecorder?.stop();
}
stopSopSpeak();
sopPanelOpen.value = false;
activeTabLabel.value = sopReturnTabLabel.value;
navigatePath(sopReturnPath.value || rolePaths[roleKey.value]);
applyRouteFromLocation();
};
const toggleSopRecording = async () => {
@@ -1593,6 +1595,15 @@ const transcribeSopRecording = async (blob: Blob, filename = 'sop.webm') => {
}
};
const openUserPracticePanel = () => {
activeTabLabel.value = '训练';
activeUserPanel.value = 'tasks';
userReviewDetail.value = null;
userReviewMessage.value = '';
navigatePath(userPanelPath('tasks'));
scrollToTop();
};
const handlePrimaryAction = () => {
if (roleKey.value === 'candidate') {
void startCandidateInterview();
@@ -1602,9 +1613,55 @@ const handlePrimaryAction = () => {
tap(workerRole.value.primary.cta);
return;
}
openUserPracticePanel();
void startMobilePractice();
};
const toggleSnippet = (index: number) => {
const next = new Set(expandedSnippets.value);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
expandedSnippets.value = next;
};
const stopSopSpeak = () => {
sopAudio?.pause();
sopAudio = null;
sopSpeaking.value = false;
};
const toggleSopSpeak = async () => {
if (sopSpeaking.value) {
stopSopSpeak();
return;
}
const result = sopResult.value;
if (!result) return;
// 播报文本 = 直接回答 + 要点,TTS 上限 300 字,留余量截 280
const speech = [result.answer, ...(result.keyPoints || []).map((point, i) => `${i + 1}、${point}`)].join('。').slice(0, 280);
sopSpeakLoading.value = true;
try {
const data = await readApi<{ audioUrl: string }>(
await fetch('/dev-api/api/ai/tts', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ text: speech, voice: '' })
})
);
sopAudio = new Audio(data.audioUrl);
sopAudio.onended = () => stopSopSpeak();
sopSpeaking.value = true;
void sopAudio.play().catch(() => stopSopSpeak());
} catch {
sopMessage.value = '语音播报暂不可用(需启用 tts 模型),可直接阅读上方概要';
} finally {
sopSpeakLoading.value = false;
}
};
const searchSopKnowledge = async () => {
const cleaned = sopQuery.value.trim();
if (!cleaned) {
@@ -1615,8 +1672,12 @@ const searchSopKnowledge = async () => {
sopLoading.value = true;
sopMessage.value = '';
sopResult.value = null;
sopSnippetsOpen.value = false;
expandedSnippets.value = new Set();
stopSopSpeak();
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 15000);
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
const timeout = window.setTimeout(() => controller.abort(), 25000);
try {
sopResult.value = await readApi<KnowledgeSearchResponse>(
await fetch('/dev-api/api/knowledge/search', {
@@ -1666,6 +1727,7 @@ const searchKnowledge = async (query = knowledgeQuery.value, category = activeUs
const switchKnowledgePanel = (panel: Extract<UserPanelKey, 'sop' | 'cases'>) => {
activeTabLabel.value = '知识';
activeUserPanel.value = panel;
navigatePath(roleKey.value === 'candidate' ? candidatePanelPath(panel) : userPanelPath(panel));
knowledgeQuery.value =
panel === 'cases' ? '投诉接待优秀案例' : roleKey.value === 'candidate' ? '客服接待基础流程' : '住宅投诉处理流程';
void searchKnowledge(knowledgeQuery.value, panel === 'cases' ? 'case' : 'sop');
@@ -1694,6 +1756,7 @@ const openCandidateAction = (panel: UserPanelKey) => {
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
navigatePath(candidatePanelPath(panel));
scrollToTop();
if (panel === 'sop') {
@@ -1740,6 +1803,7 @@ const openUserAction = (panel: UserPanelKey) => {
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
navigatePath(userPanelPath(panel));
scrollToTop();
if (panel === 'cases') {
@@ -1786,6 +1850,7 @@ const openUserTool = (item: ToolItem) => {
}
if (item.title === '每日一练') {
openUserPracticePanel();
void startMobilePractice();
return;
}
@@ -1817,6 +1882,7 @@ const openSupervisorAction = (panel: SupervisorPanelKey) => {
({ team: '团队', reviews: '团队', assign: '团队', weakness: '团队', case: '案例', sop: '案例', profile: '我的' } as Partial<Record<SupervisorPanelKey, string>>)[panel] || '首页';
activeSupervisorPanel.value = panel;
activeUserPanel.value = '';
navigatePath(supervisorPanelPath(panel));
scrollToTop();
if (panel === 'reviews' || panel === 'assign') {
void fetchPracticeData();
@@ -1863,6 +1929,7 @@ const openTab = (label: string) => {
activeSupervisorPanel.value = '';
activeUserPanel.value = '';
selectedReview.value = null;
syncPath();
return;
}
if (label === '团队') {
@@ -1884,6 +1951,7 @@ const openTab = (label: string) => {
activeUserPanel.value = '';
userReviewDetail.value = null;
userReviewMessage.value = '';
syncPath();
return;
}
if (label === '面试') {
@@ -1908,13 +1976,11 @@ const openTab = (label: string) => {
activeUserPanel.value = '';
userReviewDetail.value = null;
userReviewMessage.value = '';
syncPath();
return;
}
if (label === '训练') {
activeUserPanel.value = 'tasks';
userReviewDetail.value = null;
userReviewMessage.value = '';
void fetchPracticeData();
openUserAction('tasks');
return;
}
if (label === '知识') {
@@ -1928,12 +1994,52 @@ const openTab = (label: string) => {
tap(label);
};
const loadUserReviewDetail = async (id: number) => {
userReviewDetail.value = null;
userReviewMessage.value = '';
try {
userReviewDetail.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${id}`, { headers: apiHeaders() })
);
} catch (error) {
userReviewMessage.value = error instanceof Error ? error.message : '训练复盘加载失败';
}
};
const reviewDetailFromRecord = (item: PracticeRecord): PracticeReviewDetail => ({
...item,
mentorRewrite: `建议复盘时围绕这条记录继续追问:${item.summary}`,
aiComment: item.summary,
scoreItems: [
{ label: '总分', value: item.score, note: '本条对练综合评分' }
],
dialogue: [
{ role: 'coach', label: 'AI教练', text: item.summary }
]
});
const reviewDetailPlaceholder = (id: number): PracticeReviewDetail => ({
id,
time: '',
trainee: '复盘记录',
scene: '训练复盘',
score: 0,
status: '加载中',
summary: '正在加载复盘详情',
mentorRewrite: '正在加载导师改写',
aiComment: '正在加载AI点评',
scoreItems: [],
dialogue: []
});
const openUserHistory = async (item: PracticeRecord) => {
activeTabLabel.value = '我的';
if (!item.id) {
activeUserPanel.value = 'history';
userReviewMessage.value = '这条训练记录暂无复盘详情';
userReviewDetail.value = null;
navigatePath(userPanelPath('history'));
scrollToTop();
return;
}
@@ -1941,14 +2047,9 @@ const openUserHistory = async (item: PracticeRecord) => {
activeUserPanel.value = 'history';
userReviewDetail.value = null;
userReviewMessage.value = '';
navigatePath(`${userPanelPath('history')}/${item.id}`);
scrollToTop();
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 : '训练复盘加载失败';
}
await loadUserReviewDetail(item.id);
};
const handleMaterialFile = (event: Event) => {
@@ -2140,6 +2241,7 @@ const submitMobilePractice = async () => {
const startCandidateInterview = async () => {
activeUserPanel.value = 'tasks';
activeTabLabel.value = '面试';
navigatePath(candidatePanelPath('tasks'));
scrollToTop();
interviewStatus.value = 'starting';
interviewSessionId.value = '';
@@ -2203,23 +2305,44 @@ const finishCandidateInterview = async () => {
const goSupervisor = () => {
roleKey.value = 'supervisor';
activeTabLabel.value = '首页';
activeSupervisorPanel.value = '';
activeUserPanel.value = '';
syncPath();
void fetchMobileHome();
};
const loadSupervisorReviewDetail = async (id: number) => {
reviewMessage.value = '';
try {
selectedReview.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${id}`, { headers: apiHeaders() })
);
} catch (error) {
reviewMessage.value = error instanceof Error ? error.message : '复盘详情加载失败';
}
};
const openPracticeReview = async (item: PracticeRecord) => {
if (!item.id) {
tap(item.trainee);
return;
}
activeTabLabel.value = '团队';
activeSupervisorPanel.value = 'reviews';
activeUserPanel.value = '';
selectedReview.value = reviewDetailFromRecord(item);
reviewMessage.value = '';
try {
selectedReview.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
);
} catch (error) {
reviewMessage.value = error instanceof Error ? error.message : '复盘详情加载失败';
}
navigatePath(`${supervisorPanelPath('reviews')}/${item.id}`);
scrollToTop();
await loadSupervisorReviewDetail(item.id);
};
const closeSupervisorReviewDetail = () => {
selectedReview.value = null;
reviewMessage.value = '';
navigatePath(supervisorPanelPath('reviews'));
scrollToTop();
};
const markReviewDone = async () => {
@@ -2304,10 +2427,6 @@ const loginBySms = async () => {
}
};
window.addEventListener('popstate', () => {
roleKey.value = readRole();
});
const employeeTabs: TabItem[] = [
{ label: '首页', icon: 'home', active: true },
{ label: '训练', icon: 'graduation', active: false },
@@ -2501,9 +2620,130 @@ const fetchPracticeData = async () => {
}
};
const applyRouteFromLocation = () => {
const route = readRoute();
applyingRoute = true;
roleKey.value = route.role;
activeTabLabel.value = '首页';
activeUserPanel.value = '';
activeSupervisorPanel.value = '';
selectedReview.value = null;
userReviewDetail.value = null;
reviewMessage.value = '';
userReviewMessage.value = '';
sopPanelOpen.value = false;
if (route.role === 'user') {
if (route.page === 'training') {
activeTabLabel.value = '训练';
activeUserPanel.value = 'tasks';
void fetchPracticeData();
} else if (route.page === 'knowledge') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'sop';
knowledgeQuery.value = '住宅投诉处理流程';
void searchKnowledge(knowledgeQuery.value, 'sop');
} else if (route.page === 'cases') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'cases';
knowledgeQuery.value = '投诉接待优秀案例';
void searchKnowledge(knowledgeQuery.value, 'case');
} else if (route.page === 'submit') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'submit';
} else if (route.page === 'profile') {
activeTabLabel.value = '我的';
activeUserPanel.value = 'profile';
void fetchPracticeData();
} else if (route.page === 'history') {
activeTabLabel.value = '我的';
activeUserPanel.value = 'history';
void fetchPracticeData();
const id = Number(route.detail);
if (Number.isFinite(id) && id > 0) void loadUserReviewDetail(id);
} else if (route.page === 'recommend') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'sop';
knowledgeQuery.value = '催费沟通专项';
void searchKnowledge(knowledgeQuery.value, 'sop');
} else if (route.page === 'sop') {
sopReturnTabLabel.value = '首页';
sopReturnPath.value = rolePaths.user;
sopPanelOpen.value = true;
}
} else if (route.role === 'candidate') {
if (route.page === 'interview') {
activeTabLabel.value = '面试';
activeUserPanel.value = 'tasks';
} else if (route.page === 'sop') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'sop';
knowledgeQuery.value = '客服接待基础流程';
void searchKnowledge(knowledgeQuery.value, 'sop');
} else if (route.page === 'cases') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'cases';
knowledgeQuery.value = '投诉接待优秀案例';
void searchKnowledge(knowledgeQuery.value, 'case');
} else if (route.page === 'materials') {
activeTabLabel.value = '我的';
activeUserPanel.value = 'submit';
void fetchCandidateMaterials();
} else if (route.page === 'profile') {
activeTabLabel.value = '我的';
activeUserPanel.value = 'profile';
void fetchCandidateMaterials();
} else if (route.page === 'recommend') {
activeTabLabel.value = '知识';
activeUserPanel.value = 'sop';
knowledgeQuery.value = '投诉接待基础';
void searchKnowledge(knowledgeQuery.value, 'sop');
}
} else if (route.role === 'supervisor') {
if (route.page === 'team') {
activeTabLabel.value = '团队';
activeSupervisorPanel.value = 'team';
} else if (route.page === 'reviews') {
activeTabLabel.value = '团队';
activeSupervisorPanel.value = 'reviews';
void fetchPracticeData();
const id = Number(route.detail);
if (Number.isFinite(id) && id > 0) {
selectedReview.value = reviewDetailPlaceholder(id);
void loadSupervisorReviewDetail(id);
}
} else if (route.page === 'assign') {
activeTabLabel.value = '团队';
activeSupervisorPanel.value = 'assign';
void fetchPracticeData();
} else if (route.page === 'weakness') {
activeTabLabel.value = '团队';
activeSupervisorPanel.value = 'weakness';
} else if (route.page === 'cases') {
activeTabLabel.value = '案例';
activeSupervisorPanel.value = 'case';
} else if (route.page === 'profile') {
activeTabLabel.value = '我的';
activeSupervisorPanel.value = 'profile';
} else if (route.page === 'sop') {
sopReturnTabLabel.value = '案例';
sopReturnPath.value = rolePaths.supervisor;
sopPanelOpen.value = true;
}
}
window.setTimeout(() => {
applyingRoute = false;
});
void fetchMobileHome();
};
window.addEventListener('popstate', applyRouteFromLocation);
watch(
roleKey,
() => {
if (applyingRoute) return;
apiHome.value = null;
reviewMessage.value = '';
userReviewMessage.value = '';
@@ -2529,6 +2769,8 @@ watch(
{ immediate: true }
);
applyRouteFromLocation();
const role = computed(() => (apiHome.value?.role === roleKey.value ? apiHome.value : fallbackHomes[roleKey.value]));
const workerRole = computed(
() =>
@@ -3797,6 +4039,101 @@ p {
gap: 8px;
}
.digest-card {
padding: 14px;
border-radius: 12px;
background: linear-gradient(160deg, rgba(35, 150, 133, 0.08), rgba(35, 150, 133, 0.02));
border: 1px solid rgba(35, 150, 133, 0.22);
}
.digest-head {
display: flex;
align-items: flex-start;
gap: 10px;
}
.digest-head strong,
.digest-card > strong {
flex: 1;
display: block;
font-size: 16px;
line-height: 1.45;
color: var(--text-primary, #16323f);
}
.digest-speak {
flex-shrink: 0;
min-height: 32px;
padding: 0 12px;
border-radius: 16px;
border: 1px solid rgba(35, 150, 133, 0.45);
background: #ffffff;
color: #1f8a76;
font-size: 13px;
font-weight: 650;
}
.digest-points {
margin: 10px 0 0;
padding-left: 20px;
display: grid;
gap: 6px;
font-size: 14px;
line-height: 1.5;
}
.digest-cautions {
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(240, 160, 32, 0.12);
border: 1px solid rgba(240, 160, 32, 0.35);
}
.digest-cautions p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #9a6206;
}
.digest-cautions p + p {
margin-top: 4px;
}
.digest-reference {
margin: 10px 0 0;
font-size: 12px;
color: var(--text-secondary);
}
.snippet-toggle {
width: 100%;
min-height: 38px;
margin-top: 10px;
border-radius: 10px;
border: 1px dashed rgba(35, 150, 133, 0.4);
background: transparent;
color: #1f8a76;
font-size: 13px;
font-weight: 650;
}
.sop-result .snippet-list blockquote:not(.expanded) span {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sop-result .snippet-list blockquote em {
display: block;
margin-top: 6px;
font-size: 12px;
font-style: normal;
color: #1f8a76;
}
.knowledge-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -4409,6 +4746,17 @@ p {
gap: 12px;
}
.detail-back {
width: max-content;
min-height: 32px;
padding: 0 12px;
border-radius: 9px;
color: var(--primary-teal-active);
background: var(--primary-teal-light);
font-size: 12px;
font-weight: 800;
}
.review-detail-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -4481,7 +4829,7 @@ p {
overflow: auto;
}
.review-detail > button {
.review-detail > button:not(.detail-back) {
min-height: 42px;
border-radius: 10px;
color: #ffffff;
@@ -4492,7 +4840,7 @@ p {
transition: var(--transition-spring);
}
.review-detail > button:disabled {
.review-detail > button:not(.detail-back):disabled {
color: var(--primary-teal-active);
background: var(--primary-teal-light);
box-shadow: none;