Compare commits

...

3 Commits
main ... dev

Author SHA1 Message Date
28cf53dcf6 渲染助手Markdown表格并优化撤回交互 2026-10-28 17:35:35 +08:00
591cce3a98 完整展示无卡片助手回复 2026-10-27 15:28:31 +08:00
56d7ac0b20 新增使用说明文档入口 2026-09-22 15:13:31 +08:00
8 changed files with 781 additions and 70 deletions

139
package-lock.json generated
View File

@ -11,12 +11,15 @@
"@lucide/vue": "^1.34.0", "@lucide/vue": "^1.34.0",
"axios": "^1.19.0", "axios": "^1.19.0",
"dingtalk-jsapi": "^3.2.9", "dingtalk-jsapi": "^3.2.9",
"dompurify": "^3.4.17",
"markdown-it": "^15.0.3",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/markdown-it": "^14.2.0",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
@ -958,6 +961,31 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.4.0", "version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
@ -968,6 +996,13 @@
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.68.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
@ -1669,6 +1704,22 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/argparse": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz",
"integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "PSF-2.0"
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -2019,6 +2070,15 @@
"promise-polyfill": "^7.1.0" "promise-polyfill": "^7.1.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -3258,6 +3318,25 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkify-it": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^3.0.0"
}
},
"node_modules/local-pkg": { "node_modules/local-pkg": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
@ -3325,6 +3404,45 @@
"url": "https://github.com/sponsors/sxzz" "url": "https://github.com/sponsors/sxzz"
} }
}, },
"node_modules/markdown-it": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz",
"integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^3.0.3",
"entities": "^8.1.0",
"linkify-it": "^6.1.0",
"mdurl": "^2.1.0",
"punycode.js": "^2.3.1",
"uc.micro": "^3.0.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -3341,6 +3459,12 @@
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/mdurl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"license": "MIT"
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@ -3803,6 +3927,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@ -4238,6 +4371,12 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/uc.micro": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"license": "MIT"
},
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.4", "version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",

View File

