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.ProcessingOverviewResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
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.UploadEnqueueResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
|
import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||||
@@ -61,6 +63,11 @@ public class AihrSopController {
|
|||||||
return R.ok(sopSeedService.search(request));
|
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")
|
@PostMapping("/doc/upload")
|
||||||
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
|
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
|
||||||
return R.ok(sopSeedService.uploadDoc(file, 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 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) {
|
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 com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse;
|
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.DocResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportItemResponse;
|
import org.dromara.aihr.domain.AihrSopDto.LocalImportItemResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
|
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.SearchRequest;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
|
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.UploadResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
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) {
|
public UploadResponse uploadDoc(MultipartFile file, String category) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new ServiceException("上传文件不能为空");
|
throw new ServiceException("上传文件不能为空");
|
||||||
@@ -1567,10 +1620,7 @@ public class AihrSopSeedService {
|
|||||||
|
|
||||||
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
|
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
|
||||||
try {
|
try {
|
||||||
int limitValue = limitValue(limit);
|
List<KnowledgeHit> hits = dbHits(category, queryText, 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));
|
|
||||||
if (hits.isEmpty()) {
|
if (hits.isEmpty()) {
|
||||||
return null;
|
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) {
|
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
|
||||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||||
try {
|
try {
|
||||||
@@ -1831,6 +1888,54 @@ public class AihrSopSeedService {
|
|||||||
return choices.get(0).path("message").path("content").asText();
|
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) {
|
private static List<String> jsonStrings(JsonNode node, int maxItems, int maxChars) {
|
||||||
if (!node.isArray()) {
|
if (!node.isArray()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -1848,6 +1953,42 @@ public class AihrSopSeedService {
|
|||||||
return List.copyOf(values);
|
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) {
|
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>
|
<p v-for="item in sopResult.cautions" :key="item">⚠ {{ item }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="sopResult.reference" class="digest-reference">{{ sopResult.reference }}</p>
|
<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>
|
||||||
|
|
||||||
<div v-if="sopResult.snippets?.length" class="snippet-list">
|
<div v-if="sopResult.snippets?.length" class="snippet-list">
|
||||||
@@ -850,6 +857,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<div v-if="toastMessage" class="build-toast" role="status" aria-live="polite">{{ toastMessage }}</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -1116,6 +1183,24 @@ interface KnowledgeSearchResponse {
|
|||||||
cautions?: string[];
|
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 {
|
interface CaseTag {
|
||||||
text: string;
|
text: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -1301,7 +1386,14 @@ const sopSnippetsOpen = ref(false);
|
|||||||
const expandedSnippets = ref(new Set<number>());
|
const expandedSnippets = ref(new Set<number>());
|
||||||
const sopSpeaking = ref(false);
|
const sopSpeaking = ref(false);
|
||||||
const sopSpeakLoading = 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 sopAudio: HTMLAudioElement | null = null;
|
||||||
|
let cardAudio: 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('');
|
||||||
@@ -1593,15 +1685,30 @@ const openSopPanel = () => {
|
|||||||
sopQuery.value = '';
|
sopQuery.value = '';
|
||||||
sopResult.value = null;
|
sopResult.value = null;
|
||||||
sopMessage.value = '';
|
sopMessage.value = '';
|
||||||
|
summaryCard.value = null;
|
||||||
|
summaryCardMessage.value = '';
|
||||||
|
summaryCardOpen.value = false;
|
||||||
navigatePath(`${rolePaths[roleKey.value]}/sop`);
|
navigatePath(`${rolePaths[roleKey.value]}/sop`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopSummaryCardSpeak = () => {
|
||||||
|
cardAudio?.pause();
|
||||||
|
cardAudio = null;
|
||||||
|
cardSpeaking.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSummaryCard = () => {
|
||||||
|
stopSummaryCardSpeak();
|
||||||
|
summaryCardOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const closeSopPanel = () => {
|
const closeSopPanel = () => {
|
||||||
if (sopRecording.value) {
|
if (sopRecording.value) {
|
||||||
sopAutoSearchAfterTranscribe = false;
|
sopAutoSearchAfterTranscribe = false;
|
||||||
sopMediaRecorder?.stop();
|
sopMediaRecorder?.stop();
|
||||||
}
|
}
|
||||||
stopSopSpeak();
|
stopSopSpeak();
|
||||||
|
closeSummaryCard();
|
||||||
sopPanelOpen.value = false;
|
sopPanelOpen.value = false;
|
||||||
activeTabLabel.value = sopReturnTabLabel.value;
|
activeTabLabel.value = sopReturnTabLabel.value;
|
||||||
navigatePath(sopReturnPath.value || rolePaths[roleKey.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 searchSopKnowledge = async () => {
|
||||||
const cleaned = sopQuery.value.trim();
|
const cleaned = sopQuery.value.trim();
|
||||||
if (!cleaned) {
|
if (!cleaned) {
|
||||||
@@ -1752,7 +1929,11 @@ const searchSopKnowledge = async () => {
|
|||||||
sopResult.value = null;
|
sopResult.value = null;
|
||||||
sopSnippetsOpen.value = false;
|
sopSnippetsOpen.value = false;
|
||||||
expandedSnippets.value = new Set();
|
expandedSnippets.value = new Set();
|
||||||
|
summaryCard.value = null;
|
||||||
|
summaryCardMessage.value = '';
|
||||||
|
summaryCardOpen.value = false;
|
||||||
stopSopSpeak();
|
stopSopSpeak();
|
||||||
|
stopSummaryCardSpeak();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
|
// 概要由 chat 模型生成,比纯检索多几秒,超时放宽到 25s
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 25000);
|
const timeout = window.setTimeout(() => controller.abort(), 25000);
|
||||||
@@ -2801,6 +2982,7 @@ const applyRouteFromLocation = () => {
|
|||||||
reviewMessage.value = '';
|
reviewMessage.value = '';
|
||||||
userReviewMessage.value = '';
|
userReviewMessage.value = '';
|
||||||
sopPanelOpen.value = false;
|
sopPanelOpen.value = false;
|
||||||
|
closeSummaryCard();
|
||||||
|
|
||||||
if (route.role === 'user') {
|
if (route.role === 'user') {
|
||||||
if (route.page === 'training') {
|
if (route.page === 'training') {
|
||||||
@@ -3053,6 +3235,8 @@ watch(
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
setMentorScrollListening(false);
|
setMentorScrollListening(false);
|
||||||
cleanupMentorVoicePress();
|
cleanupMentorVoicePress();
|
||||||
|
stopSopSpeak();
|
||||||
|
stopSummaryCardSpeak();
|
||||||
window.removeEventListener('popstate', applyRouteFromLocation);
|
window.removeEventListener('popstate', applyRouteFromLocation);
|
||||||
});
|
});
|
||||||
const ringStyle = (value: number) => ({ '--value': value } as Record<string, string | number>);
|
const ringStyle = (value: number) => ({ '--value': value } as Record<string, string | number>);
|
||||||
@@ -5185,7 +5369,7 @@ p {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background:
|
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%),
|
radial-gradient(circle at 90% 10%, rgba(56, 132, 255, 0.07) 0%, transparent 35%),
|
||||||
linear-gradient(180deg, #f5f8fa 0%, #fcfdfe 100%);
|
linear-gradient(180deg, #f5f8fa 0%, #fcfdfe 100%);
|
||||||
}
|
}
|
||||||
@@ -5293,6 +5477,428 @@ p {
|
|||||||
box-shadow: var(--shadow-md);
|
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 {
|
.tool-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -5980,7 +6586,8 @@ p {
|
|||||||
.mentor-avatar img,
|
.mentor-avatar img,
|
||||||
.mentor-eyelid,
|
.mentor-eyelid,
|
||||||
.mentor-thinking i,
|
.mentor-thinking i,
|
||||||
.mentor-waves i {
|
.mentor-waves i,
|
||||||
|
.summary-card-sheet {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user