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:
2026-07-05 03:21:37 +08:00
parent 9af439dc2a
commit 617847f7e0
4 changed files with 773 additions and 6 deletions
+609 -2
View File
@@ -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;
}