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

- 展示数据库七维风险维度、默认值和数据说明\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,
ClipboardCheck,
PackageSearch,
Ruler,
Sparkles,
} from "@lucide/vue";
import { computed, ref } from "vue";
@ -196,8 +197,17 @@ function formatCount(value: unknown) {
<template v-else>
<div class="solution-base"><b>分析基础:</b>{{ data.analysisBase }}</div>
<section v-if="data.standards?.length" class="solution-section">
<h4><BookOpenCheck :size="14" /> 标准兜底校验</h4>
<section
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
v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`"
@ -210,6 +220,9 @@ function formatCount(value: unknown) {
</div>
<strong>{{ standard.status }}</strong>
</div>
<p v-if="!data.standards?.length" class="empty">
当前标准库暂无可匹配规则,请补充产品品类、包材或检测指标。
</p>
</section>
<section v-if="data.risks?.length" class="solution-section risk-list">
<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 feedback = ref("");
interface DimensionView {
name: string;
score: number;
weight: string;
tone: string;
status?: string;
reason?: string | null;
}
function notify(label: string) {
feedback.value = `${label}功能将在对应模块接入后执行`;
window.setTimeout(() => (feedback.value = ""), 2600);
@ -34,19 +43,29 @@ function copyScore() {
notify("评分已复制");
}
const dimensions = computed(() =>
const dimensions = computed<DimensionView[]>(() =>
(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) => ({
dimension: factor.name,
score: Math.max(0, Math.round(100 - factor.value)),
weight: factor.weight,
status: undefined,
reason: undefined,
}))
).map((item) => ({
name: item.dimension,
score: Math.round(item.score),
weight: `${item.weight}%`,
tone: item.score < 40 ? "danger" : item.score < 60 ? "warning" : "success",
status: item.status,
reason: item.reason,
})),
);
const suggestions = computed(() =>
@ -195,7 +214,8 @@ const stars = computed(() => {
</div>
<small
><b>{{ item.score }}</b
>分 · {{ item.weight }}</small
>分 · {{ item.weight }}
<em v-if="item.status === 'default'">默认</em></small
>
</div>
</div>

View File

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

View File

@ -1,10 +1,23 @@
<script setup lang="ts">
import { BarChart3 } from "@lucide/vue";
import { computed } from "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>
<template>
@ -32,18 +45,24 @@ defineProps<{ result: RiskAssessmentResult | null; error: string | null }>();
<dt>模型</dt>
<dd>{{ result.modelVersion }}</dd>
</div>
<div>
<dt>数据口径</dt>
<dd>异常案例库 + 缺失值兜底</dd>
</div>
</dl>
<div class="factor-list">
<div
v-for="factor in result.factors"
:key="factor.name"
v-for="factor in dimensions"
:key="factor.dimension"
class="factor-row"
:title="factor.reason || undefined"
>
<span>{{ factor.name }}</span>
<span>{{ factor.dimension }}</span>
<div class="factor-bar">
<i :style="{ width: `${factor.value}%` }"></i>
<i :style="{ width: `${factor.score}%` }"></i>
</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>
@ -147,6 +166,22 @@ dd {
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 {
height: 6px;
overflow: hidden;

View File

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

View File

@ -9,13 +9,29 @@ export interface RiskIndicator {
export interface RiskAssessmentRequest {
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 {
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 {
subjectId: string;
score: number;
@ -54,11 +70,7 @@ export interface RiskAssessmentResult {
source: string | null;
status: string;
}>;
dimensionScores?: Array<{
dimension: string;
score: number;
weight: number;
}>;
dimensionScores?: RiskDimensionScore[];
}
export interface ModelInfo {