From 4c5c343acaf1a1fd2dd9e3d7573d32b6ccef2d68 Mon Sep 17 00:00:00 2001
From: let5sne
Date: Sat, 4 Jul 2026 23:27:08 +0800
Subject: [PATCH] feat(aihr): structured digest answer with layered mobile
presentation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
用户反馈「检索返回一堆文字不便总结」修复。后端:检索命中后新增
RAG 生成环节 searchDigest——chat 模型 temperature=0 把 top5 片段压
成结构化概要(直接回答≤60字+3-6条可执行要点+0-3条注意红线),硬约束
不得编造、片段答不了则明示无依据;模型未配置/超时/解析失败回退
片段原文拼接,15s 超时不拖垮检索。SearchResponse 增 keyPoints/
cautions 字段。移动端:sopPanel 结果改三层——概要卡(回答大字+要点
列表+警示色块)默认唯一展开;「查看依据」折叠原文片段,单条3行截断
点击展开;「听一遍」按钮把概要送 TTS 播报(独立播放状态,关面板即停,
未配 tts 提示降级)。候选人端知识 tab 同套概要卡+依据折叠,消除
原文倾泻。实测:「客服接待基础流程」4s 返回概要+5要点+3红线。
---
.../org/dromara/aihr/domain/AihrSopDto.java | 4 +-
.../aihr/service/AihrSopSeedService.java | 111 ++-
mobile/src/App.vue | 658 +++++++++++++-----
3 files changed, 613 insertions(+), 160 deletions(-)
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
index a40f67c1..0780b77d 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
@@ -65,7 +65,9 @@ public final class AihrSopDto {
List docs,
List snippets,
List training,
- List records
+ List records,
+ List keyPoints,
+ List cautions
) {
}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
index 60d5424f..1f9a1786 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
@@ -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 hits) {
+ private SearchResponse responseFromHits(String category, String queryText, List hits) {
KnowledgeHit first = hits.get(0);
String responseCategory = isBlank(category) ? first.category() : category;
List 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 hits) {
+ Optional 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 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 jsonStrings(JsonNode node, int maxItems, int maxChars) {
+ if (!node.isArray()) {
+ return List.of();
+ }
+ List 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 keyPoints, List cautions) {
+ }
+
private static String dbCategory(String category) {
return "sop".equalsIgnoreCase(Optional.ofNullable(category).orElse("").trim()) ? "" : category;
}
diff --git a/mobile/src/App.vue b/mobile/src/App.vue
index 84c89245..17295742 100644
--- a/mobile/src/App.vue
+++ b/mobile/src/App.vue
@@ -52,7 +52,7 @@
-
+
- {{ reviewMessage }}
-
-
-
-
- {{ selectedReview.trainee }} · {{ selectedReview.score }}分
- {{ selectedReview.time }} · {{ selectedReview.scene }}
-
- {{ selectedReview.status }}
-
-
-
-
- {{ item.label }}
- {{ item.value }}
- {{ item.note }}
-
-
-
-
-
- {{ turn.label }}
- {{ turn.text }}
-
-
-
-
-
导师改写
-
{{ selectedReview.mentorRewrite }}
-
AI点评
-
{{ selectedReview.aiComment }}
-
-
-
-
{{ reviewMessage }}
-
@@ -192,7 +154,7 @@
-
+
{{ activeSupervisorPanel === 'assign' ? '建议指派对象' : '待复盘对练' }}
-
暂无待复盘记录
+
暂无待复盘记录
+
{{ reviewMessage }}
+
{{ selectedReview.trainee }} · {{ selectedReview.score }}分
@@ -239,7 +203,7 @@
{{ reviewMessage }}
-
+
专项建议
催费沟通专项
@@ -255,14 +219,26 @@
{{ knowledgeMessage }}
-
{{ knowledgeResult.answer }}
-
{{ knowledgeResult.reference }}
+
+
{{ knowledgeResult.answer }}
+
+ - {{ point }}
+
+
+
{{ knowledgeResult.reference }}
+
-
引用片段
-
- {{ item.title }}
- {{ item.text }}
-
+
+
+
+ {{ item.title }}
+ {{ item.text }}
+
+
@@ -345,44 +321,6 @@
-
-
- 三角色对练
- {{ practiceResult.totalScore }}分
- {{ practiceStatus === 'starting' ? '准备中' : '进行中' }}
-
-
-
-
{{ practiceResult.aiComment }}
-
{{ practiceResult.mentorRewrite }}
-
-
-
-
-
-
-
-
-
- {{ turn.label }}
- {{ turn.text }}
-
-
-
-
-
-
-
-
-
-
-
-
{{ practiceMessage }}
-
@@ -549,6 +487,9 @@
+
-
-
- 综合能力
- {{ competencyProfile.score }}
-
-
- 训练历史
- {{ competencyProfile.completed }}
-
-
- 待复盘
- {{ competencyProfile.pendingReview }}
-
-
-
- {{ item.label }}
- {{ item.value }}
- {{ item.note }}
-
-
-
-
-
-
训练历史
-
-
@@ -882,20 +791,44 @@
{{ sopRecording ? '停止录音' : sopAsrBusy ? '识别中' : sopUseAudioFileFallback ? '选择语音' : '语音提问' }}
-
+
{{ sopMessage }}
-
{{ sopResult.answer }}
-
{{ sopResult.reference }}
+
+
+ {{ sopResult.answer }}
+
+
+
+ - {{ point }}
+
+
+
{{ sopResult.reference }}
+
+
-
引用片段
-
- {{ item.title }}
- {{ item.text }}
-
+
+
+
+ {{ item.title }}
+ {{ item.text }}
+ 点击展开全文
+
+
@@ -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 = {
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, string> = {
+ tasks: 'training',
+ sop: 'knowledge',
+ cases: 'cases',
+ submit: 'submit',
+ profile: 'profile',
+ history: 'history',
+ recommend: 'recommend'
+};
+
+const candidatePanelSegments: Record, string> = {
+ tasks: 'interview',
+ sop: 'sop',
+ cases: 'cases',
+ submit: 'materials',
+ profile: 'profile',
+ history: 'history',
+ recommend: 'recommend'
+};
+
+const supervisorPanelSegments: Record, 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('');
const activeSupervisorPanel = ref('');
const knowledgeQuery = ref('住宅投诉处理流程');
const knowledgeResult = ref(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(null);
const sopLoading = ref(false);
@@ -1297,6 +1278,11 @@ const sopRecording = ref(false);
const sopAsrBusy = ref(false);
const sopUseAudioFileFallback = ref(false);
const sopAudioInput = ref();
+const sopSnippetsOpen = ref(false);
+const expandedSnippets = ref(new Set());
+const sopSpeaking = ref(false);
+const sopSpeakLoading = ref(false);
+let sopAudio: HTMLAudioElement | null = null;
const materialFile = ref(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(
await fetch('/dev-api/api/knowledge/search', {
@@ -1666,6 +1727,7 @@ const searchKnowledge = async (query = knowledgeQuery.value, category = activeUs
const switchKnowledgePanel = (panel: Extract) => {
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>)[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(
+ 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(
- 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(
+ 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(
- 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;