@ -11,15 +11,18 @@
"format": "prettier --write \"src/**/*.{ts,vue,css}\"" "format": "prettier --write \"src/**/*.{ts,vue,css}\""
}, },
"dependencies": { "dependencies": {
"dingtalk-jsapi": "^3.2.9",
"axios": "^1.19.0",
"@lucide/vue": "^1.34.0", "@lucide/vue": "^1.34.0",
"axios": "^1.19.0",
"dingtalk-jsapi": "^3.2.9",
"dompurify": "^3.4.17",
"markdown-it": "^15.0.3",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"vue": "^3.5.41", "vue": "^3.5.41",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/markdown-it": "^14.2.0",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ExternalLink,
FileQuestion,
PenLine, PenLine,
Search, Search,
} from "@lucide/vue"; } from "@lucide/vue";
@ -139,6 +141,18 @@ function searchConversations() {
</div> </div>
</section> </section>
<a
class="help-document"
href="https://alidocs.dingtalk.com/i/nodes/dpYLaezmVNZ9EBwXuKGavoNvVrMqPxX6?utm_scene=person_space"
target="_blank"
rel="noopener noreferrer"
title="打开使用说明文档"
>
<FileQuestion :size="15" />
<span>使用说明文档</span>
<ExternalLink :size="12" class="help-document__external" />
</a>
<footer> <footer>
<div class="knowledge-progress"><i></i></div> <div class="knowledge-progress"><i></i></div>
<p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p> <p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
@ -332,8 +346,35 @@ section h2 {
font-weight: 700; font-weight: 700;
} }
footer { .help-document {
display: flex;
min-height: 38px;
align-items: center;
gap: 8px;
border-top: 1px solid var(--color-border-light);
margin-top: auto; margin-top: auto;
padding: 0 16px;
color: var(--color-text-secondary);
font-size: 12px;
text-decoration: none;
}
.help-document:hover {
background: var(--color-bg-warm);
color: var(--color-text);
}
.help-document span {
min-width: 0;
flex: 1;
}
.help-document__external {
color: var(--color-text-faint);
}
footer {
margin-top: 0;
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
padding: 12px 16px; padding: 12px 16px;
} }
@ -380,6 +421,8 @@ footer p {
.conversation-search, .conversation-search,
.sidebar-item__label, .sidebar-item__label,
.new-label, .new-label,
.help-document span,
.help-document__external,
footer { footer {
display: none; display: none;
} }
@ -389,6 +432,13 @@ footer p {
padding: 0; padding: 0;
} }
.help-document {
min-height: 42px;
justify-content: center;
margin-top: auto;
padding: 0;
}
.risk-dot { .risk-dot {
width: 7px; width: 7px;
height: 7px; height: 7px;

View File

@ -13,6 +13,7 @@ import { directConversationQuery } from "@/api/conversations";
import { useConversationStore } from "@/stores/conversation.store"; import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { renderSafeMarkdown } from "@/utils/markdown";
import type { ReportCard, RiskAssessmentResult } from "@/types/risk"; import type { ReportCard, RiskAssessmentResult } from "@/types/risk";
import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation"; import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation";
import { import {
@ -28,6 +29,7 @@ const {
current: conversation, current: conversation,
sending: loading, sending: loading,
generating, generating,
withdrawing,
error, error,
} = storeToRefs(conversationStore); } = storeToRefs(conversationStore);
const withdrawingId = ref<number | null>(null); const withdrawingId = ref<number | null>(null);
@ -230,7 +232,10 @@ function closeTool() {
directResult.value = null; directResult.value = null;
} }
async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) { async function submitDirectTool(
kind: "supplier" | "material" | "cases" | "solution",
query: string,
) {
directLoading.value = true; directLoading.value = true;
toolError.value = ""; toolError.value = "";
try { try {
@ -238,7 +243,8 @@ async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solut
directResult.value = response.data.data ?? null; directResult.value = response.data.data ?? null;
if (response.data.data) { if (response.data.data) {
directUserPrompt.value = query; directUserPrompt.value = query;
directResponseCard.value = response.data.data as AssistantResponseCardData; directResponseCard.value = response.data
.data as AssistantResponseCardData;
closeTool(); closeTool();
await nextTick(); await nextTick();
await scrollToBottom(); await scrollToBottom();
@ -246,7 +252,9 @@ async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solut
} catch (reason) { } catch (reason) {
toolError.value = axios.isAxiosError(reason) toolError.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "查询失败") ? String(reason.response?.data?.message || "查询失败")
: reason instanceof Error ? reason.message : "查询失败"; : reason instanceof Error
? reason.message
: "查询失败";
} finally { } finally {
directLoading.value = false; directLoading.value = false;
} }
@ -298,12 +306,19 @@ async function submitTool() {
toolError.value = "请先填写必填内容"; toolError.value = "请先填写必填内容";
return; return;
} }
if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) { if (
await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue); ["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")
) {
await submitDirectTool(
selectedTool as "supplier" | "material" | "cases" | "solution",
finalValue,
);
return; return;
} }
closeTool(); closeTool();
void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`); void usePrompt(
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`,
);
} }
function selectFile(event: Event) { function selectFile(event: Event) {
@ -340,10 +355,6 @@ function handleScroll() {
if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder(); if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
} }
function hasAssistantPair(index: number) {
return conversation.value?.messages[index + 1]?.role === "assistant";
}
function stopGeneration() { function stopGeneration() {
conversationStore.stopGeneration(); conversationStore.stopGeneration();
} }
@ -354,6 +365,8 @@ async function withdrawMessage(messageId: number) {
withdrawingId.value = messageId; withdrawingId.value = messageId;
try { try {
await conversationStore.withdraw(messageId); await conversationStore.withdraw(messageId);
} catch {
// The store restores the message and exposes the failure in the page.
} finally { } finally {
withdrawingId.value = null; withdrawingId.value = null;
} }
@ -422,9 +435,7 @@ function refreshPrimaryReportState() {
const conversationId = conversation.value?.id; const conversationId = conversation.value?.id;
primaryReportReady.value = Boolean( primaryReportReady.value = Boolean(
conversationId && conversationId &&
isPrimaryRiskReport( isPrimaryRiskReport(readConversationReport(conversationId, auth.user?.id)),
readConversationReport(conversationId, auth.user?.id),
),
); );
} }
@ -441,10 +452,12 @@ function openReport(result: RiskAssessmentResult) {
stored?.subjectId === result.subjectId stored?.subjectId === result.subjectId
? { ? {
...cloneReport(result), ...cloneReport(result),
reportCards: storedCards.length ? storedCards : result.reportCards ?? [], reportCards: storedCards.length
? storedCards
: (result.reportCards ?? []),
aiSolutionCards: stored.aiSolutionCards?.length aiSolutionCards: stored.aiSolutionCards?.length
? stored.aiSolutionCards ? stored.aiSolutionCards
: result.aiSolutionCards ?? [], : (result.aiSolutionCards ?? []),
} }
: cloneReport(result); : cloneReport(result);
writeConversationReport(conversationId, merged, auth.user?.id); writeConversationReport(conversationId, merged, auth.user?.id);
@ -485,7 +498,7 @@ async function submit(
messageType: "text" | "image" | "file" | "audio" = "text", messageType: "text" | "image" | "file" | "audio" = "text",
) { ) {
const value = prompt.value.trim(); const value = prompt.value.trim();
if (!value || loading.value) return; if (!value || loading.value || withdrawing.value) return;
prompt.value = ""; prompt.value = "";
const sentConversation = await conversationStore.streamSend( const sentConversation = await conversationStore.streamSend(
value, value,
@ -551,18 +564,14 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<time>{{ formatMessageTime(message.createdAt) }}</time> <time>{{ formatMessageTime(message.createdAt) }}</time>
<div class="user-message-actions"> <div class="user-message-actions">
<button <button
v-if=" v-if="message.id > 0 && !generating"
message.id > 0 &&
!generating &&
hasAssistantPair(
conversation?.messages.indexOf(message) ?? -1,
)
"
type="button" type="button"
:disabled="withdrawingId !== null || loading" :disabled="
withdrawing || conversationStore.loadingOlder || loading
"
@click="withdrawMessage(message.id)" @click="withdrawMessage(message.id)"
> >
撤回 {{ withdrawingId === message.id ? "撤回中…" : "撤回" }}
</button> </button>
</div> </div>
</div> </div>
@ -584,12 +593,19 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
> >
</div> </div>
<div <div
v-if="message.content || isMessageThinking(message.id, messageIndex)" v-if="
message.content || isMessageThinking(message.id, messageIndex)
"
class="assistant-thinking" class="assistant-thinking"
> >
<button <button
type="button" type="button"
class="assistant-thinking__header" class="assistant-thinking__header"
:disabled="!message.riskResult && !message.responseCard"
:aria-expanded="
(!message.riskResult && !message.responseCard) ||
expandedReplies.has(message.id)
"
@click="toggleReply(message.id)" @click="toggleReply(message.id)"
> >
<span <span
@ -598,11 +614,15 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
>思考中</span >思考中</span
> >
<span v-else> <span v-else>
已完成<span v-if="thinkingDuration(messageIndex)" class="assistant-thinking__duration"> 已完成<span
v-if="thinkingDuration(messageIndex)"
class="assistant-thinking__duration"
>
{{ thinkingDuration(messageIndex) }}s</span {{ thinkingDuration(messageIndex) }}s</span
> >
</span> </span>
<ChevronDown <ChevronDown
v-if="message.riskResult || message.responseCard"
:size="13" :size="13"
class="assistant-thinking__arrow" class="assistant-thinking__arrow"
:class="{ expanded: expandedReplies.has(message.id) }" :class="{ expanded: expandedReplies.has(message.id) }"
@ -613,10 +633,16 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
class="assistant-bubble" class="assistant-bubble"
:class="{ :class="{
'assistant-bubble--collapsed': 'assistant-bubble--collapsed':
(message.riskResult || message.responseCard) &&
!expandedReplies.has(message.id), !expandedReplies.has(message.id),
}" }"
> >
{{ message.content }} <!-- eslint-disable vue/no-v-html -->
<div
class="assistant-markdown"
v-html="renderSafeMarkdown(message.content)"
/>
<!-- eslint-enable vue/no-v-html -->
</div> </div>
</div> </div>
<RiskAnalysisCard <RiskAnalysisCard
@ -641,7 +667,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<template v-if="directResponseCard"> <template v-if="directResponseCard">
<div class="message message--user"> <div class="message message--user">
<div class="message-avatar">{{ auth.user?.name?.slice(0, 1) ?? "我" }}</div> <div class="message-avatar">
{{ auth.user?.name?.slice(0, 1) ?? "我" }}
</div>
<div class="user-bubble">{{ directUserPrompt }}</div> <div class="user-bubble">{{ directUserPrompt }}</div>
</div> </div>
<div class="message message--assistant"> <div class="message message--assistant">
@ -673,13 +701,21 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<p v-if="error" class="state-message state-message--error"> <p v-if="error" class="state-message state-message--error">
{{ error }} {{ error }}
</p> </p>
<p
v-if="withdrawing"
class="state-message"
role="status"
aria-live="polite"
>
正在撤回消息…
</p>
</div> </div>
</div> </div>
<AssistantComposer <AssistantComposer
ref="composer" ref="composer"
v-model="prompt" v-model="prompt"
:loading="loading" :loading="loading || withdrawing"
:can-upload="auth.hasPermission('risk_item.submit')" :can-upload="auth.hasPermission('risk_item.submit')"
:can-review="auth.hasPermission('risk_item.review')" :can-review="auth.hasPermission('risk_item.review')"
@submit="submit" @submit="submit"
@ -750,39 +786,114 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<p v-if="toolError" class="dialog-error">{{ toolError }}</p> <p v-if="toolError" class="dialog-error">{{ toolError }}</p>
<section v-if="directResult" class="direct-result-card"> <section v-if="directResult" class="direct-result-card">
<template v-if="tool === 'supplier'"> <template v-if="tool === 'supplier'">
<span class="direct-tier">{{ directResult.tier || "新供应商" }}</span> <span class="direct-tier">{{
directResult.tier || "新供应商"
}}</span>
<div class="direct-lines"> <div class="direct-lines">
<p>近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b>(代理合作频次口径)</p> <p>
<p>基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α <b>×{{ Number(directResult.frequencyCoefficient ?? 1).toFixed(2) }}</b></p> 近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b
<p>供应商得分 S供 = <b>{{ directResult.supplierScore ?? 70 }}</b> 分</p> >(代理合作频次口径)
</div> </p>
<p v-if="Array.isArray(directResult.topProblems) && directResult.topProblems.length"> <p>
高频异常:{{ (directResult.topProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }} 基础分 <b>{{ directResult.supplierBaseScore ?? 70 }}</b> · α
<b
>×{{
Number(directResult.frequencyCoefficient ?? 1).toFixed(2)
}}</b
>
</p>
<p>
供应商得分 S供 =
<b>{{ directResult.supplierScore ?? 70 }}</b> 分
</p>
</div>
<p
v-if="
Array.isArray(directResult.topProblems) &&
directResult.topProblems.length
"
>
高频异常:{{
(
directResult.topProblems as Array<{
name: string;
count: number;
}>
)
.map((item) => `${item.name}(${item.count} 条)`)
.join("、")
}}
</p>
<p v-if="Array.isArray(directResult.suggestions)">
建议:{{ (directResult.suggestions as string[]).join(" + ") }}
</p> </p>
<p v-if="Array.isArray(directResult.suggestions)">建议:{{ (directResult.suggestions as string[]).join(" + ") }}</p>
</template> </template>
<template v-else-if="tool === 'material'"> <template v-else-if="tool === 'material'">
<strong>{{ directResult.title || "物料风险分析" }}</strong> <strong>{{ directResult.title || "物料风险分析" }}</strong>
<p>物料:<b>{{ directResult.material || directResult.query }}</b> · 历史异常 {{ directResult.anomalyCount ?? 0 }} 条</p> <p>
<p v-if="Array.isArray(directResult.frequentProblems) && directResult.frequentProblems.length"> 物料:<b>{{ directResult.material || directResult.query }}</b> ·
高频问题:{{ (directResult.frequentProblems as Array<{name: string; count: number}>).map((item) => `${item.name}(${item.count} 条)`).join("、") }} 历史异常 {{ directResult.anomalyCount ?? 0 }} 条
</p>
<p
v-if="
Array.isArray(directResult.frequentProblems) &&
directResult.frequentProblems.length
"
>
高频问题:{{
(
directResult.frequentProblems as Array<{
name: string;
count: number;
}>
)
.map((item) => `${item.name}(${item.count} 条)`)
.join("、")
}}
</p>
<p v-if="Array.isArray(directResult.suggestions)">
建议:{{ (directResult.suggestions as string[]).join(";") }}
</p> </p>
<p v-if="Array.isArray(directResult.suggestions)">建议:{{ (directResult.suggestions as string[]).join(";") }}</p>
</template> </template>
<template v-else-if="tool === 'cases'"> <template v-else-if="tool === 'cases'">
<strong>相似案例检索</strong> <strong>相似案例检索</strong>
<p>关键词:{{ directResult.query }} · 匹配 {{ directResult.matchedCount ?? 0 }} 条</p> <p>
<ul v-if="Array.isArray(directResult.cases)" class="direct-case-list"> 关键词:{{ directResult.query }} · 匹配
<li v-for="(item, index) in (directResult.cases as Array<Record<string, unknown>>).slice(0, 5)" :key="index"> {{ directResult.matchedCount ?? 0 }} 条
<b>{{ item.product || "未命名产品" }}</b>:{{ item.problem || item.description || "异常描述未填写" }} </p>
<ul
v-if="Array.isArray(directResult.cases)"
class="direct-case-list"
>
<li
v-for="(item, index) in (
directResult.cases as Array<Record<string, unknown>>
).slice(0, 5)"
:key="index"
>
<b>{{ item.product || "未命名产品" }}</b
>:{{ item.problem || item.description || "异常描述未填写" }}
</li> </li>
</ul> </ul>
</template> </template>
<template v-else> <template v-else>
<strong>{{ directResult.title || "AI 方案生成" }}</strong> <strong>{{ directResult.title || "AI 方案生成" }}</strong>
<p>{{ directResult.analysisBase || "已结合历史案例与标准库生成分析方案。" }}</p> <p>
<ol v-if="Array.isArray(directResult.suggestions)" class="direct-suggestion-list"> {{
<li v-for="item in directResult.suggestions as string[]" :key="item">{{ item }}</li> directResult.analysisBase ||
"已结合历史案例与标准库生成分析方案。"
}}
</p>
<ol
v-if="Array.isArray(directResult.suggestions)"
class="direct-suggestion-list"
>
<li
v-for="item in directResult.suggestions as string[]"
:key="item"
>
{{ item }}
</li>
</ol> </ol>
</template> </template>
</section> </section>
@ -790,7 +901,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<button type="button" class="dialog-secondary" @click="closeTool"> <button type="button" class="dialog-secondary" @click="closeTool">
取消 取消
</button> </button>
<button type="submit" class="dialog-primary" :disabled="directLoading"> <button
type="submit"
class="dialog-primary"
:disabled="directLoading"
>
{{ toolSubmitLabel }} {{ toolSubmitLabel }}
</button> </button>
</div> </div>
@ -934,6 +1049,91 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 13px; font-size: 13px;
line-height: 1.65; line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.assistant-markdown {
overflow-wrap: anywhere;
white-space: normal;
}
.assistant-markdown :deep(p) {
margin: 0 0 0.75em;
}
.assistant-markdown :deep(p:last-child),
.assistant-markdown :deep(ul:last-child),
.assistant-markdown :deep(ol:last-child),
.assistant-markdown :deep(table:last-child),
.assistant-markdown :deep(blockquote:last-child) {
margin-bottom: 0;
}
.assistant-markdown :deep(ul),
.assistant-markdown :deep(ol) {
margin: 0 0 0.75em;
padding-left: 1.5em;
}
.assistant-markdown :deep(h1),
.assistant-markdown :deep(h2),
.assistant-markdown :deep(h3),
.assistant-markdown :deep(h4) {
margin: 0.9em 0 0.45em;
color: var(--color-text);
font-size: 1em;
line-height: 1.45;
}
.assistant-markdown :deep(table) {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.95em;
}
.assistant-markdown :deep(th),
.assistant-markdown :deep(td) {
border: 1px solid var(--color-border);
padding: 6px 9px;
text-align: left;
vertical-align: top;
}
.assistant-markdown :deep(th) {
background: var(--color-bg-warm);
color: var(--color-text);
font-weight: 650;
}
.assistant-markdown :deep(blockquote) {
border-left: 3px solid var(--color-border-strong);
margin: 0.75em 0;
padding-left: 0.9em;
color: var(--color-text-muted);
}
.assistant-markdown :deep(pre) {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 10px;
background: var(--color-bg-warm);
}
.assistant-markdown :deep(code) {
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-size: 0.92em;
}
.assistant-markdown :deep(a) {
color: var(--color-brand);
} }
.assistant-bubble--collapsed { .assistant-bubble--collapsed {
@ -944,6 +1144,13 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.assistant-bubble--collapsed .assistant-markdown {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.assistant-thinking { .assistant-thinking {
margin-bottom: 8px; margin-bottom: 8px;
} }
@ -962,10 +1169,16 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
font-size: 11px; font-size: 11px;
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition:
background 0.15s,
color 0.15s;
} }
.assistant-thinking__header:hover { .assistant-thinking__header:disabled {
cursor: default;
}
.assistant-thinking__header:not(:disabled):hover {
background: var(--color-bg-warm); background: var(--color-bg-warm);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }

View File

@ -0,0 +1,177 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AxiosResponse } from "axios";
import { fetchConversation, withdrawConversationMessage } from "@/api/conversations";
import { useConversationStore } from "@/stores/conversation.store";
import type { ApiResponse } from "@/types/api";
import type { Conversation, ConversationMessage } from "@/types/conversation";
vi.mock("@/api/conversations", () => ({
createConversation: vi.fn(),
deleteConversation: vi.fn(),
fetchConversation: vi.fn(),
fetchConversations: vi.fn(),
withdrawConversationMessage: vi.fn(),
streamConversationMessage: vi.fn(),
sendConversationMessage: vi.fn(),
}));
function conversation(id = 1, count = 26): Conversation {
return {
id,
title: `会话${id}`,
messageCount: count,
messagesTotal: count,
messagesOffset: 0,
hasMoreMessages: false,
lastRiskLevel: null,
updatedAt: "2026-10-27T10:00:00Z",
messages: Array.from({ length: count }, (_, index): ConversationMessage => ({
id: index + 1,
role: index % 2 === 0 ? "user" : "assistant",
messageType: "text",
content: `消息${index + 1}`,
createdAt: `2026-10-27T10:00:${String(index).padStart(2, "0")}Z`,
riskResult: null,
responseCard: null,
semantic: null,
})),
};
}
function response(data: Conversation) {
return { data: { code: 200, message: "success", data } } as AxiosResponse<ApiResponse<Conversation>>;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function afterWithdrawal(original: Conversation, ids = [5, 6]) {
const messages = original.messages.filter((message) => !ids.includes(message.id));
return {
...original,
messageCount: messages.length,
messagesTotal: messages.length,
messages: messages.slice(-20),
hasMoreMessages: messages.length > 20,
};
}
beforeEach(() => {
setActivePinia(createPinia());
vi.resetAllMocks();
});
describe("conversation withdrawal", () => {
it("removes the turn immediately, blocks conflicting sends, and preserves older pages on success", async () => {
const store = useConversationStore();
const original = conversation();
store.current = original;
const pending = deferred<ReturnType<typeof response>>();
vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise);
const operation = store.withdraw(5);
expect(store.current?.messages.some((message) => [5, 6].includes(message.id))).toBe(false);
expect(store.current?.messageCount).toBe(24);
expect(store.items[0]?.messageCount).toBe(24);
expect(store.withdrawing).toBe(true);
expect(store.loading).toBe(false);
expect(store.generating).toBe(false);
await expect(store.withdraw(7)).rejects.toThrow("正在撤回消息");
await expect(store.send("不要并发发送")).rejects.toThrow("正在撤回消息");
await expect(store.streamSend("不要并发生成", vi.fn())).rejects.toThrow("正在撤回消息");
pending.resolve(response(afterWithdrawal(original)));
await operation;
expect(withdrawConversationMessage).toHaveBeenCalledTimes(1);
expect(withdrawConversationMessage).toHaveBeenCalledWith(1, 5);
expect(store.current?.messages.map((message) => message.id)).toEqual(
original.messages.filter((message) => ![5, 6].includes(message.id)).map((message) => message.id),
);
expect(store.current?.hasMoreMessages).toBe(false);
expect(store.withdrawing).toBe(false);
});
it("restores the turn and counts when deletion fails", async () => {
const store = useConversationStore();
const original = conversation();
store.current = original;
const pending = deferred<ReturnType<typeof response>>();
vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise);
const operation = store.withdraw(5);
pending.reject(new Error("网络请求失败"));
await expect(operation).rejects.toThrow("网络请求失败");
expect(store.current).toEqual(original);
expect(store.items[0]?.messageCount).toBe(26);
expect(store.error).toBe("网络请求失败");
expect(store.withdrawing).toBe(false);
});
it.each([true, false])("does not switch conversations when pending deletion completes (success=%s)", async (succeeds) => {
const store = useConversationStore();
const original = conversation();
store.current = original;
const pending = deferred<ReturnType<typeof response>>();
vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise);
const operation = store.withdraw(5);
vi.mocked(fetchConversation).mockResolvedValue(response(conversation(2)));
await store.open(2);
if (succeeds) {
pending.resolve(response(afterWithdrawal(original)));
await operation;
} else {
pending.reject(new Error("撤回失败"));
await expect(operation).rejects.toThrow("撤回失败");
}
expect(store.current?.id).toBe(2);
expect(store.current?.messageCount).toBe(26);
expect(store.error).toBeNull();
expect(store.items.find((item) => item.id === 1)?.messageCount).toBe(succeeds ? 24 : 26);
});
it("does not restore stale messages from an earlier history request", async () => {
const store = useConversationStore();
const original = conversation();
store.current = original;
const history = deferred<ReturnType<typeof response>>();
vi.mocked(fetchConversation).mockReturnValue(history.promise);
const opening = store.open(1);
const deletion = deferred<ReturnType<typeof response>>();
vi.mocked(withdrawConversationMessage).mockReturnValue(deletion.promise);
const operation = store.withdraw(5);
// Reopening while withdrawal is pending uses the optimistic cache.
await store.open(1);
expect(fetchConversation).toHaveBeenCalledTimes(1);
deletion.resolve(response(afterWithdrawal(original)));
await operation;
history.resolve(response(original));
await opening;
expect(store.current?.messageCount).toBe(24);
expect(store.current?.messages.some((message) => message.id === 5)).toBe(false);
});
it("does not remove the following user message when there is no assistant reply", async () => {
const store = useConversationStore();
const original = conversation(1, 3);
original.messages[1]!.role = "user";
store.current = original;
const pending = deferred<ReturnType<typeof response>>();
vi.mocked(withdrawConversationMessage).mockReturnValue(pending.promise);
const operation = store.withdraw(1);
expect(store.current?.messages.map((message) => message.id)).toEqual([2, 3]);
pending.resolve(response(afterWithdrawal(original, [1])));
await operation;
expect(store.current?.messagesTotal).toBe(2);
});
});

View File

@ -29,8 +29,11 @@ export const useConversationStore = defineStore("conversation", () => {
}; };
const streamStates = reactive(new Map<number, StreamState>()); const streamStates = reactive(new Map<number, StreamState>());
const conversationCache = reactive(new Map<number, Conversation>()); const conversationCache = reactive(new Map<number, Conversation>());
const withdrawingConversations = reactive(new Set<number>());
const conversationVersions = new Map<number, number>();
const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id))); const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id)));
const generating = sending; const generating = sending;
const withdrawing = computed(() => Boolean(current.value && withdrawingConversations.has(current.value.id)));
let listRequest: Promise<void> | null = null; let listRequest: Promise<void> | null = null;
let listRequestSearch: string | undefined; let listRequestSearch: string | undefined;
let listLoadedAt = 0; let listLoadedAt = 0;
@ -65,6 +68,12 @@ export const useConversationStore = defineStore("conversation", () => {
} }
async function open(id: number) { async function open(id: number) {
const version = conversationVersions.get(id) ?? 0;
const pending = conversationCache.get(id);
if (withdrawingConversations.has(id) && pending) {
current.value = pending;
return;
}
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
@ -72,6 +81,11 @@ export const useConversationStore = defineStore("conversation", () => {
if (!response.data.data) throw new Error("对话响应为空"); if (!response.data.data) throw new Error("对话响应为空");
const remote = response.data.data; const remote = response.data.data;
const cached = conversationCache.get(id); const cached = conversationCache.get(id);
// A GET started before withdrawal must not put the deleted turn back.
if ((conversationVersions.get(id) ?? 0) !== version && cached) {
current.value = cached;
return;
}
const stream = streamStates.get(id); const stream = streamStates.get(id);
const merged = stream && cached const merged = stream && cached
? { ? {
@ -94,6 +108,7 @@ export const useConversationStore = defineStore("conversation", () => {
async function loadOlder() { async function loadOlder() {
if ( if (
loadingOlder.value || loadingOlder.value ||
withdrawing.value ||
!current.value || !current.value ||
!current.value.messages.length || !current.value.messages.length ||
!current.value.messagesTotal !current.value.messagesTotal
@ -150,6 +165,7 @@ export const useConversationStore = defineStore("conversation", () => {
if (!current.value) await create(); if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在"); if (!current.value) throw new Error("当前对话不存在");
const conversationId = current.value.id; const conversationId = current.value.id;
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
plainSending.value = true; plainSending.value = true;
error.value = null; error.value = null;
try { try {
@ -184,6 +200,7 @@ export const useConversationStore = defineStore("conversation", () => {
if (!current.value) await create(); if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在"); if (!current.value) throw new Error("当前对话不存在");
const conversationId = current.value.id; const conversationId = current.value.id;
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候"); if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候");
const state: StreamState = { const state: StreamState = {
controller: new AbortController(), controller: new AbortController(),
@ -387,28 +404,55 @@ export const useConversationStore = defineStore("conversation", () => {
async function withdraw(messageId: number) { async function withdraw(messageId: number) {
if (!current.value) return; if (!current.value) return;
loading.value = true; const original = current.value;
const conversationId = original.id;
if (withdrawingConversations.has(conversationId)) throw new Error("正在撤回消息,请稍候");
if (sending.value || loadingOlder.value) throw new Error("当前会话正在处理,请稍候再撤回");
const index = original.messages.findIndex((message) => message.id === messageId && message.role === "user");
if (messageId <= 0 || index < 0) throw new Error("只能撤回已保存的用户消息");
const removed = new Set([messageId]);
const next = original.messages[index + 1];
if (next?.role === "assistant") removed.add(next.id);
const remaining = original.messages.filter((message) => !removed.has(message.id));
withdrawingConversations.add(conversationId);
conversationVersions.set(conversationId, (conversationVersions.get(conversationId) ?? 0) + 1);
setConversation({
...original,
messages: remaining,
messageCount: Math.max(0, original.messageCount - removed.size),
messagesTotal: Math.max(0, original.messagesTotal - removed.size),
hasMoreMessages: original.messagesOffset + remaining.length < original.messagesTotal - removed.size,
lastRiskLevel: [...remaining].reverse().find((message) => message.riskResult)?.riskResult?.level ?? null,
});
error.value = null; error.value = null;
try { try {
const response = await withdrawConversationMessage( const response = await withdrawConversationMessage(
current.value.id, conversationId,
messageId, messageId,
); );
if (!response.data.data) throw new Error("撤回响应为空"); if (!response.data.data) throw new Error("撤回响应为空");
current.value = response.data.data; const result = response.data.data;
conversationCache.set(current.value.id, current.value); // Keep already loaded older pages; the API returns only the newest page.
const index = items.value.findIndex( const messages = new Map(remaining.map((message) => [message.id, message]));
(item) => item.id === current.value?.id, for (const message of result.messages) messages.set(message.id, message);
const mergedMessages = [...messages.values()].sort((left, right) =>
left.createdAt.localeCompare(right.createdAt) || left.id - right.id,
); );
const summary = toSummary(current.value); const merged = {
if (index >= 0) items.value.splice(index, 1, summary); ...result,
else items.value.unshift(summary); messages: mergedMessages,
return current.value; hasMoreMessages: mergedMessages.length < result.messagesTotal,
};
if (conversationCache.has(conversationId)) setConversation(merged);
return merged;
} catch (reason) { } catch (reason) {
// Restore both the turn and the sidebar count if the server rejects deletion.
if (conversationCache.has(conversationId)) setConversation(original);
if (current.value?.id === conversationId)
error.value = reason instanceof Error ? reason.message : "撤回失败"; error.value = reason instanceof Error ? reason.message : "撤回失败";
throw reason; throw reason;
} finally { } finally {
loading.value = false; withdrawingConversations.delete(conversationId);
} }
} }
@ -433,6 +477,7 @@ export const useConversationStore = defineStore("conversation", () => {
loading, loading,
sending, sending,
generating, generating,
withdrawing,
loadingOlder, loadingOlder,
error, error,
loadList, loadList,

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { renderSafeMarkdown } from "@/utils/markdown";
describe("renderSafeMarkdown", () => {
it("renders a Markdown table as semantic table elements", () => {
const html = renderSafeMarkdown(
[
"【风险清单】",
"",
"| 宣称/成分 | 风险等级 | 违规点 | 整改建议 |",
"| --- | --- | --- | --- |",
"| 淡化细纹 | 中 | 缺少功效检测报告 | 补充检测及备案支持 |",
].join("\n"),
);
expect(html).toContain("<table>");
expect(html).toContain("<th>宣称/成分</th>");
expect(html).toContain("<td>淡化细纹</td>");
expect(html).toContain("<td>补充检测及备案支持</td>");
});
it("preserves ordinary Markdown blocks and escaped literal HTML", () => {
const html = renderSafeMarkdown(
"## 结论\n\n<strong>这不是可信 HTML</strong>\n\n- 请补充报告",
);
expect(html).toContain("<h2>结论</h2>");
expect(html).toContain("&lt;strong&gt;");
expect(html).toContain("<li>请补充报告</li>");
});
it("sanitizes unsafe Markdown links and generated markup", () => {
const html = renderSafeMarkdown(
'[点击](javascript:alert(1)) <img src=x onerror="alert(1)">',
);
expect(html).not.toContain('<a href="javascript:');
expect(html).not.toContain("<img src=");
expect(html).toContain("&lt;img");
expect(html).toContain("onerror=");
});
});

41
src/utils/markdown.ts Normal file
View File

@ -0,0 +1,41 @@
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
const parser = new MarkdownIt({
breaks: true,
html: false,
linkify: false,
typographer: false,
});
export function renderSafeMarkdown(source: string): string {
return DOMPurify.sanitize(parser.render(source), {
ALLOWED_TAGS: [
"a",
"blockquote",
"br",
"code",
"del",
"em",
"h1",
"h2",
"h3",
"h4",
"hr",
"li",
"ol",
"p",
"pre",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
],
ALLOWED_ATTR: ["href", "rel", "target"],
ALLOW_DATA_ATTR: false,
});
}