dev #1
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user