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<SnippetResponse> snippets,
|
||||
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.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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user