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:
+3
-1
@@ -65,7 +65,9 @@ public final class AihrSopDto {
|
|||||||
List<DocResponse> docs,
|
List<DocResponse> docs,
|
||||||
List<SnippetResponse> snippets,
|
List<SnippetResponse> snippets,
|
||||||
List<String> training,
|
List<String> training,
|
||||||
List<RecordResponse> records
|
List<RecordResponse> records,
|
||||||
|
List<String> keyPoints,
|
||||||
|
List<String> cautions
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-4
@@ -123,7 +123,9 @@ public class AihrSopSeedService {
|
|||||||
seed.docs(),
|
seed.docs(),
|
||||||
seed.snippets(),
|
seed.snippets(),
|
||||||
seed.training(),
|
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);
|
KnowledgeHit first = hits.get(0);
|
||||||
String responseCategory = isBlank(category) ? first.category() : category;
|
String responseCategory = isBlank(category) ? first.category() : category;
|
||||||
List<SnippetResponse> snippets = hits.stream()
|
List<SnippetResponse> snippets = hits.stream()
|
||||||
@@ -1736,18 +1738,119 @@ public class AihrSopSeedService {
|
|||||||
for (KnowledgeHit hit : hits) {
|
for (KnowledgeHit hit : hits) {
|
||||||
docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success"));
|
docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success"));
|
||||||
}
|
}
|
||||||
|
SearchDigest digest = searchDigest(queryText, hits);
|
||||||
return new SearchResponse(
|
return new SearchResponse(
|
||||||
queryText,
|
queryText,
|
||||||
responseCategory,
|
responseCategory,
|
||||||
"根据已入库 SOP:" + first.content(),
|
digest != null ? digest.answer() : "根据已入库 SOP:" + first.content(),
|
||||||
"引用 " + docs.size() + " 份文档 / " + snippets.size() + " 个片段",
|
"引用 " + docs.size() + " 份文档 / " + snippets.size() + " 个片段",
|
||||||
List.copyOf(docs.values()),
|
List.copyOf(docs.values()),
|
||||||
snippets,
|
snippets,
|
||||||
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
|
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) {
|
private static String dbCategory(String category) {
|
||||||
return "sop".equalsIgnoreCase(Optional.ofNullable(category).orElse("").trim()) ? "" : category;
|
return "sop".equalsIgnoreCase(Optional.ofNullable(category).orElse("").trim()) ? "" : category;
|
||||||
}
|
}
|
||||||
|
|||||||
+503
-155
@@ -52,7 +52,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card v-if="roleKey !== 'user' && isHomeTab" :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
|
<Card v-if="isHomeTab" :title="roleKey === 'supervisor' ? '今日处理流程' : '今日使用流程'">
|
||||||
<div class="flow-steps">
|
<div class="flow-steps">
|
||||||
<button v-for="item in roleFlowSteps" :key="`${item.phase}-${item.title}`" type="button" :class="{ active: isFlowActive(item) }" @click="openFlowStep(item)">
|
<button v-for="item in roleFlowSteps" :key="`${item.phase}-${item.title}`" type="button" :class="{ active: isFlowActive(item) }" @click="openFlowStep(item)">
|
||||||
<em>{{ item.phase }}</em>
|
<em>{{ item.phase }}</em>
|
||||||
@@ -118,44 +118,6 @@
|
|||||||
<em>{{ item.status }}</em>
|
<em>{{ item.status }}</em>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Card :title="supervisorRole.toolTitle">
|
<Card :title="supervisorRole.toolTitle">
|
||||||
@@ -192,7 +154,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="activeSupervisorPanel === 'reviews' || activeSupervisorPanel === 'assign'">
|
<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>
|
<h3>{{ activeSupervisorPanel === 'assign' ? '建议指派对象' : '待复盘对练' }}</h3>
|
||||||
<button v-for="item in practiceReviews" :key="item.id || `${item.time}-${item.trainee}-${item.scene}`" type="button" @click="openPracticeReview(item)">
|
<button v-for="item in practiceReviews" :key="item.id || `${item.time}-${item.trainee}-${item.scene}`" type="button" @click="openPracticeReview(item)">
|
||||||
<span>
|
<span>
|
||||||
@@ -202,8 +164,10 @@
|
|||||||
<em>{{ item.status }}</em>
|
<em>{{ item.status }}</em>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div v-if="selectedReview" class="review-detail">
|
||||||
|
<button class="detail-back" type="button" @click="closeSupervisorReviewDetail">返回列表</button>
|
||||||
<div class="review-detail-head">
|
<div class="review-detail-head">
|
||||||
<span>
|
<span>
|
||||||
<b>{{ selectedReview.trainee }} · {{ selectedReview.score }}分</b>
|
<b>{{ selectedReview.trainee }} · {{ selectedReview.score }}分</b>
|
||||||
@@ -239,7 +203,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<em v-if="reviewMessage">{{ reviewMessage }}</em>
|
<em v-if="reviewMessage">{{ reviewMessage }}</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-list">
|
<div v-if="!selectedReview" class="result-list">
|
||||||
<h3>专项建议</h3>
|
<h3>专项建议</h3>
|
||||||
<p>
|
<p>
|
||||||
<b>催费沟通专项</b>
|
<b>催费沟通专项</b>
|
||||||
@@ -255,14 +219,26 @@
|
|||||||
</form>
|
</form>
|
||||||
<em v-if="knowledgeMessage" class="panel-message">{{ knowledgeMessage }}</em>
|
<em v-if="knowledgeMessage" class="panel-message">{{ knowledgeMessage }}</em>
|
||||||
<div v-if="knowledgeResult" class="knowledge-result">
|
<div v-if="knowledgeResult" class="knowledge-result">
|
||||||
<strong>{{ knowledgeResult.answer }}</strong>
|
<div class="digest-card">
|
||||||
<p v-if="knowledgeResult.reference">{{ knowledgeResult.reference }}</p>
|
<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">
|
<div v-if="knowledgeResult.snippets?.length" class="snippet-list">
|
||||||
<h3>引用片段</h3>
|
<button class="snippet-toggle" type="button" @click="knowledgeSnippetsOpen = !knowledgeSnippetsOpen">
|
||||||
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
|
{{ knowledgeSnippetsOpen ? '收起依据' : `查看依据(${knowledgeResult.snippets.length} 条原文片段)` }}
|
||||||
<b>{{ item.title }}</b>
|
</button>
|
||||||
<span>{{ item.text }}</span>
|
<template v-if="knowledgeSnippetsOpen">
|
||||||
</blockquote>
|
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
|
||||||
|
<b>{{ item.title }}</b>
|
||||||
|
<span>{{ item.text }}</span>
|
||||||
|
</blockquote>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -345,44 +321,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="scenario-section">
|
<section class="scenario-section">
|
||||||
@@ -549,6 +487,9 @@
|
|||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-right" size="24" />
|
<Icon name="chevron-right" size="24" />
|
||||||
</button>
|
</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')">
|
<button class="panel-row" type="button" @click="openUserAction('sop')">
|
||||||
<IconBubble icon="document" tone="amber" />
|
<IconBubble icon="document" tone="amber" />
|
||||||
<span>
|
<span>
|
||||||
@@ -825,38 +766,6 @@
|
|||||||
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
|
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -882,20 +791,44 @@
|
|||||||
{{ sopRecording ? '停止录音' : sopAsrBusy ? '识别中' : sopUseAudioFileFallback ? '选择语音' : '语音提问' }}
|
{{ sopRecording ? '停止录音' : sopAsrBusy ? '识别中' : sopUseAudioFileFallback ? '选择语音' : '语音提问' }}
|
||||||
</button>
|
</button>
|
||||||
<input ref="sopAudioInput" class="practice-audio-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" capture @change="handleSopAudioFile" />
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<em v-if="sopMessage" class="panel-message">{{ sopMessage }}</em>
|
<em v-if="sopMessage" class="panel-message">{{ sopMessage }}</em>
|
||||||
<div v-if="sopResult" class="knowledge-result sop-result">
|
<div v-if="sopResult" class="knowledge-result sop-result">
|
||||||
<strong>{{ sopResult.answer }}</strong>
|
<div class="digest-card">
|
||||||
<p v-if="sopResult.reference">{{ sopResult.reference }}</p>
|
<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">
|
<div v-if="sopResult.snippets?.length" class="snippet-list">
|
||||||
<h3>引用片段</h3>
|
<button class="snippet-toggle" type="button" @click="sopSnippetsOpen = !sopSnippetsOpen">
|
||||||
<blockquote v-for="item in sopResult.snippets" :key="`${item.title}-${item.text}`">
|
{{ sopSnippetsOpen ? '收起依据' : `查看依据(${sopResult.snippets.length} 条原文片段)` }}
|
||||||
<b>{{ item.title }}</b>
|
</button>
|
||||||
<span>{{ item.text }}</span>
|
<template v-if="sopSnippetsOpen">
|
||||||
</blockquote>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1161,6 +1094,8 @@ interface KnowledgeSearchResponse {
|
|||||||
snippets: KnowledgeSnippet[];
|
snippets: KnowledgeSnippet[];
|
||||||
training: string[];
|
training: string[];
|
||||||
records: KnowledgeRecord[];
|
records: KnowledgeRecord[];
|
||||||
|
keyPoints?: string[];
|
||||||
|
cautions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CaseTag {
|
interface CaseTag {
|
||||||
@@ -1236,9 +1171,53 @@ const rolePaths: Record<RoleKey, string> = {
|
|||||||
supervisor: '/h5/supervisor'
|
supervisor: '/h5/supervisor'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface MobileRoute {
|
||||||
|
role: RoleKey;
|
||||||
|
page: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = () => window.location.pathname.split('/').filter(Boolean);
|
||||||
const readRole = (): RoleKey => {
|
const readRole = (): RoleKey => {
|
||||||
const last = window.location.pathname.split('/').filter(Boolean).at(-1) || 'user';
|
const parts = pathParts();
|
||||||
return aliases[last] || 'user';
|
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))}%`;
|
const clampPercent = (value: number) => `${Math.max(0, Math.min(value, 100))}%`;
|
||||||
@@ -1285,10 +1264,12 @@ const activeUserPanel = ref<UserPanelKey>('');
|
|||||||
const activeSupervisorPanel = ref<SupervisorPanelKey>('');
|
const activeSupervisorPanel = ref<SupervisorPanelKey>('');
|
||||||
const knowledgeQuery = ref('住宅投诉处理流程');
|
const knowledgeQuery = ref('住宅投诉处理流程');
|
||||||
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
|
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
|
||||||
|
const knowledgeSnippetsOpen = ref(false);
|
||||||
const knowledgeLoading = ref(false);
|
const knowledgeLoading = ref(false);
|
||||||
const knowledgeMessage = ref('');
|
const knowledgeMessage = ref('');
|
||||||
const sopPanelOpen = ref(false);
|
const sopPanelOpen = ref(false);
|
||||||
const sopReturnTabLabel = ref('首页');
|
const sopReturnTabLabel = ref('首页');
|
||||||
|
const sopReturnPath = ref(rolePaths[roleKey.value]);
|
||||||
const sopQuery = ref('');
|
const sopQuery = ref('');
|
||||||
const sopResult = ref<KnowledgeSearchResponse | null>(null);
|
const sopResult = ref<KnowledgeSearchResponse | null>(null);
|
||||||
const sopLoading = ref(false);
|
const sopLoading = ref(false);
|
||||||
@@ -1297,6 +1278,11 @@ const sopRecording = ref(false);
|
|||||||
const sopAsrBusy = ref(false);
|
const sopAsrBusy = ref(false);
|
||||||
const sopUseAudioFileFallback = ref(false);
|
const sopUseAudioFileFallback = ref(false);
|
||||||
const sopAudioInput = ref<HTMLInputElement>();
|
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 materialFile = ref<File | null>(null);
|
||||||
const materialUploading = ref(false);
|
const materialUploading = ref(false);
|
||||||
const materialMessage = ref('');
|
const materialMessage = ref('');
|
||||||
@@ -1313,8 +1299,19 @@ let recordedChunks: Blob[] = [];
|
|||||||
let sopMediaRecorder: MediaRecorder | null = null;
|
let sopMediaRecorder: MediaRecorder | null = null;
|
||||||
let sopRecordedChunks: Blob[] = [];
|
let sopRecordedChunks: Blob[] = [];
|
||||||
let customerAudio: HTMLAudioElement | null = null;
|
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 = () => {
|
const scrollToTop = () => {
|
||||||
window.requestAnimationFrame(() => window.scrollTo({ top: 0 }));
|
window.requestAnimationFrame(() => window.scrollTo({ top: 0 }));
|
||||||
};
|
};
|
||||||
@@ -1521,18 +1518,23 @@ const transcribeRecording = async (blob: Blob, filename = 'practice.webm') => {
|
|||||||
|
|
||||||
const openSopPanel = () => {
|
const openSopPanel = () => {
|
||||||
sopReturnTabLabel.value = activeTabLabel.value;
|
sopReturnTabLabel.value = activeTabLabel.value;
|
||||||
|
sopReturnPath.value = window.location.pathname.endsWith('/sop') ? rolePaths[roleKey.value] : window.location.pathname;
|
||||||
sopPanelOpen.value = true;
|
sopPanelOpen.value = true;
|
||||||
sopQuery.value = '';
|
sopQuery.value = '';
|
||||||
sopResult.value = null;
|
sopResult.value = null;
|
||||||
sopMessage.value = '';
|
sopMessage.value = '';
|
||||||
|
navigatePath(`${rolePaths[roleKey.value]}/sop`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeSopPanel = () => {
|
const closeSopPanel = () => {
|
||||||
if (sopRecording.value) {
|
if (sopRecording.value) {
|
||||||
sopMediaRecorder?.stop();
|
sopMediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
|
stopSopSpeak();
|
||||||
sopPanelOpen.value = false;
|
sopPanelOpen.value = false;
|
||||||
activeTabLabel.value = sopReturnTabLabel.value;
|
activeTabLabel.value = sopReturnTabLabel.value;
|
||||||
|
navigatePath(sopReturnPath.value || rolePaths[roleKey.value]);
|
||||||
|
applyRouteFromLocation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSopRecording = async () => {
|
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 = () => {
|
const handlePrimaryAction = () => {
|
||||||
if (roleKey.value === 'candidate') {
|
if (roleKey.value === 'candidate') {
|
||||||
void startCandidateInterview();
|
void startCandidateInterview();
|
||||||
@@ -1602,9 +1613,55 @@ const handlePrimaryAction = () => {
|
|||||||
tap(workerRole.value.primary.cta);
|
tap(workerRole.value.primary.cta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openUserPracticePanel();
|
||||||
void startMobilePractice();
|
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 searchSopKnowledge = async () => {
|
||||||
const cleaned = sopQuery.value.trim();
|
const cleaned = sopQuery.value.trim();
|
||||||
if (!cleaned) {
|
if (!cleaned) {
|
||||||
@@ -1615,8 +1672,12 @@ const searchSopKnowledge = async () => {
|
|||||||
sopLoading.value = true;
|
sopLoading.value = true;
|
||||||
sopMessage.value = '';
|
sopMessage.value = '';
|
||||||
sopResult.value = null;
|
sopResult.value = null;
|
||||||
|
sopSnippetsOpen.value = false;
|
||||||
|
expandedSnippets.value = new Set();
|
||||||
|
stopSopSpeak();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 15000);
|
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 25000);
|
||||||
try {
|
try {
|
||||||
sopResult.value = await readApi<KnowledgeSearchResponse>(
|
sopResult.value = await readApi<KnowledgeSearchResponse>(
|
||||||
await fetch('/dev-api/api/knowledge/search', {
|
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'>) => {
|
const switchKnowledgePanel = (panel: Extract<UserPanelKey, 'sop' | 'cases'>) => {
|
||||||
activeTabLabel.value = '知识';
|
activeTabLabel.value = '知识';
|
||||||
activeUserPanel.value = panel;
|
activeUserPanel.value = panel;
|
||||||
|
navigatePath(roleKey.value === 'candidate' ? candidatePanelPath(panel) : userPanelPath(panel));
|
||||||
knowledgeQuery.value =
|
knowledgeQuery.value =
|
||||||
panel === 'cases' ? '投诉接待优秀案例' : roleKey.value === 'candidate' ? '客服接待基础流程' : '住宅投诉处理流程';
|
panel === 'cases' ? '投诉接待优秀案例' : roleKey.value === 'candidate' ? '客服接待基础流程' : '住宅投诉处理流程';
|
||||||
void searchKnowledge(knowledgeQuery.value, panel === 'cases' ? 'case' : 'sop');
|
void searchKnowledge(knowledgeQuery.value, panel === 'cases' ? 'case' : 'sop');
|
||||||
@@ -1694,6 +1756,7 @@ const openCandidateAction = (panel: UserPanelKey) => {
|
|||||||
activeUserPanel.value = panel;
|
activeUserPanel.value = panel;
|
||||||
userReviewDetail.value = null;
|
userReviewDetail.value = null;
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
|
navigatePath(candidatePanelPath(panel));
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
|
|
||||||
if (panel === 'sop') {
|
if (panel === 'sop') {
|
||||||
@@ -1740,6 +1803,7 @@ const openUserAction = (panel: UserPanelKey) => {
|
|||||||
activeUserPanel.value = panel;
|
activeUserPanel.value = panel;
|
||||||
userReviewDetail.value = null;
|
userReviewDetail.value = null;
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
|
navigatePath(userPanelPath(panel));
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
|
|
||||||
if (panel === 'cases') {
|
if (panel === 'cases') {
|
||||||
@@ -1786,6 +1850,7 @@ const openUserTool = (item: ToolItem) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.title === '每日一练') {
|
if (item.title === '每日一练') {
|
||||||
|
openUserPracticePanel();
|
||||||
void startMobilePractice();
|
void startMobilePractice();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1817,6 +1882,7 @@ const openSupervisorAction = (panel: SupervisorPanelKey) => {
|
|||||||
({ team: '团队', reviews: '团队', assign: '团队', weakness: '团队', case: '案例', sop: '案例', profile: '我的' } as Partial<Record<SupervisorPanelKey, string>>)[panel] || '首页';
|
({ team: '团队', reviews: '团队', assign: '团队', weakness: '团队', case: '案例', sop: '案例', profile: '我的' } as Partial<Record<SupervisorPanelKey, string>>)[panel] || '首页';
|
||||||
activeSupervisorPanel.value = panel;
|
activeSupervisorPanel.value = panel;
|
||||||
activeUserPanel.value = '';
|
activeUserPanel.value = '';
|
||||||
|
navigatePath(supervisorPanelPath(panel));
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
if (panel === 'reviews' || panel === 'assign') {
|
if (panel === 'reviews' || panel === 'assign') {
|
||||||
void fetchPracticeData();
|
void fetchPracticeData();
|
||||||
@@ -1863,6 +1929,7 @@ const openTab = (label: string) => {
|
|||||||
activeSupervisorPanel.value = '';
|
activeSupervisorPanel.value = '';
|
||||||
activeUserPanel.value = '';
|
activeUserPanel.value = '';
|
||||||
selectedReview.value = null;
|
selectedReview.value = null;
|
||||||
|
syncPath();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (label === '团队') {
|
if (label === '团队') {
|
||||||
@@ -1884,6 +1951,7 @@ const openTab = (label: string) => {
|
|||||||
activeUserPanel.value = '';
|
activeUserPanel.value = '';
|
||||||
userReviewDetail.value = null;
|
userReviewDetail.value = null;
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
|
syncPath();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (label === '面试') {
|
if (label === '面试') {
|
||||||
@@ -1908,13 +1976,11 @@ const openTab = (label: string) => {
|
|||||||
activeUserPanel.value = '';
|
activeUserPanel.value = '';
|
||||||
userReviewDetail.value = null;
|
userReviewDetail.value = null;
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
|
syncPath();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (label === '训练') {
|
if (label === '训练') {
|
||||||
activeUserPanel.value = 'tasks';
|
openUserAction('tasks');
|
||||||
userReviewDetail.value = null;
|
|
||||||
userReviewMessage.value = '';
|
|
||||||
void fetchPracticeData();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (label === '知识') {
|
if (label === '知识') {
|
||||||
@@ -1928,12 +1994,52 @@ const openTab = (label: string) => {
|
|||||||
tap(label);
|
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) => {
|
const openUserHistory = async (item: PracticeRecord) => {
|
||||||
activeTabLabel.value = '我的';
|
activeTabLabel.value = '我的';
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
activeUserPanel.value = 'history';
|
activeUserPanel.value = 'history';
|
||||||
userReviewMessage.value = '这条训练记录暂无复盘详情';
|
userReviewMessage.value = '这条训练记录暂无复盘详情';
|
||||||
userReviewDetail.value = null;
|
userReviewDetail.value = null;
|
||||||
|
navigatePath(userPanelPath('history'));
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1941,14 +2047,9 @@ const openUserHistory = async (item: PracticeRecord) => {
|
|||||||
activeUserPanel.value = 'history';
|
activeUserPanel.value = 'history';
|
||||||
userReviewDetail.value = null;
|
userReviewDetail.value = null;
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
|
navigatePath(`${userPanelPath('history')}/${item.id}`);
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
try {
|
await loadUserReviewDetail(item.id);
|
||||||
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 handleMaterialFile = (event: Event) => {
|
||||||
@@ -2140,6 +2241,7 @@ const submitMobilePractice = async () => {
|
|||||||
const startCandidateInterview = async () => {
|
const startCandidateInterview = async () => {
|
||||||
activeUserPanel.value = 'tasks';
|
activeUserPanel.value = 'tasks';
|
||||||
activeTabLabel.value = '面试';
|
activeTabLabel.value = '面试';
|
||||||
|
navigatePath(candidatePanelPath('tasks'));
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
interviewStatus.value = 'starting';
|
interviewStatus.value = 'starting';
|
||||||
interviewSessionId.value = '';
|
interviewSessionId.value = '';
|
||||||
@@ -2203,23 +2305,44 @@ const finishCandidateInterview = async () => {
|
|||||||
|
|
||||||
const goSupervisor = () => {
|
const goSupervisor = () => {
|
||||||
roleKey.value = 'supervisor';
|
roleKey.value = 'supervisor';
|
||||||
|
activeTabLabel.value = '首页';
|
||||||
|
activeSupervisorPanel.value = '';
|
||||||
|
activeUserPanel.value = '';
|
||||||
syncPath();
|
syncPath();
|
||||||
void fetchMobileHome();
|
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) => {
|
const openPracticeReview = async (item: PracticeRecord) => {
|
||||||
if (!item.id) {
|
if (!item.id) {
|
||||||
tap(item.trainee);
|
tap(item.trainee);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
activeTabLabel.value = '团队';
|
||||||
|
activeSupervisorPanel.value = 'reviews';
|
||||||
|
activeUserPanel.value = '';
|
||||||
|
selectedReview.value = reviewDetailFromRecord(item);
|
||||||
reviewMessage.value = '';
|
reviewMessage.value = '';
|
||||||
try {
|
navigatePath(`${supervisorPanelPath('reviews')}/${item.id}`);
|
||||||
selectedReview.value = await readApi<PracticeReviewDetail>(
|
scrollToTop();
|
||||||
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
|
await loadSupervisorReviewDetail(item.id);
|
||||||
);
|
};
|
||||||
} catch (error) {
|
|
||||||
reviewMessage.value = error instanceof Error ? error.message : '复盘详情加载失败';
|
const closeSupervisorReviewDetail = () => {
|
||||||
}
|
selectedReview.value = null;
|
||||||
|
reviewMessage.value = '';
|
||||||
|
navigatePath(supervisorPanelPath('reviews'));
|
||||||
|
scrollToTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const markReviewDone = async () => {
|
const markReviewDone = async () => {
|
||||||
@@ -2304,10 +2427,6 @@ const loginBySms = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
|
||||||
roleKey.value = readRole();
|
|
||||||
});
|
|
||||||
|
|
||||||
const employeeTabs: TabItem[] = [
|
const employeeTabs: TabItem[] = [
|
||||||
{ label: '首页', icon: 'home', active: true },
|
{ label: '首页', icon: 'home', active: true },
|
||||||
{ label: '训练', icon: 'graduation', active: false },
|
{ 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(
|
watch(
|
||||||
roleKey,
|
roleKey,
|
||||||
() => {
|
() => {
|
||||||
|
if (applyingRoute) return;
|
||||||
apiHome.value = null;
|
apiHome.value = null;
|
||||||
reviewMessage.value = '';
|
reviewMessage.value = '';
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
@@ -2529,6 +2769,8 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
applyRouteFromLocation();
|
||||||
|
|
||||||
const role = computed(() => (apiHome.value?.role === roleKey.value ? apiHome.value : fallbackHomes[roleKey.value]));
|
const role = computed(() => (apiHome.value?.role === roleKey.value ? apiHome.value : fallbackHomes[roleKey.value]));
|
||||||
const workerRole = computed(
|
const workerRole = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -3797,6 +4039,101 @@ p {
|
|||||||
gap: 8px;
|
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 {
|
.knowledge-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -4409,6 +4746,17 @@ p {
|
|||||||
gap: 12px;
|
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 {
|
.review-detail-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -4481,7 +4829,7 @@ p {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-detail > button {
|
.review-detail > button:not(.detail-back) {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -4492,7 +4840,7 @@ p {
|
|||||||
transition: var(--transition-spring);
|
transition: var(--transition-spring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-detail > button:disabled {
|
.review-detail > button:not(.detail-back):disabled {
|
||||||
color: var(--primary-teal-active);
|
color: var(--primary-teal-active);
|
||||||
background: var(--primary-teal-light);
|
background: var(--primary-teal-light);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user