完善 AI 卡片交互与会话消息控制

实现 AI 案例推理方案生成报告并追加到当前会话报告;查看标准详情跳转标准库;优化标准兜底和相似案例展示;新增停止生成、撤回问题及对应回答功能,并将未接入按钮置灰隐藏。
This commit is contained in:
kx_guo 2026-09-09 15:40:21 +08:00
parent 74a1c9d4bc
commit 69a1433a86
7 changed files with 905 additions and 68 deletions

View File

@ -40,16 +40,31 @@ export function sendConversationMessage(
); );
} }
export function withdrawConversationMessage(
conversationId: number,
messageId: number,
) {
return http.delete<ApiResponse<Conversation>>(
`/v1/conversations/${conversationId}/messages/${messageId}`,
);
}
export async function streamConversationMessage( export async function streamConversationMessage(
id: number, id: number,
content: string, content: string,
onEvent: (event: ConversationStreamEvent) => void, onEvent: (event: ConversationStreamEvent) => void,
messageType: "text" | "image" | "file" | "audio" = "text", messageType: "text" | "image" | "file" | "audio" = "text",
signal?: AbortSignal,
) { ) {
const token = localStorage.getItem("risk_prediction_token"); const token = localStorage.getItem("risk_prediction_token");
const controller = new AbortController(); const controller = new AbortController();
const abortFromCaller = () => controller.abort();
if (signal?.aborted) controller.abort();
signal?.addEventListener("abort", abortFromCaller, { once: true });
const timeout = window.setTimeout(() => controller.abort(), 180_000); const timeout = window.setTimeout(() => controller.abort(), 180_000);
const response = await fetch( let response: Response;
try {
response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`, `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
{ {
method: "POST", method: "POST",
@ -62,6 +77,10 @@ export async function streamConversationMessage(
signal: controller.signal, signal: controller.signal,
}, },
); );
} catch (reason) {
if (signal?.aborted) throw new Error("已停止生成", { cause: reason });
throw reason;
}
if (response.status === 401) { if (response.status === 401) {
localStorage.removeItem("risk_prediction_token"); localStorage.removeItem("risk_prediction_token");
window.dispatchEvent(new CustomEvent("risk-auth-expired")); window.dispatchEvent(new CustomEvent("risk-auth-expired"));
@ -109,11 +128,15 @@ export async function streamConversationMessage(
} }
if (!completed) throw new Error("AI 流式连接中断,请重试"); if (!completed) throw new Error("AI 流式连接中断,请重试");
} catch (reason) { } catch (reason) {
if (signal?.aborted) {
throw new Error("已停止生成", { cause: reason });
}
if (controller.signal.aborted) { if (controller.signal.aborted) {
throw new Error("AI 响应超时,请稍后重试", { cause: reason }); throw new Error("AI 响应超时,请稍后重试", { cause: reason });
} }
throw reason; throw reason;
} finally { } finally {
window.clearTimeout(timeout); window.clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromCaller);
} }
} }

View File

