feat(aihr): on-demand summary card with infographic bottom sheet
P0-2 按甲方钦定交互落地(反馈#2:显式按钮+小窗+预期等待):新端点
POST /api/knowledge/summary-card 复用检索命中片段调 chat 模型
(temperature=0,30s超时)生成结构化卡{title,steps,objections,scripts,
reminders},无命中/无模型/失败均明确报错可重试,不兜假数据;检索
底层拆出 dbHits() 避免总结卡重复触发轻概要 LLM。移动端:概要卡下
「✨生成总结卡·约10秒生成」按钮→底部弹层(拖条+X+深遮罩),四区块
信息图排版(蓝序号四步法/橙块异议/绿引用话术/✓提醒)对照设计基准,
430px 两列 375px 单列;听一遍(独立 TTS 状态)+开始跟读(问练焊接);
SOP 面板配色统一蓝色系。「保存图片」需 html2canvas 依赖本轮不做。
实测:端点4s返回结构化卡;浏览器弹层与 summary-modal 设计图逐区块
一致。
This commit is contained in:
+7
@@ -7,6 +7,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
@@ -61,6 +63,11 @@ public class AihrSopController {
|
||||
return R.ok(sopSeedService.search(request));
|
||||
}
|
||||
|
||||
@PostMapping("/summary-card")
|
||||
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
|
||||
return R.ok(sopSeedService.summaryCard(request));
|
||||
}
|
||||
|
||||
@PostMapping("/doc/upload")
|
||||
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
|
||||
return R.ok(sopSeedService.uploadDoc(file, category));
|
||||
|
||||
+12
@@ -10,6 +10,18 @@ public final class AihrSopDto {
|
||||
public record SearchRequest(String queryText, String category, Integer limit) {
|
||||
}
|
||||
|
||||
public record SummaryCardRequest(String queryText, String category) {
|
||||
}
|
||||
|
||||
public record SummaryCardResponse(String title, List<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> reminders) {
|
||||
}
|
||||
|
||||
public record CardStep(String label, String desc) {
|
||||
}
|
||||
|
||||
public record CardObjection(String concern, String response) {
|
||||
}
|
||||
|
||||
public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List<String> tags, List<SnippetResponse> snippets) {
|
||||
}
|
||||
|
||||
|
||||
+145
-4
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.CardObjection;
|
||||
import org.dromara.aihr.domain.AihrSopDto.CardStep;
|
||||
import org.dromara.aihr.domain.AihrSopDto.DocResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportItemResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
|
||||
@@ -21,6 +23,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
@@ -129,6 +133,55 @@ public class AihrSopSeedService {
|
||||
);
|
||||
}
|
||||
|
||||
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
|
||||
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
|
||||
if (isBlank(queryText)) {
|
||||
throw new ServiceException("请先输入问题");
|
||||
}
|
||||
String category = request == null ? "sop" : firstNonBlank(request.category(), "sop");
|
||||
List<KnowledgeHit> hits;
|
||||
try {
|
||||
hits = dbHits(dbCategory(category), queryText, 5);
|
||||
} catch (DataAccessException e) {
|
||||
hits = List.of();
|
||||
}
|
||||
if (hits.isEmpty()) {
|
||||
throw new ServiceException("知识库中未检索到相关内容");
|
||||
}
|
||||
Optional<ChatRuntime> runtime = chatRuntime();
|
||||
if (runtime.isEmpty()) {
|
||||
throw new ServiceException("未配置对话模型,无法生成总结卡");
|
||||
}
|
||||
|
||||
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 = callSummaryCardModel(runtime.get(), queryText, context.toString());
|
||||
JsonNode root = objectMapper.readTree(jsonObjectText(content));
|
||||
String title = truncate(root.path("title").asText("").strip(), 14);
|
||||
List<CardStep> steps = jsonCardSteps(root.path("steps"));
|
||||
if (isBlank(title) || steps.isEmpty()) {
|
||||
throw new IllegalStateException("summary card missing title or steps");
|
||||
}
|
||||
return new SummaryCardResponse(
|
||||
title,
|
||||
steps,
|
||||
jsonCardObjections(root.path("objections")),
|
||||
jsonStrings(root.path("scripts"), 3, 90),
|
||||
jsonStrings(root.path("reminders"), 5, 20)
|
||||
);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("summary card failed: {}", e.getMessage());
|
||||
throw new ServiceException("总结卡生成失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
public UploadResponse uploadDoc(MultipartFile file, String category) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("上传文件不能为空");
|
||||
@@ -1567,10 +1620,7 @@ public class AihrSopSeedService {
|
||||
|
||||
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
|
||||
try {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
|
||||
List<KnowledgeHit> hits = rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
|
||||
List<KnowledgeHit> hits = dbHits(category, queryText, limit);
|
||||
if (hits.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -1580,6 +1630,13 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
|
||||
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
|
||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||
try {
|
||||
@@ -1831,6 +1888,54 @@ public class AihrSopSeedService {
|
||||
return choices.get(0).path("message").path("content").asText();
|
||||
}
|
||||
|
||||
private String callSummaryCardModel(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、代码块或解释。
|
||||
输出格式固定为:
|
||||
{"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]}
|
||||
字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
|
||||
硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。
|
||||
""");
|
||||
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
user.put("content", """
|
||||
员工的问题:%s
|
||||
|
||||
检索命中的知识片段:
|
||||
%s
|
||||
""".formatted(queryText, context));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.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("summary card HTTP " + response.statusCode());
|
||||
}
|
||||
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
|
||||
if (!choices.isArray() || choices.isEmpty()) {
|
||||
throw new IllegalStateException("summary card 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();
|
||||
@@ -1848,6 +1953,42 @@ public class AihrSopSeedService {
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private static List<CardStep> jsonCardSteps(JsonNode node) {
|
||||
if (!node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CardStep> values = new ArrayList<>();
|
||||
for (JsonNode item : node) {
|
||||
String label = item.path("label").asText("").strip();
|
||||
String desc = item.path("desc").asText("").strip();
|
||||
if (!label.isEmpty() && !desc.isEmpty()) {
|
||||
values.add(new CardStep(truncate(label, 6), truncate(desc, 20)));
|
||||
}
|
||||
if (values.size() >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private static List<CardObjection> jsonCardObjections(JsonNode node) {
|
||||
if (!node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CardObjection> values = new ArrayList<>();
|
||||
for (JsonNode item : node) {
|
||||
String concern = item.path("concern").asText("").strip();
|
||||
String response = item.path("response").asText("").strip();
|
||||
if (!concern.isEmpty() && !response.isEmpty()) {
|
||||
values.add(new CardObjection(truncate(concern, 8), truncate(response, 20)));
|
||||
}
|
||||
if (values.size() >= 4) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) {
|
||||
}
|
||||
|
||||
|
||||
+609
-2
@@ -827,6 +827,13 @@
|
||||
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
|
||||
</div>
|
||||
<p v-if="sopResult.reference" class="digest-reference">{{ sopResult.reference }}</p>
|
||||
<div class="summary-card-entry">
|
||||
<button class="summary-card-trigger" type="button" :disabled="summaryCardLoading" @click="generateSummaryCard">
|
||||
<span>{{ summaryCardLoading ? '生成中…' : '✨ 生成总结卡' }}</span>
|
||||
<small>约10秒生成</small>
|
||||
</button>
|
||||
<em v-if="summaryCardMessage">{{ summaryCardMessage }}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sopResult.snippets?.length" class="snippet-list">
|
||||
@@ -850,6 +857,66 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="summaryCardOpen && summaryCard" class="summary-card-mask" aria-label="总结卡弹层" @click.self="closeSummaryCard">
|
||||
<article class="summary-card-sheet" role="dialog" aria-modal="true">
|
||||
<span class="summary-card-handle" aria-hidden="true"></span>
|
||||
<button class="summary-card-close" type="button" aria-label="关闭总结卡" @click="closeSummaryCard">×</button>
|
||||
<header class="summary-card-head">
|
||||
<h2>{{ summaryCard.title }}</h2>
|
||||
<span>由AI生成</span>
|
||||
</header>
|
||||
|
||||
<div class="summary-card-grid">
|
||||
<section class="summary-card-block is-steps">
|
||||
<h3>沟通四步法</h3>
|
||||
<ol>
|
||||
<li v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`">
|
||||
<em>{{ index + 1 }}</em>
|
||||
<span>
|
||||
<b>{{ step.label }}</b>
|
||||
<small>{{ step.desc }}</small>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="summary-card-block is-objections">
|
||||
<h3>高频异议应对</h3>
|
||||
<div class="objection-list">
|
||||
<p v-for="(item, index) in summaryCard.objections" :key="`${item.concern}-${index}`">
|
||||
<b>{{ item.concern }}</b>
|
||||
<span>{{ item.response }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-card-block is-scripts">
|
||||
<h3>开口话术(可直接使用)</h3>
|
||||
<blockquote v-for="(script, index) in summaryCard.scripts" :key="`${script}-${index}`">{{ script }}</blockquote>
|
||||
</section>
|
||||
|
||||
<section class="summary-card-block is-reminders">
|
||||
<h3>关键提醒</h3>
|
||||
<ul>
|
||||
<li v-for="(item, index) in summaryCard.reminders" :key="`${item}-${index}`">{{ item }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p v-if="summaryCardMessage" class="summary-card-inline-message">{{ summaryCardMessage }}</p>
|
||||
|
||||
<footer class="summary-card-footer">
|
||||
<div class="summary-card-footer-actions">
|
||||
<button class="listen" type="button" :disabled="cardSpeakLoading" @click="toggleSummaryCardSpeak">
|
||||
{{ cardSpeaking ? '停止播放' : cardSpeakLoading ? '合成中' : '🔊 听一遍' }}
|
||||
</button>
|
||||
<button class="practice" type="button" @click="startPracticeFromSummaryCard">🎤 开始跟读</button>
|
||||
</div>
|
||||
<p>总结卡由 AI 基于检索结果生成,请结合实际情况灵活使用</p>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="toastMessage" class="build-toast" role="status" aria-live="polite">{{ toastMessage }}</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1116,6 +1183,24 @@ interface KnowledgeSearchResponse {
|
||||
cautions?: string[];
|
||||
}
|
||||
|
||||
interface SummaryCardStep {
|
||||
label: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface SummaryCardObjection {
|
||||
concern: string;
|
||||
response: string;
|
||||
}
|
||||
|
||||
interface SummaryCardResponse {
|
||||
title: string;
|
||||
steps: SummaryCardStep[];
|
||||
objections: SummaryCardObjection[];
|
||||
scripts: string[];
|
||||
reminders: string[];
|
||||
}
|
||||
|
||||
interface CaseTag {
|
||||
text: string;
|
||||
type: string;
|
||||
@@ -1301,7 +1386,14 @@ const sopSnippetsOpen = ref(false);
|
||||
const expandedSnippets = ref(new Set<number>());
|
||||
const sopSpeaking = ref(false);
|
||||
const sopSpeakLoading = ref(false);
|
||||
const summaryCard = ref<SummaryCardResponse | null>(null);
|
||||
const summaryCardOpen = ref(false);
|
||||
const summaryCardLoading = ref(false);
|
||||
const summaryCardMessage = ref('');
|
||||
const cardSpeaking = ref(false);
|
||||
const cardSpeakLoading = ref(false);
|
||||
let sopAudio: HTMLAudioElement | null = null;
|
||||
let cardAudio: HTMLAudioElement | null = null;
|
||||
const materialFile = ref<File | null>(null);
|
||||
const materialUploading = ref(false);
|
||||
const materialMessage = ref('');
|
||||
@@ -1593,15 +1685,30 @@ const openSopPanel = () => {
|
||||
sopQuery.value = '';
|
||||
sopResult.value = null;
|
||||
sopMessage.value = '';
|
||||
summaryCard.value = null;
|
||||
summaryCardMessage.value = '';
|
||||
summaryCardOpen.value = false;
|
||||
navigatePath(`${rolePaths[roleKey.value]}/sop`);
|
||||
};
|
||||
|
||||
const stopSummaryCardSpeak = () => {
|
||||
cardAudio?.pause();
|
||||
cardAudio = null;
|
||||
cardSpeaking.value = false;
|
||||
};
|
||||
|
||||
const closeSummaryCard = () => {
|
||||
stopSummaryCardSpeak();
|
||||
summaryCardOpen.value = false;
|
||||
};
|
||||
|
||||
const closeSopPanel = () => {
|
||||
if (sopRecording.value) {
|
||||
sopAutoSearchAfterTranscribe = false;
|
||||
sopMediaRecorder?.stop();
|
||||
}
|
||||
stopSopSpeak();
|
||||
closeSummaryCard();
|
||||
sopPanelOpen.value = false;
|
||||
activeTabLabel.value = sopReturnTabLabel.value;
|
||||
navigatePath(sopReturnPath.value || rolePaths[roleKey.value]);
|
||||
@@ -1740,6 +1847,76 @@ const toggleSopSpeak = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const generateSummaryCard = async () => {
|
||||
const queryText = (sopQuery.value || sopResult.value?.queryText || '').trim();
|
||||
if (!sopResult.value || !queryText) return;
|
||||
summaryCardLoading.value = true;
|
||||
summaryCardMessage.value = '';
|
||||
closeSummaryCard();
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 35000);
|
||||
try {
|
||||
summaryCard.value = await readApi<SummaryCardResponse>(
|
||||
await fetch('/dev-api/api/knowledge/summary-card', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ queryText, category: 'sop' }),
|
||||
signal: controller.signal
|
||||
})
|
||||
);
|
||||
summaryCardOpen.value = true;
|
||||
} catch (error) {
|
||||
summaryCardMessage.value =
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? '总结卡生成超时,请稍后重试'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: '总结卡生成失败,请稍后重试';
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
summaryCardLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSummaryCardSpeak = async () => {
|
||||
if (cardSpeaking.value) {
|
||||
stopSummaryCardSpeak();
|
||||
return;
|
||||
}
|
||||
const card = summaryCard.value;
|
||||
if (!card) return;
|
||||
const speech = [
|
||||
card.title,
|
||||
...card.steps.map((step, index) => `${index + 1}、${step.label},${step.desc}`),
|
||||
...card.reminders.map((item) => `提醒,${item}`)
|
||||
].join('。').slice(0, 280);
|
||||
cardSpeakLoading.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: '' })
|
||||
})
|
||||
);
|
||||
if (!summaryCardOpen.value) return;
|
||||
cardAudio = new Audio(data.audioUrl);
|
||||
cardAudio.onended = () => stopSummaryCardSpeak();
|
||||
cardSpeaking.value = true;
|
||||
void cardAudio.play().catch(() => stopSummaryCardSpeak());
|
||||
} catch {
|
||||
summaryCardMessage.value = '语音播报暂不可用(需启用 tts 模型)';
|
||||
} finally {
|
||||
cardSpeakLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPracticeFromSummaryCard = () => {
|
||||
closeSummaryCard();
|
||||
closeSopPanel();
|
||||
openUserPracticePanel();
|
||||
};
|
||||
|
||||
const searchSopKnowledge = async () => {
|
||||
const cleaned = sopQuery.value.trim();
|
||||
if (!cleaned) {
|
||||
@@ -1752,7 +1929,11 @@ const searchSopKnowledge = async () => {
|
||||
sopResult.value = null;
|
||||
sopSnippetsOpen.value = false;
|
||||
expandedSnippets.value = new Set();
|
||||
summaryCard.value = null;
|
||||
summaryCardMessage.value = '';
|
||||
summaryCardOpen.value = false;
|
||||
stopSopSpeak();
|
||||
stopSummaryCardSpeak();
|
||||
const controller = new AbortController();
|
||||
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
|
||||
const timeout = window.setTimeout(() => controller.abort(), 25000);
|
||||
@@ -2801,6 +2982,7 @@ const applyRouteFromLocation = () => {
|
||||
reviewMessage.value = '';
|
||||
userReviewMessage.value = '';
|
||||
sopPanelOpen.value = false;
|
||||
closeSummaryCard();
|
||||
|
||||
if (route.role === 'user') {
|
||||
if (route.page === 'training') {
|
||||
@@ -3053,6 +3235,8 @@ watch(
|
||||
onBeforeUnmount(() => {
|
||||
setMentorScrollListening(false);
|
||||
cleanupMentorVoicePress();
|
||||
stopSopSpeak();
|
||||
stopSummaryCardSpeak();
|
||||
window.removeEventListener('popstate', applyRouteFromLocation);
|
||||
});
|
||||
const ringStyle = (value: number) => ({ '--value': value } as Record<string, string | number>);
|
||||
@@ -5185,7 +5369,7 @@ p {
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(circle at 10% 20%, rgba(15, 174, 166, 0.07) 0%, transparent 40%),
|
||||
radial-gradient(circle at 10% 20%, rgba(46, 107, 230, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 10%, rgba(56, 132, 255, 0.07) 0%, transparent 35%),
|
||||
linear-gradient(180deg, #f5f8fa 0%, #fcfdfe 100%);
|
||||
}
|
||||
@@ -5293,6 +5477,428 @@ p {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.sop-panel .sop-back {
|
||||
color: #1c56c6;
|
||||
background: rgba(46, 107, 230, 0.1);
|
||||
}
|
||||
|
||||
.sop-panel .sop-form input {
|
||||
border-color: rgba(46, 107, 230, 0.2);
|
||||
}
|
||||
|
||||
.sop-panel .sop-form input:focus {
|
||||
border-color: #2e6be6;
|
||||
box-shadow: 0 0 0 3px rgba(46, 107, 230, 0.14);
|
||||
}
|
||||
|
||||
.sop-panel .sop-voice,
|
||||
.sop-panel .digest-speak,
|
||||
.sop-panel .snippet-toggle,
|
||||
.sop-panel .snippet-list blockquote em,
|
||||
.sop-panel .panel-message {
|
||||
color: #1c56c6;
|
||||
}
|
||||
|
||||
.sop-panel .sop-voice {
|
||||
background: rgba(46, 107, 230, 0.1);
|
||||
border-color: rgba(46, 107, 230, 0.16);
|
||||
}
|
||||
|
||||
.sop-panel .sop-submit {
|
||||
background: linear-gradient(135deg, #2e6be6, #0f58d6);
|
||||
box-shadow: 0 4px 12px rgba(46, 107, 230, 0.18);
|
||||
}
|
||||
|
||||
.sop-panel .digest-card {
|
||||
border-color: rgba(46, 107, 230, 0.24);
|
||||
background: linear-gradient(160deg, rgba(46, 107, 230, 0.09), rgba(46, 107, 230, 0.02));
|
||||
}
|
||||
|
||||
.sop-panel .digest-speak {
|
||||
border-color: rgba(46, 107, 230, 0.36);
|
||||
}
|
||||
|
||||
.sop-panel .snippet-toggle {
|
||||
border-color: rgba(46, 107, 230, 0.35);
|
||||
}
|
||||
|
||||
.summary-card-entry {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.summary-card-trigger {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
border-radius: 22px;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #2e6be6, #0b55d8);
|
||||
box-shadow: 0 10px 24px rgba(46, 107, 230, 0.26);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 2px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-trigger span {
|
||||
font-size: 16px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.summary-card-trigger small {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.summary-card-trigger:disabled {
|
||||
opacity: 0.62;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.summary-card-entry em {
|
||||
color: #c25712;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.summary-card-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 180;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.summary-card-sheet {
|
||||
position: relative;
|
||||
width: min(100vw, 860px);
|
||||
height: 88vh;
|
||||
max-height: 88vh;
|
||||
padding: 22px 16px 0;
|
||||
border-radius: 24px 24px 0 0;
|
||||
background: #f8fbff;
|
||||
box-shadow: 0 -18px 44px rgba(13, 31, 68, 0.24);
|
||||
overflow-y: auto;
|
||||
animation: summary-sheet-in 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes summary-sheet-in {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-card-handle {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 50%;
|
||||
width: 66px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(25, 42, 70, 0.18);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.summary-card-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 16px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(21, 39, 70, 0.16);
|
||||
color: #182842;
|
||||
background: #ffffff;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.summary-card-head {
|
||||
min-height: 46px;
|
||||
padding: 12px 48px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-card-head h2 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #101828;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.summary-card-head span {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(46, 107, 230, 0.35);
|
||||
border-radius: 999px;
|
||||
color: #1f62dc;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary-card-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 400px) {
|
||||
.summary-card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.summary-card-block {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(213, 225, 233, 0.8);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6px 18px rgba(17, 33, 63, 0.06);
|
||||
}
|
||||
|
||||
.summary-card-block h3 {
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #101828;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-block h3::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--card-accent);
|
||||
}
|
||||
|
||||
.summary-card-block.is-steps {
|
||||
--card-accent: #2e6be6;
|
||||
}
|
||||
|
||||
.summary-card-block.is-objections {
|
||||
--card-accent: #ff7a1a;
|
||||
}
|
||||
|
||||
.summary-card-block.is-scripts {
|
||||
--card-accent: #18b66a;
|
||||
}
|
||||
|
||||
.summary-card-block.is-reminders {
|
||||
--card-accent: #f6b91a;
|
||||
}
|
||||
|
||||
.summary-card-block ol,
|
||||
.summary-card-block ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.summary-card-block.is-steps ol {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.summary-card-block.is-steps li {
|
||||
min-height: 54px;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(46, 107, 230, 0.11), rgba(46, 107, 230, 0.04));
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.summary-card-block.is-steps em {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: #2e6be6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-block b {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
color: #101828;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-block small,
|
||||
.summary-card-block span {
|
||||
min-width: 0;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.objection-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.objection-list p {
|
||||
margin: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, rgba(255, 122, 26, 0.14), rgba(255, 122, 26, 0.05));
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 0.55fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-card-block.is-scripts blockquote {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid rgba(24, 182, 106, 0.18);
|
||||
border-radius: 10px;
|
||||
color: #183b2b;
|
||||
background: rgba(24, 182, 106, 0.08);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.summary-card-block.is-scripts blockquote + blockquote {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.summary-card-block.is-scripts blockquote::before,
|
||||
.summary-card-block.is-scripts blockquote::after {
|
||||
position: absolute;
|
||||
color: rgba(24, 182, 106, 0.32);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-block.is-scripts blockquote::before {
|
||||
content: "“";
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.summary-card-block.is-scripts blockquote::after {
|
||||
content: "”";
|
||||
right: 8px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.summary-card-block.is-reminders ul {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.summary-card-block.is-reminders li {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
color: #182842;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.summary-card-block.is-reminders li::before {
|
||||
content: "✓";
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
background: #f6b91a;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-inline-message {
|
||||
margin: 10px 0 0;
|
||||
color: #c25712;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.summary-card-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin: 14px -16px 0;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid rgba(213, 225, 233, 0.72);
|
||||
background: rgba(248, 251, 255, 0.96);
|
||||
}
|
||||
|
||||
.summary-card-footer-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-card-footer button {
|
||||
min-height: 52px;
|
||||
border-radius: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-card-footer button.listen {
|
||||
background: linear-gradient(135deg, #2e6be6, #0f58d6);
|
||||
box-shadow: 0 8px 18px rgba(46, 107, 230, 0.2);
|
||||
}
|
||||
|
||||
.summary-card-footer button.practice {
|
||||
background: linear-gradient(135deg, #20bf72, #0a9f59);
|
||||
box-shadow: 0 8px 18px rgba(32, 191, 114, 0.2);
|
||||
}
|
||||
|
||||
.summary-card-footer button:disabled {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.summary-card-footer p {
|
||||
margin: 8px 0 0;
|
||||
color: #667085;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -5980,7 +6586,8 @@ p {
|
||||
.mentor-avatar img,
|
||||
.mentor-eyelid,
|
||||
.mentor-thinking i,
|
||||
.mentor-waves i {
|
||||
.mentor-waves i,
|
||||
.summary-card-sheet {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user