优化风险卡片与历史会话加载

- 展示数据库七维风险维度、默认值和数据说明\n- 增加标准兜底校验卡片及无历史案例提示\n- 支持 AI 风险建议和案例推理卡片的新数据结构\n- 进入会话默认定位最新内容\n- 上划到顶部自动加载更早的 20 条消息并保持阅读位置\n- 移除手动加载更早消息按钮\n- 更新风险评估表单和前端类型定义
This commit is contained in:
kx_guo 2026-09-04 18:02:39 +08:00
parent b672f025dd
commit 32739944c7
6 changed files with 253 additions and 207 deletions

View File

@ -8,6 +8,7 @@ import {
CheckCircle2, CheckCircle2,
ClipboardCheck, ClipboardCheck,
PackageSearch, PackageSearch,
Ruler,
Sparkles, Sparkles,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
@ -196,8 +197,17 @@ function formatCount(value: unknown) {
<template v-else> <template v-else>
<div class="solution-base"><b>分析基础:</b>{{ data.analysisBase }}</div> <div class="solution-base"><b>分析基础:</b>{{ data.analysisBase }}</div>
<section v-if="data.standards?.length" class="solution-section"> <section
<h4><BookOpenCheck :size="14" /> 标准兜底校验</h4> v-if="data.standardFallbackEnabled || data.standards?.length"
class="solution-section"
>
<h4>
<Ruler :size="14" />
<span v-if="data.standardFallbackEnabled"
>标准兜底校验(无历史案例时启用)</span
>
<span v-else>关联标准校验</span>
</h4>
<div <div
v-for="standard in data.standards" v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`" :key="`${standard.category}-${standard.riskPoint}`"
@ -210,6 +220,9 @@ function formatCount(value: unknown) {
</div> </div>
<strong>{{ standard.status }}</strong> <strong>{{ standard.status }}</strong>
</div> </div>
<p v-if="!data.standards?.length" class="empty">
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
</p>
</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">

View File

@ -22,6 +22,15 @@ const props = defineProps<{
const expanded = ref(true); const expanded = ref(true);
const feedback = ref(""); const feedback = ref("");
interface DimensionView {
name: string;
score: number;
weight: string;
tone: string;
status?: string;
reason?: string | null;
}
function notify(label: string) { function notify(label: string) {
feedback.value = `${label}功能将在对应模块接入后执行`; feedback.value = `${label}功能将在对应模块接入后执行`;
window.setTimeout(() => (feedback.value = ""), 2600); window.setTimeout(() => (feedback.value = ""), 2600);
@ -34,19 +43,29 @@ function copyScore() {
notify("评分已复制"); notify("评分已复制");
} }
const dimensions = computed(() => const dimensions = computed<DimensionView[]>(() =>
(props.result.dimensionScores?.length (props.result.dimensionScores?.length
? props.result.dimensionScores ? props.result.dimensionScores.map((item) => ({
dimension: item.dimension,
score: item.score,
weight: item.weight,
status: item.status,
reason: item.reason,
}))
: props.result.factors.map((factor) => ({ : props.result.factors.map((factor) => ({
dimension: factor.name, dimension: factor.name,
score: Math.max(0, Math.round(100 - factor.value)), score: Math.max(0, Math.round(100 - factor.value)),
weight: factor.weight, weight: factor.weight,
status: undefined,
reason: undefined,
})) }))
).map((item) => ({ ).map((item) => ({
name: item.dimension, name: item.dimension,
score: Math.round(item.score), score: Math.round(item.score),
weight: `${item.weight}%`, weight: `${item.weight}%`,
tone: item.score < 40 ? "danger" : item.score < 60 ? "warning" : "success", tone: item.score < 40 ? "danger" : item.score < 60 ? "warning" : "success",
status: item.status,
reason: item.reason,
})), })),
); );
const suggestions = computed(() => const suggestions = computed(() =>
@ -195,7 +214,8 @@ const stars = computed(() => {
</div> </div>
<small <small
><b>{{ item.score }}</b ><b>{{ item.score }}</b
>分 · {{ item.weight }}</small >分 · {{ item.weight }}
<em v-if="item.status === 'default'">默认</em></small
> >
</div> </div>
</div> </div>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Plus, ShieldCheck, Trash2 } from "@lucide/vue"; import { ShieldCheck } from "@lucide/vue";
import { reactive } from "vue"; import { reactive } from "vue";
import type { RiskAssessmentRequest, RiskIndicator } from "@/types/risk"; import type { RiskAssessmentRequest } from "@/types/risk";
defineProps<{ loading: boolean }>(); defineProps<{ loading: boolean }>();
@ -10,111 +10,99 @@ const emit = defineEmits<{
submit: [payload: RiskAssessmentRequest]; submit: [payload: RiskAssessmentRequest];
}>(); }>();
const form = reactive<RiskAssessmentRequest>({ const form = reactive({
subjectId: "customer-001", subjectId: "脱毛膏 100g 新品",
indicators: [ productCategory: "脱毛膏",
{ name: "historicalDefault", value: 28, weight: 4 }, materialType: "PE软管",
{ name: "debtBurden", value: 52, weight: 3 }, supplier: "瑞沣",
{ name: "cashflowVolatility", value: 36, weight: 3 }, factory: "",
], stageNew: "第三阶段(产品开发)",
defectLevel: "",
}); });
function addIndicator() {
form.indicators.push({
name: `indicator${form.indicators.length + 1}`,
value: 0,
weight: 1,
});
}
function removeIndicator(index: number) {
if (form.indicators.length > 1) form.indicators.splice(index, 1);
}
function submit() { function submit() {
const indicators: RiskIndicator[] = form.indicators.map((item) => ({ emit("submit", {
...item, subjectId: form.subjectId,
})); indicators: [],
emit("submit", { subjectId: form.subjectId, indicators }); productCategory: form.productCategory || null,
materialType: form.materialType || null,
supplier: form.supplier || null,
factory: form.factory || null,
stageNew: form.stageNew || null,
defectLevel: form.defectLevel || null,
});
} }
</script> </script>
<template> <template>
<form class="assessment-form" @submit.prevent="submit"> <form class="assessment-form" @submit.prevent="submit">
<div class="field-group"> <div class="form-intro">
<label for="subject-id">评估对象</label> <strong>新品风险参数</strong>
<span>系统将结合近 3 年异常案例计算七维安全评分</span>
</div>
<div class="form-grid">
<label class="field-wide">
评估对象 *
<input v-model.trim="form.subjectId" required maxlength="128" />
</label>
<label>
产品品类 *
<input <input
id="subject-id" v-model.trim="form.productCategory"
v-model.trim="form.subjectId"
required required
maxlength="128" maxlength="100"
placeholder="如:洗发水、面霜、脱毛膏"
/> />
</div> </label>
<label>
<div class="indicator-heading"> 包装材质 *
<div>
<strong>风险指标</strong>
<span>指标值范围 0-100</span>
</div>
<button
class="button button--secondary"
type="button"
@click="addIndicator"
>
<Plus :size="16" />
添加指标
</button>
</div>
<div class="indicator-table">
<div class="indicator-table__header" aria-hidden="true">
<span>指标名称</span>
<span>指标值</span>
<span>权重</span>
<span></span>
</div>
<div
v-for="(indicator, index) in form.indicators"
:key="index"
class="indicator-row"
>
<input <input
v-model.trim="indicator.name" v-model.trim="form.materialType"
required required
maxlength="64" maxlength="200"
aria-label="指标名称" placeholder="如:PET瓶、PE软管、泵头"
/> />
</label>
<label>
供应商
<input <input
v-model.number="indicator.value" v-model.trim="form.supplier"
required maxlength="300"
type="number" placeholder="可填写已知供应商或新供应商"
min="0"
max="100"
step="0.1"
aria-label="指标值"
/> />
</label>
<label>
加工厂
<input <input
v-model.number="indicator.weight" v-model.trim="form.factory"
required maxlength="300"
type="number" placeholder="如:博元、瑞沣"
min="0.1"
max="100"
step="0.1"
aria-label="权重"
/> />
<button </label>
class="icon-button" <label>
type="button" 开发阶段
title="删除指标" <select v-model="form.stageNew">
aria-label="删除指标" <option>第一阶段(需求发现)</option>
:disabled="form.indicators.length === 1" <option>第二阶段(产品立项)</option>
@click="removeIndicator(index)" <option>第三阶段(产品开发)</option>
> <option>第四阶段(量产上市)</option>
<Trash2 :size="16" /> <option>第五阶段(产品生命管理)</option>
</button> </select>
</label>
<label>
已知缺陷等级
<select v-model="form.defectLevel">
<option value="">暂无</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
</label>
</div> </div>
<div class="data-note">
<span class="data-note__dot"></span>
缺少巡检或检测判定数据的维度,将按 PRD 中性值 50 分计入并标注。
</div> </div>
<button <button
class="button button--primary submit-button" class="button button--primary submit-button"
type="submit" type="submit"
@ -129,99 +117,82 @@ function submit() {
<style scoped> <style scoped>
.assessment-form { .assessment-form {
display: grid; display: grid;
gap: 22px; gap: 20px;
} }
.form-intro {
.field-group,
.indicator-heading div {
display: grid; display: grid;
gap: 7px; gap: 3px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 14px;
} }
.form-intro strong {
label,
.indicator-heading strong {
color: var(--color-text); color: var(--color-text);
font-size: 13px; font-size: 15px;
font-weight: 700;
} }
.form-intro span {
.field-group input {
width: min(420px, 100%);
}
.indicator-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.indicator-heading span {
color: var(--color-text-muted);
font-size: 12px;
}
.indicator-table {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.indicator-table__header,
.indicator-row {
display: grid;
grid-template-columns:
minmax(180px, 1.5fr) minmax(100px, 0.7fr) minmax(90px, 0.6fr)
42px;
gap: 10px;
align-items: center;
padding: 10px 12px;
}
.indicator-table__header {
background: var(--color-surface-muted);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 11px; font-size: 11px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 15px;
}
label {
display: grid;
gap: 6px;
color: var(--color-text);
font-size: 12px;
font-weight: 700; font-weight: 700;
text-transform: uppercase;
} }
.field-wide {
.indicator-row + .indicator-row { grid-column: 1 / -1;
border-top: 1px solid var(--color-border);
} }
input,
.indicator-row input { select {
width: 100%; width: 100%;
min-height: 38px;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 0 10px;
background: white;
color: var(--color-text);
font-size: 13px;
}
input:focus,
select:focus {
border-color: var(--color-accent);
outline: 2px solid var(--color-accent-subtle);
outline-offset: 0;
}
.data-note {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid var(--color-warning-border);
border-radius: 5px;
padding: 9px 10px;
background: var(--color-warning-soft);
color: #92400e;
font-size: 10px;
line-height: 1.5;
}
.data-note__dot {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
background: var(--color-warning);
} }
.submit-button { .submit-button {
justify-self: start; justify-self: start;
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.indicator-heading { .form-grid {
align-items: stretch; grid-template-columns: 1fr;
flex-direction: column;
} }
.field-wide {
.indicator-table__header { grid-column: auto;
display: none;
}
.indicator-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px;
gap: 6px;
padding: 10px 8px;
}
.indicator-row input:first-child {
grid-column: 1 / 3;
}
.indicator-row .icon-button {
grid-column: 3;
grid-row: 1 / 3;
align-self: center;
} }
} }
</style> </style>

View File

@ -1,10 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { BarChart3 } from "@lucide/vue"; import { BarChart3 } from "@lucide/vue";
import { computed } from "vue";
import RiskBadge from "@/components/common/RiskBadge.vue"; import RiskBadge from "@/components/common/RiskBadge.vue";
import type { RiskAssessmentResult } from "@/types/risk"; import type { RiskAssessmentResult, RiskDimensionScore } from "@/types/risk";
defineProps<{ result: RiskAssessmentResult | null; error: string | null }>(); const props = defineProps<{
result: RiskAssessmentResult | null;
error: string | null;
}>();
const dimensions = computed<RiskDimensionScore[]>(() =>
props.result?.dimensionScores?.length
? props.result.dimensionScores
: (props.result?.factors ?? []).map((factor) => ({
dimension: factor.name,
score: Math.max(0, Math.round(100 - factor.value)),
weight: factor.weight,
})),
);
</script> </script>
<template> <template>
@ -32,18 +45,24 @@ defineProps<{ result: RiskAssessmentResult | null; error: string | null }>();
<dt>模型</dt> <dt>模型</dt>
<dd>{{ result.modelVersion }}</dd> <dd>{{ result.modelVersion }}</dd>
</div> </div>
<div>
<dt>数据口径</dt>
<dd>异常案例库 + 缺失值兜底</dd>
</div>
</dl> </dl>
<div class="factor-list"> <div class="factor-list">
<div <div
v-for="factor in result.factors" v-for="factor in dimensions"
:key="factor.name" :key="factor.dimension"
class="factor-row" class="factor-row"
:title="factor.reason || undefined"
> >
<span>{{ factor.name }}</span> <span>{{ factor.dimension }}</span>
<div class="factor-bar"> <div class="factor-bar">
<i :style="{ width: `${factor.value}%` }"></i> <i :style="{ width: `${factor.score}%` }"></i>
</div> </div>
<strong>{{ factor.contribution.toFixed(1) }}</strong> <strong>{{ Math.round(factor.score) }}<small>分</small></strong>
<em v-if="factor.status === 'default'">默认</em>
</div> </div>
</div> </div>
</div> </div>
@ -147,6 +166,22 @@ dd {
text-align: right; text-align: right;
} }
.factor-row > strong small {
margin-left: 2px;
color: var(--color-text-muted);
font-size: 10px;
font-weight: 500;
}
.factor-row > em {
border-radius: 3px;
padding: 2px 4px;
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: 9px;
font-style: normal;
}
.factor-bar { .factor-bar {
height: 6px; height: 6px;
overflow: hidden; overflow: hidden;

View File

@ -180,13 +180,17 @@ function formatMessageTime(value: string) {
function loadOlder() { function loadOlder() {
const node = scrollArea.value; const node = scrollArea.value;
const before = node?.scrollHeight ?? 0; const before = node?.scrollHeight ?? 0;
void conversationStore.loadOlder().then((loaded) => { const beforeTop = node?.scrollTop ?? 0;
if (loaded && node) node.scrollTop = node.scrollHeight - before; void conversationStore.loadOlder().then(async (loaded) => {
if (loaded && node) {
await nextTick();
node.scrollTop = node.scrollHeight - before + beforeTop;
}
}); });
} }
function handleScroll() { function handleScroll() {
if (scrollArea.value && scrollArea.value.scrollTop < 60) loadOlder(); if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
} }
async function openFromRoute() { async function openFromRoute() {
@ -249,14 +253,9 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
@scroll.passive="handleScroll" @scroll.passive="handleScroll"
> >
<div class="conversation-column"> <div class="conversation-column">
<button <p v-if="conversationStore.loadingOlder" class="history-loading">
v-if="conversation?.hasMoreMessages" 正在加载更早消息…
class="load-older" </p>
type="button"
@click="loadOlder"
>
{{ conversationStore.loadingOlder ? "加载中…" : "加载更早消息" }}
</button>
<AssistantWelcome <AssistantWelcome
v-if="!conversation?.messages.length" v-if="!conversation?.messages.length"
@select="usePrompt" @select="usePrompt"
@ -440,15 +439,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
gap: 18px; gap: 18px;
} }
.load-older { .history-loading {
align-self: center; align-self: center;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 6px 12px;
background: var(--color-surface);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 11px; font-size: 11px;
cursor: pointer; line-height: 1.4;
} }
.message { .message {

View File

@ -9,13 +9,29 @@ export interface RiskIndicator {
export interface RiskAssessmentRequest { export interface RiskAssessmentRequest {
subjectId: string; subjectId: string;
indicators: RiskIndicator[]; indicators?: RiskIndicator[];
productCategory?: string | null;
materialType?: string | null;
supplier?: string | null;
factory?: string | null;
stageNew?: string | null;
defectLevel?: string | null;
} }
export interface RiskFactorResult extends RiskIndicator { export interface RiskFactorResult extends RiskIndicator {
contribution: number; contribution: number;
} }
export interface RiskDimensionScore {
dimension: string;
score: number;
weight: number;
status?: string;
reason?: string | null;
sampleSize?: number | null;
rawValue?: number | null;
}
export interface RiskAssessmentResult { export interface RiskAssessmentResult {
subjectId: string; subjectId: string;
score: number; score: number;
@ -54,11 +70,7 @@ export interface RiskAssessmentResult {
source: string | null; source: string | null;
status: string; status: string;
}>; }>;
dimensionScores?: Array<{ dimensionScores?: RiskDimensionScore[];
dimension: string;
score: number;
weight: number;
}>;
} }
export interface ModelInfo { export interface ModelInfo {