@ -18,7 +18,9 @@ import type { AssistantResponseCard } from "@/types/conversation";
const props = defineProps<{ card: AssistantResponseCard }>(); const props = defineProps<{ card: AssistantResponseCard }>();
const router = useRouter(); const router = useRouter();
const feedback = ref(""); const emit = defineEmits<{
generateReport: [card: AssistantResponseCard];
}>();
interface CardData extends AssistantResponseCard { interface CardData extends AssistantResponseCard {
name?: string; name?: string;
partyType?: string; partyType?: string;
@ -48,20 +50,36 @@ interface CardData extends AssistantResponseCard {
analysisBase?: string; analysisBase?: string;
risks?: Array<{ level: string; title: string; detail: string }>; risks?: Array<{ level: string; title: string; detail: string }>;
similarCases?: Array<{ similarCases?: Array<{
caseId?: number | null;
case_id?: number | null;
date?: string | null; date?: string | null;
product?: string | null; product?: string | null;
category?: string | null;
product_category?: string | null;
material?: string | null;
material_type?: string | null;
anomalyType?: string | null;
anomaly_type?: string | null;
problem?: string | null; problem?: string | null;
description?: string | null;
rootCause?: string | null;
root_cause?: string | null;
solution?: string | null; solution?: string | null;
result?: string | null;
defectLevel?: string | null;
defect_level?: string | null;
similarity?: number | null; similarity?: number | null;
}>; }>;
warning?: string; warning?: string;
} }
const data = computed(() => props.card as CardData); const data = computed(() => props.card as CardData);
type SolutionCase = NonNullable<CardData["similarCases"]>[number];
function notify(label: string) { const selectedCase = ref<SolutionCase | null>(null);
feedback.value = `${label}功能将在对应模块接入后执行`; const standardCategories = computed(() =>
window.setTimeout(() => (feedback.value = ""), 2400); [...new Set((data.value.standards ?? []).map((item) => item.category))].join(
} " / ",
),
);
function openSupplier() { function openSupplier() {
const supplier = data.value.name; const supplier = data.value.name;
@ -71,9 +89,38 @@ function openSupplier() {
}); });
} }
function openStandard(riskPoint: string) {
void router.push({
name: "standards",
query: { keyword: riskPoint.slice(0, 50) },
});
}
function openSimilarCase(item: SolutionCase) {
selectedCase.value = item;
}
function closeSimilarCase() {
selectedCase.value = null;
}
function openStandardLibrary() {
const firstStandard = data.value.standards?.[0];
void router.push({
name: "standards",
query: firstStandard?.riskPoint
? { keyword: firstStandard.riskPoint.slice(0, 50) }
: undefined,
});
}
function formatCount(value: unknown) { function formatCount(value: unknown) {
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据"; return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
} }
function generateReport() {
emit("generateReport", props.card);
}
</script> </script>
<template> <template>
@ -230,50 +277,86 @@ function formatCount(value: unknown) {
</div> </div>
<section <section
v-if="data.standardFallbackEnabled || data.standards?.length" v-if="data.standardFallbackEnabled || data.standards?.length"
class="solution-section" class="solution-section solution-standards"
> >
<h4> <h4>
<Ruler :size="14" /> <Ruler :size="14" />
<span v-if="data.standardFallbackEnabled" <span v-if="data.standardFallbackEnabled"
>标准兜底校验(无历史案例时启用)</span >标准兜底校验(无历史案例时启用)</span
> >
<span v-else>关联标准校验</span> <span v-else>标准判定 · 双引擎</span>
</h4> </h4>
<div <div class="solution-standards__sub">
命中判定标准库 {{ data.standards?.length ?? 0 }} 条<span
v-if="standardCategories"
>
· 覆盖 {{ standardCategories }}</span
>
· 来源:《标准文件.xlsx》219 条
</div>
<button
v-for="standard in data.standards" v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`" :key="`${standard.category}-${standard.riskPoint}`"
class="standard-row" class="solution-standard-row"
type="button"
title="查看标准库详情"
@click="openStandard(standard.riskPoint)"
>
<span
class="solution-standard-tag"
:class="{
law: standard.category === '法规' || standard.category === '包装',
}"
>{{ standard.category }}</span
> >
<span>{{ standard.category }}</span>
<div> <div>
<b>{{ standard.riskPoint }}</b <b>{{ standard.riskPoint }}</b
><small>{{ standard.rule }} · {{ standard.source }}</small> ><small
</div> >{{ standard.rule }} ·
<strong>{{ standard.status }}</strong> {{ standard.source || "标准文件.xlsx" }}</small
>
</div> </div>
<strong
:class="{
ok: standard.status === '合规' || standard.status === '打样必测',
}"
>{{ standard.status }}</strong
>
</button>
<p v-if="!data.standards?.length" class="empty"> <p v-if="!data.standards?.length" class="empty">
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。 当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
</p> </p>
<div
v-if="data.standardFallbackEnabled && data.standards?.length"
class="solution-standards__foot"
>
🔄
双引擎融合:历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。
</div>
</section> </section>
<section v-if="data.similarCases?.length" class="solution-section"> <section v-if="data.similarCases?.length" class="solution-section">
<h4>相似历史案例</h4> <h4>匹配到 {{ data.similarCases.length }} 条相似案例</h4>
<div <button
v-for="(item, index) in data.similarCases" v-for="(item, index) in data.similarCases"
:key="`${item.date}-${index}`" :key="`${item.date}-${index}`"
class="standard-row" class="solution-case-row"
type="button"
title="查看案例详情"
@click="openSimilarCase(item)"
> >
<span>TOP {{ index + 1 }}</span> <span class="solution-case-rank">TOP {{ index + 1 }}</span>
<div> <div>
<b>{{ item.product || "未命名产品" }}</b>
<small <small
>{{ item.problem || "异常问题未填写" }} → >{{ item.date || "日期未知" }} ·
{{ item.product || "未命名产品" }} ·
{{ item.problem || "异常问题未填写" }} →
{{ item.solution || "解决方案未填写" }} {{ item.solution || "解决方案未填写" }}
<em v-if="item.similarity != null" <em v-if="item.similarity != null"
>相似度 {{ Math.round(item.similarity * 100) }}%</em >相似度 {{ Math.round(item.similarity * 100) }}%</em
></small ></small
> >
</div> </div>
</div> </button>
</section> </section>
<section v-if="data.risks?.length" class="solution-section risk-list"> <section v-if="data.risks?.length" class="solution-section risk-list">
<div v-for="risk in data.risks" :key="risk.title" class="solution-risk"> <div v-for="risk in data.risks" :key="risk.title" class="solution-risk">
@ -306,16 +389,25 @@ function formatCount(value: unknown) {
<button <button
type="button" type="button"
class="response-actions__primary" class="response-actions__primary"
@click="notify('生成报告')" :disabled="card.type !== 'solution'"
:title="card.type === 'solution' ? '追加到当前风险报告' : '功能开发中'"
@click="generateReport"
> >
<CheckCircle2 :size="14" />生成报告 <CheckCircle2 :size="14" />生成报告
</button> </button>
<button type="button" @click="notify('查看详情')">查看详情</button> <button
<button type="button" @click="notify('追问详情')">追问详情</button> type="button"
:disabled="card.type !== 'solution'"
:title="card.type === 'solution' ? '跳转判定标准库' : '功能开发中'"
@click="openStandardLibrary"
>
查看标准详情
</button>
<button <button
v-if="card.type === 'solution'" v-if="card.type === 'solution'"
type="button" type="button"
@click="notify('收藏方案')" disabled
title="功能开发中"
> >
📌 收藏方案 📌 收藏方案
</button> </button>
@ -327,7 +419,104 @@ function formatCount(value: unknown) {
🏭 查供应商 🏭 查供应商
</button> </button>
</div> </div>
<p v-if="feedback" class="response-feedback">{{ feedback }}</p> <div
v-if="selectedCase"
class="case-modal-backdrop"
role="presentation"
@click.self="closeSimilarCase"
>
<section
class="case-modal"
role="dialog"
aria-modal="true"
aria-labelledby="solution-case-title"
>
<header class="case-modal__header">
<div>
<small>相似历史案例</small>
<h3 id="solution-case-title">
{{ selectedCase.product || "未命名产品" }}
</h3>
</div>
<button
type="button"
class="case-modal__close"
aria-label="关闭案例详情"
@click="closeSimilarCase"
>
×
</button>
</header>
<div class="case-modal__summary">
<strong>{{ selectedCase.date || "日期未知" }}</strong>
<span v-if="selectedCase.similarity != null">
相似度 {{ Math.round(selectedCase.similarity * 100) }}%
</span>
</div>
<dl class="case-modal__grid">
<div>
<dt>品类</dt>
<dd>
{{
selectedCase.category ||
selectedCase.product_category ||
"未填写"
}}
</dd>
</div>
<div>
<dt>物料</dt>
<dd>
{{
selectedCase.material || selectedCase.material_type || "未填写"
}}
</dd>
</div>
<div>
<dt>异常类型</dt>
<dd>
{{
selectedCase.anomalyType ||
selectedCase.anomaly_type ||
"未填写"
}}
</dd>
</div>
<div>
<dt>缺陷等级</dt>
<dd>
{{
selectedCase.defectLevel ||
selectedCase.defect_level ||
"未定级"
}}
</dd>
</div>
<div class="case-modal__field--wide">
<dt>问题描述</dt>
<dd>
{{ selectedCase.problem || selectedCase.description || "未填写" }}
</dd>
</div>
<div
v-if="selectedCase.rootCause || selectedCase.root_cause"
class="case-modal__field--wide"
>
<dt>原因分析</dt>
<dd>{{ selectedCase.rootCause || selectedCase.root_cause }}</dd>
</div>
<div class="case-modal__field--wide">
<dt>解决方案</dt>
<dd>{{ selectedCase.solution || "未填写" }}</dd>
</div>
<div v-if="selectedCase.result" class="case-modal__field--wide">
<dt>处理结果</dt>
<dd>{{ selectedCase.result }}</dd>
</div>
</dl>
<footer class="case-modal__footer">点击遮罩或右上角关闭详情</footer>
</section>
</div>
</article> </article>
</template> </template>
@ -350,8 +539,19 @@ function formatCount(value: unknown) {
border-left: 3px solid var(--color-accent); border-left: 3px solid var(--color-accent);
} }
.response-card--solution { .response-card--solution {
border: 1px solid #bfdbfe; position: relative;
border-left: 3px solid var(--color-purple); overflow: hidden;
border: 1px solid var(--color-accent-soft);
border-radius: 10px;
padding: 22px;
}
.response-card--solution::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
border-radius: 10px 0 0 10px;
background: linear-gradient(180deg, var(--color-accent), var(--color-purple));
content: "";
} }
.response-card__header { .response-card__header {
display: flex; display: flex;
@ -376,7 +576,20 @@ function formatCount(value: unknown) {
color: var(--color-warning); color: var(--color-warning);
} }
.response-card--solution .response-card__icon { .response-card--solution .response-card__icon {
color: var(--color-purple); width: 28px;
height: 28px;
border-radius: 5px;
background: linear-gradient(135deg, var(--color-accent), var(--color-purple));
color: #fff;
}
.response-card--solution .response-card__header {
margin-bottom: 14px;
border-bottom: 0;
padding-bottom: 0;
}
.response-card--solution .response-card__header h3 {
font-size: 14px;
font-weight: 650;
} }
.response-card__header h3 { .response-card__header h3 {
margin: 0; margin: 0;
@ -496,6 +709,66 @@ function formatCount(value: unknown) {
color: var(--color-warning); color: var(--color-warning);
font-size: 10px; font-size: 10px;
} }
.solution-standard-row {
display: flex;
width: 100%;
align-items: flex-start;
gap: 8px;
border: 0;
border-top: 1px dashed var(--color-border-light);
padding: 7px 0;
background: transparent;
color: var(--color-text-secondary);
font: inherit;
font-size: 13px;
line-height: 1.55;
text-align: left;
cursor: pointer;
}
.solution-standard-row:first-of-type {
border-top: 0;
}
.solution-standard-row:hover {
border-radius: 5px;
background: var(--color-bg-warm);
}
.solution-standard-tag {
flex: 0 0 auto;
border: 1px solid var(--color-accent-soft);
border-radius: 999px;
padding: 1px 8px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 10px;
font-weight: 600;
}
.solution-standard-tag.law {
border-color: #fbcfe8;
background: #fce7f3;
color: #be185d;
}
.solution-standard-row > div {
min-width: 0;
flex: 1;
}
.solution-standard-row > div b {
color: var(--color-text-secondary);
font-weight: 500;
}
.solution-standard-row small {
display: block;
margin-top: 1px;
color: var(--color-text-faint);
font-size: 10px;
}
.solution-standard-row > strong {
flex: 0 0 auto;
color: var(--color-warning);
font-size: 11px;
}
.solution-standard-row > strong.ok {
color: var(--color-success);
}
.suggestion-list { .suggestion-list {
margin: 12px 0 0; margin: 12px 0 0;
padding-left: 18px; padding-left: 18px;
@ -540,13 +813,82 @@ function formatCount(value: unknown) {
} }
.solution-base { .solution-base {
margin-top: 14px; margin-top: 14px;
border-radius: 6px; border-radius: 5px;
padding: 10px; padding: 10px 12px;
background: #f3f7fb; background: var(--color-bg-warm);
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 11px; font-size: 12px;
line-height: 1.7;
}
.solution-standards {
margin-bottom: 10px;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 14px;
background: #fff;
}
.solution-standards h4 {
margin-bottom: 4px;
color: #1d4ed8;
font-size: 12px;
}
.solution-standards__sub {
margin-bottom: 10px;
color: var(--color-text-faint);
font-size: 10px;
}
.solution-standards__foot {
margin-top: 8px;
border-top: 1px dashed var(--color-border-light);
padding-top: 8px;
color: var(--color-text-muted);
font-size: 10px;
line-height: 1.6; line-height: 1.6;
} }
.solution-standards__foot b {
color: var(--color-accent);
}
.solution-case-row {
display: grid;
width: 100%;
grid-template-columns: 58px minmax(0, 1fr);
align-items: start;
gap: 8px;
border: 0;
border-radius: 6px;
margin-bottom: 4px;
padding: 7px 9px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
text-align: left;
cursor: pointer;
}
.solution-case-row:hover {
background: #f0f0ee;
}
.solution-case-rank {
padding-top: 1px;
color: var(--color-accent);
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.solution-case-row > div {
min-width: 0;
}
.solution-case-row small {
display: block;
color: var(--color-text-faint);
font-size: 9px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.solution-case-row em {
margin-left: 6px;
color: var(--color-accent);
font-style: normal;
white-space: nowrap;
}
.solution-confidence { .solution-confidence {
margin-left: auto; margin-left: auto;
border: 1px solid #fcd34d; border: 1px solid #fcd34d;
@ -559,12 +901,13 @@ function formatCount(value: unknown) {
} }
.solution-suggest { .solution-suggest {
margin-top: 14px; margin-top: 14px;
border-radius: 6px; border-radius: 5px;
padding: 11px; padding: 12px;
background: #eff6ff; background: var(--color-accent-subtle);
} }
.solution-suggest h4 { .solution-suggest h4 {
color: var(--color-accent); color: var(--color-accent);
font-size: 12px;
} }
.solution-suggest ol { .solution-suggest ol {
margin: 0; margin: 0;
@ -572,16 +915,23 @@ function formatCount(value: unknown) {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.solution-suggest li { .solution-suggest li {
margin: 4px 0; margin: 3px 0;
font-size: 11px; font-size: 13px;
line-height: 1.55; line-height: 1.6;
} }
.solution-risk { .solution-risk {
display: flex; display: flex;
gap: 7px; gap: 7px;
border-top: 1px solid var(--color-border-light); border-bottom: 1px solid var(--color-border-light);
padding: 8px 0; padding: 8px 0;
font-size: 13px;
}
.solution-risk:first-child {
border-top: 0;
}
.solution-risk:last-child {
border-bottom: 0;
} }
.solution-risk > span { .solution-risk > span {
flex: 0 0 auto; flex: 0 0 auto;
@ -602,24 +952,24 @@ function formatCount(value: unknown) {
.solution-risk p { .solution-risk p {
margin: 0; margin: 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 11px; font-size: 13px;
line-height: 1.5; line-height: 1.6;
} }
.solution-warning { .solution-warning {
display: flex; display: flex;
gap: 6px; gap: 6px;
margin-top: 10px; margin-top: 10px;
border-radius: 5px; border-radius: 5px;
padding: 8px 10px; padding: 8px 12px;
background: var(--color-warning-soft); background: var(--color-warning-soft);
color: #92400e; color: #92400e;
font-size: 10px; font-size: 11px;
line-height: 1.5; line-height: 1.6;
} }
.response-actions { .response-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 8px;
margin-top: 14px; margin-top: 14px;
border-top: 1px solid var(--color-border-light); border-top: 1px solid var(--color-border-light);
padding-top: 12px; padding-top: 12px;
@ -628,27 +978,133 @@ function formatCount(value: unknown) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
min-height: 30px; min-height: 32px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 5px; border-radius: 5px;
padding: 0 10px; padding: 0 14px;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 10px; font-size: 12px;
} }
.response-actions__primary { .response-actions__primary {
border-color: var(--color-text) !important; border-color: var(--color-text) !important;
background: var(--color-text) !important; background: var(--color-text) !important;
color: white !important; color: white !important;
} }
.response-feedback { .response-actions button:disabled {
margin: 8px 0 0; border-color: var(--color-border-light);
background: var(--color-bg-warm);
color: var(--color-text-faint);
cursor: not-allowed;
opacity: 0.7;
}
.case-modal-backdrop {
position: fixed;
z-index: 30;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgb(17 17 17 / 28%);
}
.case-modal {
width: min(620px, 100%);
max-height: min(720px, calc(100vh - 48px));
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface);
box-shadow: 0 22px 54px rgb(15 23 42 / 22%);
}
.case-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
border-bottom: 1px solid var(--color-border-light);
padding: 18px 20px;
}
.case-modal__header small {
color: var(--color-accent); color: var(--color-accent);
font-size: 10px; font-size: 10px;
font-weight: 700;
}
.case-modal__header h3 {
margin: 4px 0 0;
font-size: 17px;
}
.case-modal__close {
border: 0;
background: transparent;
color: var(--color-text-muted);
font-size: 22px;
cursor: pointer;
}
.case-modal__summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 10px;
}
.case-modal__summary span {
border-radius: 4px;
padding: 4px 7px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 11px;
font-weight: 700;
}
.case-modal__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 0;
padding: 10px 20px 20px;
}
.case-modal__grid > div {
min-width: 0;
border: 1px solid var(--color-border-light);
border-radius: 5px;
padding: 9px 10px;
}
.case-modal__grid dt {
color: var(--color-text-muted);
font-size: 10px;
}
.case-modal__grid dd {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
}
.case-modal__field--wide {
grid-column: 1 / -1;
}
.case-modal__footer {
border-top: 1px solid var(--color-border-light);
padding: 10px 20px 14px;
color: var(--color-text-faint);
font-size: 10px;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.metric-grid { .metric-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
@media (max-width: 560px) {
.case-modal-backdrop {
align-items: end;
padding: 0;
}
.case-modal {
max-height: 90vh;
border-radius: 10px 10px 0 0;
}
.case-modal__grid {
grid-template-columns: 1fr;
}
.case-modal__field--wide {
grid-column: auto;
}
}
</style> </style>

View File

@ -437,7 +437,12 @@ function openSupplier() {
</button> </button>
<button type="button" @click="openCase()">案例详情</button> <button type="button" @click="openCase()">案例详情</button>
<button type="button" @click="openSupplier">供应商对比</button> <button type="button" @click="openSupplier">供应商对比</button>
<button v-if="canMonitor" type="button" @click="notify('加入监控')"> <button
v-if="canMonitor"
type="button"
disabled
title="加入监控功能开发中"
>
加入监控 加入监控
</button> </button>
</div> </div>
@ -1117,6 +1122,13 @@ h3 {
background: var(--color-text); background: var(--color-text);
color: white; color: white;
} }
.risk-actions button:disabled {
border-color: var(--color-border-light);
background: var(--color-bg-warm);
color: var(--color-text-faint);
cursor: not-allowed;
opacity: 0.7;
}
.risk-feedback { .risk-feedback {
margin: 8px 0 0; margin: 8px 0 0;

View File

@ -277,6 +277,47 @@ function dimensionConclusion(item: {
</template> </template>
</p> </p>
<template v-if="report.aiSolutionCards?.length">
<h3 class="report-subsection-title">AI 案例推理方案明细(已追加)</h3>
<section
v-for="(solution, index) in report.aiSolutionCards"
:key="`${solution.title || 'solution'}-${index}`"
class="ai-solution-report"
>
<header>
<strong>{{ solution.title || "AI 案例推理生成" }}</strong>
<span v-if="solution.confidence"
>置信度:{{ solution.confidence }}</span
>
</header>
<p v-if="solution.analysisBase">
<b>分析基础:</b>{{ solution.analysisBase }}
</p>
<template v-if="solution.risks?.length">
<h3>风险项</h3>
<ul>
<li
v-for="risk in solution.risks"
:key="`${risk.title}-${risk.detail}`"
>
<b>{{ risk.level }} · {{ risk.title }}:</b>{{ risk.detail }}
</li>
</ul>
</template>
<template v-if="solution.suggestions?.length">
<h3>建议方案</h3>
<ol>
<li v-for="suggestion in solution.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ol>
</template>
<p v-if="solution.warning" class="ai-solution-report__warning">
{{ solution.warning }}
</p>
</section>
</template>
<h2>七、标准判定结果</h2> <h2>七、标准判定结果</h2>
<template v-if="report.standardJudgment?.length"> <template v-if="report.standardJudgment?.length">
<table> <table>
@ -440,6 +481,61 @@ ol {
font-size: 13px; font-size: 13px;
line-height: 1.8; line-height: 1.8;
} }
.ai-solution-report {
margin: 0 0 16px;
border: 1px solid #bfdbfe;
border-left: 3px solid #7c3aed;
border-radius: 7px;
padding: 14px;
background: #fafbff;
}
.report-subsection-title {
margin: 14px 0 8px;
color: #334155;
font-size: 13px;
}
.ai-solution-report header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: #1d4ed8;
font-size: 13px;
}
.ai-solution-report header span {
border: 1px solid #fde68a;
border-radius: 999px;
padding: 2px 8px;
background: #fffbeb;
color: #d97706;
font-size: 10px;
}
.ai-solution-report p,
.ai-solution-report li {
color: #334155;
font-size: 12px;
line-height: 1.7;
}
.ai-solution-report p {
margin: 6px 0;
}
.ai-solution-report h3 {
margin: 10px 0 4px;
font-size: 12px;
}
.ai-solution-report ul,
.ai-solution-report ol {
margin: 4px 0;
padding-left: 20px;
}
.ai-solution-report__warning {
border-radius: 4px;
padding: 7px 9px;
background: #fffbeb;
color: #92400e !important;
font-size: 11px !important;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.report-page { .report-page {
padding: 12px; padding: 12px;

View File

@ -11,7 +11,7 @@ import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
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 type { RiskAssessmentResult } from "@/types/risk"; import type { AiSolutionReport, RiskAssessmentResult } from "@/types/risk";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -20,8 +20,10 @@ const conversationStore = useConversationStore();
const { const {
current: conversation, current: conversation,
sending: loading, sending: loading,
generating,
error, error,
} = storeToRefs(conversationStore); } = storeToRefs(conversationStore);
const withdrawingId = ref<number | null>(null);
const prompt = ref(""); const prompt = ref("");
const ocrOpen = ref(false); const ocrOpen = ref(false);
const scrollArea = ref<HTMLElement | null>(null); const scrollArea = ref<HTMLElement | null>(null);
@ -204,6 +206,25 @@ 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() {
conversationStore.stopGeneration();
}
async function withdrawMessage(messageId: number) {
if (!conversation.value || withdrawingId.value !== null) return;
if (!window.confirm("撤回后将同时删除该问题和对应回答,确认撤回吗?")) return;
withdrawingId.value = messageId;
try {
await conversationStore.withdraw(messageId);
} finally {
withdrawingId.value = null;
}
}
async function openFromRoute() { async function openFromRoute() {
const rawId = route.query.conversation; const rawId = route.query.conversation;
if (typeof rawId !== "string") return; if (typeof rawId !== "string") return;
@ -227,6 +248,53 @@ function openReport(result: RiskAssessmentResult) {
void router.push({ name: "report" }); void router.push({ name: "report" });
} }
function openSolutionReport(card: Record<string, unknown>) {
const solution = card as AiSolutionReport;
const current = conversation.value?.messages
.slice()
.reverse()
.find((message) => message.riskResult)?.riskResult;
let stored: RiskAssessmentResult | null = null;
try {
const raw = sessionStorage.getItem("dieya-risk-report");
stored = raw ? (JSON.parse(raw) as RiskAssessmentResult) : null;
} catch {
stored = null;
}
const sameReport = Boolean(
current && stored && current.subjectId === stored.subjectId,
);
const base: RiskAssessmentResult =
sameReport && stored
? structuredClone(stored)
: current
? structuredClone(current)
: stored || {
subjectId: "AI 案例推理方案",
score: 50,
level: "medium",
modelVersion: "ai-solution-card",
factors: [],
};
const existing = base.aiSolutionCards ?? [];
const merged: RiskAssessmentResult = {
...base,
aiSolutionCards: [...existing, solution],
aiSuggestions: [
...(base.aiSuggestions ?? []),
...(solution.suggestions ?? []),
],
similarCases: solution.similarCases?.length
? [...(base.similarCases ?? []), ...solution.similarCases]
: base.similarCases,
standardJudgment: solution.standards?.length
? [...(base.standardJudgment ?? []), ...solution.standards]
: base.standardJudgment,
};
sessionStorage.setItem("dieya-risk-report", JSON.stringify(merged));
void router.push({ name: "report" });
}
async function submit( async function submit(
messageType: "text" | "image" | "file" | "audio" = "text", messageType: "text" | "image" | "file" | "audio" = "text",
) { ) {
@ -293,6 +361,22 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<div v-if="message.role === 'user'" class="user-bubble"> <div v-if="message.role === 'user'" class="user-bubble">
{{ message.content }} {{ message.content }}
<time>{{ formatMessageTime(message.createdAt) }}</time> <time>{{ formatMessageTime(message.createdAt) }}</time>
<div class="user-message-actions">
<button
v-if="
message.id > 0 &&
!generating &&
hasAssistantPair(
conversation?.messages.indexOf(message) ?? -1,
)
"
type="button"
:disabled="withdrawingId !== null || loading"
@click="withdrawMessage(message.id)"
>
撤回
</button>
</div>
</div> </div>
<div v-else class="message-body"> <div v-else class="message-body">
<div <div
@ -330,6 +414,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<AssistantResponseCard <AssistantResponseCard
v-if="message.responseCard" v-if="message.responseCard"
:card="message.responseCard" :card="message.responseCard"
@generate-report="openSolutionReport"
/> />
<time class="message-time">{{ <time class="message-time">{{
formatMessageTime(message.createdAt) formatMessageTime(message.createdAt)
@ -342,6 +427,13 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
<div class="message-avatar">蝶</div> <div class="message-avatar">蝶</div>
<div class="message-body"> <div class="message-body">
<p class="state-message">{{ loadingText }}</p> <p class="state-message">{{ loadingText }}</p>
<button
type="button"
class="stop-generation"
@click="stopGeneration"
>
停止生成
</button>
</div> </div>
</div> </div>
<p v-if="error" class="state-message state-message--error"> <p v-if="error" class="state-message state-message--error">
@ -517,6 +609,45 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
text-align: right; text-align: right;
} }
.user-message-actions {
display: flex;
justify-content: flex-end;
margin-top: 7px;
}
.user-message-actions button {
border-color: rgb(255 255 255 / 35%);
color: rgb(255 255 255 / 82%);
}
.user-message-actions button,
.stop-generation {
border: 1px solid rgb(255 255 255 / 35%);
border-radius: 999px;
padding: 3px 9px;
background: transparent;
color: rgb(255 255 255 / 82%);
font-size: 10px;
}
.user-message-actions button:hover,
.stop-generation:hover {
border-color: rgb(255 255 255 / 65%);
color: white;
}
.user-message-actions button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.stop-generation {
margin-top: 8px;
border-color: var(--color-border-strong);
background: var(--color-surface);
color: var(--color-text-secondary);
}
.assistant-bubble { .assistant-bubble {
width: fit-content; width: fit-content;
max-width: 82%; max-width: 82%;

View File

@ -5,6 +5,7 @@ import {
createConversation, createConversation,
fetchConversation, fetchConversation,
fetchConversations, fetchConversations,
withdrawConversationMessage,
streamConversationMessage, streamConversationMessage,
sendConversationMessage, sendConversationMessage,
} from "@/api/conversations"; } from "@/api/conversations";
@ -19,6 +20,9 @@ export const useConversationStore = defineStore("conversation", () => {
const sending = ref(false); const sending = ref(false);
const loadingOlder = ref(false); const loadingOlder = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const generating = ref(false);
let activeController: AbortController | null = null;
let activeUserMessageId: number | null = null;
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;
@ -144,6 +148,8 @@ 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("当前对话不存在");
sending.value = true; sending.value = true;
generating.value = true;
activeController = new AbortController();
error.value = null; error.value = null;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const userMessage = { const userMessage = {
@ -156,6 +162,7 @@ export const useConversationStore = defineStore("conversation", () => {
semantic: null, semantic: null,
createdAt: startedAt, createdAt: startedAt,
}; };
activeUserMessageId = userMessage.id;
const assistantMessage = { const assistantMessage = {
id: userMessage.id - 1, id: userMessage.id - 1,
role: "assistant" as const, role: "assistant" as const,
@ -178,6 +185,23 @@ export const useConversationStore = defineStore("conversation", () => {
conversationId, conversationId,
content, content,
(event) => { (event) => {
if (
event.type === "start" &&
event.data &&
typeof event.data === "object" &&
"messageId" in event.data &&
typeof event.data.messageId === "number" &&
current.value
) {
const serverMessageId = event.data.messageId;
const messages = current.value.messages.map((message) =>
message.id === userMessage.id
? { ...message, id: serverMessageId }
: message,
);
activeUserMessageId = serverMessageId;
current.value = { ...current.value, messages };
}
if (event.type === "delta" && event.content) onDelta(event.content); if (event.type === "delta" && event.content) onDelta(event.content);
if (event.type === "semantic" && event.data && current.value) { if (event.type === "semantic" && event.data && current.value) {
const messages = [...current.value.messages]; const messages = [...current.value.messages];
@ -207,6 +231,7 @@ export const useConversationStore = defineStore("conversation", () => {
current.value = event.data as Conversation; current.value = event.data as Conversation;
}, },
messageType, messageType,
activeController.signal,
); );
if ( if (
!current.value || !current.value ||
@ -223,15 +248,49 @@ export const useConversationStore = defineStore("conversation", () => {
return current.value; return current.value;
} catch (reason) { } catch (reason) {
const conversationId = current.value?.id; const conversationId = current.value?.id;
if (activeController?.signal.aborted) {
if (conversationId) {
try {
const response = await fetchConversation(conversationId);
if (response.data.data) current.value = response.data.data;
} catch {
// If the request was cancelled before persistence, remove both
// temporary messages from the local conversation.
if (current.value) { if (current.value) {
const messages = current.value.messages.filter( const messages = current.value.messages.filter(
(message) => message.id !== assistantMessage.id, (message) =>
message.id !== assistantMessage.id &&
message.id !== userMessage.id,
); );
current.value = { current.value = {
...current.value, ...current.value,
messages, messages,
messageCount: Math.max(0, current.value.messageCount - 1), messageCount: Math.max(0, current.value.messageCount - 2),
messagesTotal: Math.max(0, current.value.messagesTotal - 1), messagesTotal: Math.max(0, current.value.messagesTotal - 2),
};
}
}
const summary = current.value ? toSummary(current.value) : null;
if (summary) {
const index = items.value.findIndex(
(item) => item.id === summary.id,
);
if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary);
}
}
return current.value;
}
if (current.value) {
const messages = current.value.messages.filter(
(message) =>
message.id !== assistantMessage.id && message.id !== userMessage.id,
);
current.value = {
...current.value,
messages,
messageCount: Math.max(0, current.value.messageCount - 2),
messagesTotal: Math.max(0, current.value.messagesTotal - 2),
}; };
} }
if (conversationId) { if (conversationId) {
@ -246,6 +305,43 @@ export const useConversationStore = defineStore("conversation", () => {
throw reason; throw reason;
} finally { } finally {
sending.value = false; sending.value = false;
generating.value = false;
activeController = null;
activeUserMessageId = null;
}
}
function stopGeneration() {
activeController?.abort();
}
function isGeneratingMessage(messageId: number) {
return activeUserMessageId === messageId && generating.value;
}
async function withdraw(messageId: number) {
if (!current.value) return;
loading.value = true;
error.value = null;
try {
const response = await withdrawConversationMessage(
current.value.id,
messageId,
);
if (!response.data.data) throw new Error("撤回响应为空");
current.value = response.data.data;
const index = items.value.findIndex(
(item) => item.id === current.value?.id,
);
const summary = toSummary(current.value);
if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary);
return current.value;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "撤回失败";
throw reason;
} finally {
loading.value = false;
} }
} }
@ -269,6 +365,7 @@ export const useConversationStore = defineStore("conversation", () => {
current, current,
loading, loading,
sending, sending,
generating,
loadingOlder, loadingOlder,
error, error,
loadList, loadList,
@ -277,6 +374,9 @@ export const useConversationStore = defineStore("conversation", () => {
create, create,
send, send,
streamSend, streamSend,
stopGeneration,
isGeneratingMessage,
withdraw,
reset, reset,
}; };
}); });

View File

@ -53,6 +53,7 @@ export interface RiskAssessmentResult {
similarity?: number | null; similarity?: number | null;
}>; }>;
aiSuggestions?: string[]; aiSuggestions?: string[];
aiSolutionCards?: AiSolutionReport[];
riskBadge?: string | null; riskBadge?: string | null;
alertText?: string | null; alertText?: string | null;
categoryWarning?: { categoryWarning?: {
@ -84,6 +85,24 @@ export interface RiskAssessmentResult {
dimensionScores?: RiskDimensionScore[]; dimensionScores?: RiskDimensionScore[];
} }
export interface AiSolutionReport {
title?: string;
confidence?: string | null;
analysisBase?: string | null;
standardFallbackEnabled?: boolean;
standards?: Array<{
category: string;
riskPoint: string;
rule: string;
source: string | null;
status: string;
}>;
similarCases?: NonNullable<RiskAssessmentResult["similarCases"]>;
risks?: Array<{ level: string; title: string; detail: string }>;
suggestions?: string[];
warning?: string | null;
}
export type RiskReportResult = RiskAssessmentResult; export type RiskReportResult = RiskAssessmentResult;
export interface ModelInfo { export interface ModelInfo {