Compare commits

...

32 Commits

Author SHA1 Message Date
94fc1ef2d3 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-09-21 10:50:45 +08:00
9359f11cd0 新增风险项撤回操作并优化队列布局 2026-09-18 17:31:14 +08:00
e172d82eb5 优化供应商评分说明并隐藏快捷入口 2026-09-18 15:21:49 +08:00
31e5325cc6 优化审核入口权限刷新逻辑 2026-09-18 13:46:14 +08:00
d04e8888cb 优化案例卡片和思考过程展示 2026-09-18 11:37:16 +08:00
7f74f5a041 规范风险报告生成与方案追加规则 2026-09-15 14:08:58 +08:00
173e05eda6 优化思考状态展开与收起交互 2026-09-15 13:35:36 +08:00
92a86efe73 优化报告信息与对话思考状态展示 2026-09-15 10:43:02 +08:00
b5484c8adb 优化快捷入口与风险项管理展示 2026-09-14 15:51:36 +08:00
6402aaf83e 支持删除历史会话 2026-09-13 17:36:22 +08:00
5582f168c2 修复钉钉用户自动免登与信息同步 2026-09-13 16:28:00 +08:00
73f574add4 支持多会话并发流式处理 2026-09-11 17:06:31 +08:00
8ccc9755cb 支持钉钉自动免登并统一风险预警品牌 2026-09-11 10:17:51 +08:00
fd26b766a3 优化供应商全称搜索与下拉展示 2026-09-10 17:33:54 +08:00
c2a0756d2f 修复报告会话关联与供应商搜索兼容性 2026-09-10 16:15:46 +08:00
763d8cc545 统一自然年度统计口径展示文案 2026-09-10 11:43:39 +08:00
f04aad9a59 修复顶部供应商与报告导航路由 2026-09-10 11:43:38 +08:00
71437dfa25 统一登录成功后进入智能对话页面 2026-09-10 11:43:38 +08:00
c502bc17c4 统一报告页面按类型追加内容 2026-09-09 17:36:57 +08:00
77741c619f 统一报告页面章节归并展示 2026-09-09 17:30:23 +08:00
8ed60807f9 按类型归并报告页面追加内容 2026-09-09 17:23:56 +08:00
343c04530c 兼容旧报告卡片并保留追加内容 2026-09-09 17:05:33 +08:00
ca9fc9ee9d 统一前端报告卡片展示与追加逻辑 2026-09-09 17:04:16 +08:00
d05be62e02 优化供应商搜索下拉与卡片操作
供应商画像选择器改为支持输入筛选的动态下拉框,输入关键词实时匹配并刷新画像;删除暂未实现的收藏方案按钮,保留已接入操作。
2026-09-09 15:51:43 +08:00
69a1433a86 完善 AI 卡片交互与会话消息控制
实现 AI 案例推理方案生成报告并追加到当前会话报告;查看标准详情跳转标准库;优化标准兜底和相似案例展示;新增停止生成、撤回问题及对应回答功能,并将未接入按钮置灰隐藏。
2026-09-09 15:40:21 +08:00
74a1c9d4bc 统一供应商画像展示并优化页面加载速度
供应商画像直接展示后端统一计算的基础分、频次风险系数和供应商得分;对齐对话卡片数据;未归类品类固定灰色展示并保留待补标记录;隐藏无推荐数据的代工厂模块;增加页面缓存、资源预加载、接口缓存和重复请求去重。
2026-09-09 10:46:14 +08:00
904198ae8b 完善前端供应商画像和标准联动展示 2026-09-08 18:27:34 +08:00
1cc6b471d3 按原型统一风险报告页面与下载内容
完善报告页栏目和原型样式,统一风险卡片、网页报告、PDF与Word的字段口径;接入真实PDF和DOCX下载,修复合作频次、七维结论及标准判定展示差异。
2026-09-08 11:59:10 +08:00
5397d128be 接入OCR录入并完善角色权限界面 2026-09-07 17:54:57 +08:00
ab20424aa9 完善供应商频次卡片展示
- 展示近3年异常次数和合作频次代理值
- 增加频次口径和无供应商数据提示
- 兼容旧历史风险卡片数据
- 保留历史会话加载超时和流式响应处理优化
2026-09-07 13:53:29 +08:00
f0a940eb10 恢复相似案例卡片优化展示
- 恢复 TOP 1 至 TOP 5 的独立标签布局\n- 恢复案例长文本自动换行和相似度展示\n- 优化移动端相似案例布局\n- 保持 AI 预防建议基础展示格式不变
2026-09-05 19:00:48 +08:00
32739944c7 优化风险卡片与历史会话加载
- 展示数据库七维风险维度、默认值和数据说明\n- 增加标准兜底校验卡片及无历史案例提示\n- 支持 AI 风险建议和案例推理卡片的新数据结构\n- 进入会话默认定位最新内容\n- 上划到顶部自动加载更早的 20 条消息并保持阅读位置\n- 移除手动加载更早消息按钮\n- 更新风险评估表单和前端类型定义
2026-09-04 18:02:39 +08:00
45 changed files with 6726 additions and 663 deletions

View File

@ -4,11 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17201d" /> <meta name="theme-color" content="#17201d" />
<title>蝶雅风险预测</title> <title>蝶雅风险预警助手</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@ -2,6 +2,7 @@
import { onMounted, onUnmounted } from "vue"; import { onMounted, onUnmounted } from "vue";
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import { isDingTalkContainer } from "@/api/dingtalk";
import router from "@/router"; import router from "@/router";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
@ -17,9 +18,28 @@ function handleAuthExpired() {
} }
} }
onMounted(() => async function syncDingTalkUser() {
window.addEventListener("risk-auth-expired", handleAuthExpired), if (
); !isDingTalkContainer() ||
!auth.user?.dingtalkId ||
auth.user.dingtalkId.startsWith("local-")
) {
return;
}
try {
await auth.dingTalkLogin(true);
} catch {
// A stale token must remain usable if DingTalk is temporarily unavailable.
}
}
onMounted(async () => {
window.addEventListener("risk-auth-expired", handleAuthExpired);
// Restore /auth/me first: on a hard refresh auth.user starts as null, so a
// synchronous check would skip the DingTalk profile resync entirely.
if (auth.token && !auth.user) await auth.restore();
await syncDingTalkUser();
});
onUnmounted(() => onUnmounted(() =>
window.removeEventListener("risk-auth-expired", handleAuthExpired), window.removeEventListener("risk-auth-expired", handleAuthExpired),
); );

View File

@ -1,6 +1,6 @@
import { http } from "@/api/http"; import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type { Conversation, ConversationSummary } from "@/types/conversation"; import type { Conversation, ConversationSummary } from "@/types/conversation";
import type { ApiResponse } from "@/types/api";
export interface ConversationStreamEvent { export interface ConversationStreamEvent {
type: "start" | "semantic" | "delta" | "done" | "error"; type: "start" | "semantic" | "delta" | "done" | "error";
@ -12,6 +12,7 @@ export interface ConversationStreamEvent {
export function fetchConversations(limit = 50, search?: string) { export function fetchConversations(limit = 50, search?: string) {
return http.get<ApiResponse<ConversationSummary[]>>("/v1/conversations", { return http.get<ApiResponse<ConversationSummary[]>>("/v1/conversations", {
params: { limit, search }, params: { limit, search },
timeout: 30_000,
}); });
} }
@ -21,12 +22,21 @@ export function createConversation(title?: string) {
}); });
} }
export function directConversationQuery(kind: "supplier" | "material" | "cases" | "solution", query: string) {
return http.post<ApiResponse<Record<string, unknown>>>("/v1/conversations/direct-query", { kind, query });
}
export function fetchConversation(id: number, offset = 0) { export function fetchConversation(id: number, offset = 0) {
return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, { return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, {
params: { limit: 20, offset }, params: { limit: 20, offset },
timeout: 60_000,
}); });
} }
export function deleteConversation(id: number) {
return http.delete<ApiResponse<null>>(`/v1/conversations/${id}`);
}
export function sendConversationMessage( export function sendConversationMessage(
id: number, id: number,
content: string, content: string,
@ -38,14 +48,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 response = await fetch( 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);
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",
@ -55,8 +82,13 @@ export async function streamConversationMessage(
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ content, messageType }), body: JSON.stringify({ content, messageType }),
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"));
@ -64,6 +96,7 @@ export async function streamConversationMessage(
if (!response.ok || !response.body) if (!response.ok || !response.body)
throw new Error(`流式请求失败(HTTP ${response.status})`); throw new Error(`流式请求失败(HTTP ${response.status})`);
try {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
@ -78,7 +111,12 @@ export async function streamConversationMessage(
if (!line.startsWith("data:")) continue; if (!line.startsWith("data:")) continue;
const raw = line.slice(5).trim(); const raw = line.slice(5).trim();
if (!raw) continue; if (!raw) continue;
const event = JSON.parse(raw) as ConversationStreamEvent; let event: ConversationStreamEvent;
try {
event = JSON.parse(raw) as ConversationStreamEvent;
} catch {
throw new Error("AI 流式响应格式错误,请重试");
}
onEvent(event); onEvent(event);
if (event.type === "done") completed = true; if (event.type === "done") completed = true;
} }
@ -86,10 +124,27 @@ export async function streamConversationMessage(
if (buffer.startsWith("data:")) { if (buffer.startsWith("data:")) {
const raw = buffer.slice(5).trim(); const raw = buffer.slice(5).trim();
if (raw) { if (raw) {
const event = JSON.parse(raw) as ConversationStreamEvent; let event: ConversationStreamEvent;
try {
event = JSON.parse(raw) as ConversationStreamEvent;
} catch {
throw new Error("AI 流式响应格式错误,请重试");
}
onEvent(event); onEvent(event);
if (event.type === "done") completed = true; if (event.type === "done") completed = true;
} }
} }
if (!completed) throw new Error("AI 流式连接中断,请重试"); if (!completed) throw new Error("AI 流式连接中断,请重试");
} catch (reason) {
if (signal?.aborted) {
throw new Error("已停止生成", { cause: reason });
}
if (controller.signal.aborted) {
throw new Error("AI 响应超时,请稍后重试", { cause: reason });
}
throw reason;
} finally {
window.clearTimeout(timeout);
signal?.removeEventListener("abort", abortFromCaller);
}
} }

View File

@ -3,14 +3,43 @@ import dd from "dingtalk-jsapi";
import { getDingTalkConfig, loginWithDingTalk } from "@/api/auth"; import { getDingTalkConfig, loginWithDingTalk } from "@/api/auth";
import type { AuthTokenData } from "@/types/auth"; import type { AuthTokenData } from "@/types/auth";
function isDingTalkContainer() { declare global {
return ( interface Window {
typeof navigator !== "undefined" && /DingTalk/i.test(navigator.userAgent) dingtalk?: { platform?: { invokeAPI?: unknown } };
DingTalkJSBridge?: unknown;
}
}
export function isDingTalkContainer() {
if (typeof window === "undefined" || typeof navigator === "undefined")
return false;
const userAgent =
navigator.userAgent ||
(navigator as Navigator & { swuserAgent?: string }).swuserAgent ||
"";
return Boolean(
/DingTalk|dingtalk-win|dd-web/i.test(userAgent) ||
window.DingTalkJSBridge ||
window.dingtalk?.platform?.invokeAPI ||
// DingTalk desktop can expose its container identity through window.name
// even when the embedded browser omits DingTalk from userAgent.
/dingtalk/i.test(window.name || ""),
); );
} }
export async function waitForDingTalkContainer(timeoutMs = 5000) {
if (isDingTalkContainer()) return true;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => window.setTimeout(resolve, 100));
if (isDingTalkContainer()) return true;
}
return false;
}
export async function authenticateWithDingTalk(): Promise<AuthTokenData> { export async function authenticateWithDingTalk(): Promise<AuthTokenData> {
if (!isDingTalkContainer()) throw new Error("请从钉钉工作台打开此应用"); if (!(await waitForDingTalkContainer()))
throw new Error("请从钉钉工作台打开此应用");
const configResponse = await getDingTalkConfig(); const configResponse = await getDingTalkConfig();
const config = configResponse.data.data; const config = configResponse.data.data;
if (!config) throw new Error("钉钉免登配置不完整"); if (!config) throw new Error("钉钉免登配置不完整");

17
src/api/modelConfig.ts Normal file
View File

@ -0,0 +1,17 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
ModelWeightConfig,
ModelWeightConfigUpdate,
} from "@/types/modelConfig";
export function fetchModelConfig() {
return http.get<ApiResponse<ModelWeightConfig>>("/v1/model-config");
}
export function updateModelConfig(payload: ModelWeightConfigUpdate) {
return http.patch<ApiResponse<ModelWeightConfig>>(
"/v1/model-config",
payload,
);
}

9
src/api/monitoring.ts Normal file
View File

@ -0,0 +1,9 @@
import { http } from "@/api/http";
export function addRiskMonitor(payload: { subjectId: string; score: number; level: string }) {
return http.post("/v1/monitoring/risk", {
subjectId: payload.subjectId,
score: payload.score,
level: payload.level,
});
}

31
src/api/ocr.ts Normal file
View File

@ -0,0 +1,31 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
OcrBatchResult,
OcrMeasurement,
OcrStandardJudgment,
} from "@/types/ocr";
export function recognizeOcrFiles(files: File[]) {
const body = new FormData();
files.forEach((file) => body.append("files", file));
return http.post<ApiResponse<OcrBatchResult>>("/v1/ocr/recognize", body, {
timeout: 180_000,
});
}
export function judgeOcrMeasurements(measurements: OcrMeasurement[]) {
return http.post<ApiResponse<OcrStandardJudgment[]>>("/v1/ocr/judge", {
measurements,
});
}
export function ocrFileUrl(fileId: string) {
return `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/ocr/files/${encodeURIComponent(fileId)}`;
}
export function downloadOcrFile(fileId: string) {
return http.get(`/v1/ocr/files/${encodeURIComponent(fileId)}`, {
responseType: "blob",
});
}

16
src/api/reports.ts Normal file
View File

@ -0,0 +1,16 @@
import { http } from "@/api/http";
import type { RiskReportResult } from "@/types/risk";
export function downloadReportPdf(payload: RiskReportResult) {
return http.post<Blob>("/v1/reports/pdf", payload, {
responseType: "blob",
timeout: 60_000,
});
}
export function downloadReportWord(payload: RiskReportResult) {
return http.post<Blob>("/v1/reports/word", payload, {
responseType: "blob",
timeout: 60_000,
});
}

View File

@ -28,6 +28,10 @@ export function updateRiskItem(itemId: number, payload: RiskItemUpdate) {
return http.patch<ApiResponse<RiskItem>>(`/v1/risk-items/${itemId}`, payload); return http.patch<ApiResponse<RiskItem>>(`/v1/risk-items/${itemId}`, payload);
} }
export function withdrawRiskItem(itemId: number) {
return http.post<ApiResponse<null>>(`/v1/risk-items/${itemId}/withdraw`);
}
export function reviewRiskItem(itemId: number, payload: ReviewAction) { export function reviewRiskItem(itemId: number, payload: ReviewAction) {
return http.post<ApiResponse<ReviewRecord>>( return http.post<ApiResponse<ReviewRecord>>(
`/v1/risk-items/${itemId}/review`, `/v1/risk-items/${itemId}/review`,

View File

@ -0,0 +1,39 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type { SupplierProfile } from "@/types/supplierProfile";
const CACHE_TTL_MS = 30_000;
const cache = new Map<string, { data: SupplierProfile; at: number }>();
const pending = new Map<string, Promise<SupplierProfile>>();
export async function fetchSupplierProfile(
name: string,
): Promise<{ data: { data: SupplierProfile | null } }> {
const key = name.trim();
const cached = cache.get(key);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
return { data: { data: cached.data } };
}
const running = pending.get(key);
if (running) return { data: { data: await running } };
const request = (async () => {
const response = await http.get<ApiResponse<SupplierProfile>>(
"/v1/suppliers/profile",
{ params: { supplier_name: key } },
);
if (!response.data.data) throw new Error("供应商画像响应为空");
cache.set(key, { data: response.data.data, at: Date.now() });
return response.data.data;
})();
pending.set(key, request);
try {
return { data: { data: await request } };
} finally {
pending.delete(key);
}
}
export function clearSupplierProfileCache(name?: string) {
if (name) cache.delete(name.trim());
else cache.clear();
}

48
src/api/suppliers.ts Normal file
View File

@ -0,0 +1,48 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
export interface SupplierOption {
name: string;
anomalyCount: number;
tier: string;
city?: string | null;
province?: string | null;
shortName?: string | null;
reviewStatus?: string | null;
}
export interface SupplierListData {
items: SupplierOption[];
defaultSupplier: string | null;
}
const CACHE_TTL_MS = 30_000;
let cached: { data: SupplierListData; at: number } | null = null;
let pending: Promise<SupplierListData> | null = null;
export async function fetchSupplierOptions(
keyword?: string,
): Promise<{ data: { data: SupplierListData } }> {
const normalized = keyword?.trim() || "";
if (!normalized && cached && Date.now() - cached.at < CACHE_TTL_MS)
return { data: { data: cached.data } };
if (!normalized && pending) return { data: { data: await pending } };
const request = (async () => {
const response = await http.get<ApiResponse<SupplierListData>>(
"/v1/suppliers",
{ params: { keyword: normalized || undefined, limit: 8 } },
);
if (!response.data.data) throw new Error("供应商列表响应为空");
if (!normalized) cached = { data: response.data.data, at: Date.now() };
return response.data.data;
})();
if (!normalized) pending = request;
try {
return { data: { data: await request } };
} finally {
if (!normalized) pending = null;
}
}
export function clearSupplierOptionsCache() {
cached = null;
}

View File

@ -11,7 +11,16 @@ import {
Search, Search,
} from "@lucide/vue"; } from "@lucide/vue";
defineProps<{ modelValue: string; loading: boolean }>(); import { ref } from "vue";
import { featureFlags } from "@/config/features";
const props = defineProps<{
modelValue: string;
loading: boolean;
canUpload: boolean;
canReview: boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
"update:modelValue": [value: string]; "update:modelValue": [value: string];
@ -29,6 +38,13 @@ const emit = defineEmits<{
| "voice", | "voice",
]; ];
}>(); }>();
const textarea = ref<HTMLTextAreaElement | null>(null);
function focus() {
textarea.value?.focus();
}
defineExpose({ focus });
const functions: Array<{ const functions: Array<{
title: string; title: string;
@ -36,6 +52,7 @@ const functions: Array<{
icon: typeof Search; icon: typeof Search;
tone: string; tone: string;
prompt?: string; prompt?: string;
isNew?: boolean;
tool: tool:
"assessment" | "supplier" | "material" | "cases" | "solution" | "review"; "assessment" | "supplier" | "material" | "cases" | "solution" | "review";
}> = [ }> = [
@ -78,6 +95,7 @@ const functions: Array<{
tone: "purple", tone: "purple",
prompt: "帮我生成一个新品风险预防方案", prompt: "帮我生成一个新品风险预防方案",
tool: "solution", tool: "solution",
isNew: true,
}, },
{ {
title: "审核队列", title: "审核队列",
@ -85,6 +103,7 @@ const functions: Array<{
icon: ClipboardCheck, icon: ClipboardCheck,
tone: "red", tone: "red",
tool: "review", tool: "review",
isNew: true,
}, },
]; ];
</script> </script>
@ -93,6 +112,7 @@ const functions: Array<{
<div class="composer-wrap"> <div class="composer-wrap">
<form class="composer" @submit.prevent="emit('submit')"> <form class="composer" @submit.prevent="emit('submit')">
<button <button
v-if="featureFlags.voiceEntry"
class="composer-icon" class="composer-icon"
type="button" type="button"
title="语音录入" title="语音录入"
@ -102,6 +122,7 @@ const functions: Array<{
<Mic :size="16" /> <Mic :size="16" />
</button> </button>
<button <button
v-if="canUpload"
class="composer-icon" class="composer-icon"
type="button" type="button"
title="拍照识别" title="拍照识别"
@ -111,6 +132,7 @@ const functions: Array<{
<Camera :size="16" /> <Camera :size="16" />
</button> </button>
<textarea <textarea
ref="textarea"
:value="modelValue" :value="modelValue"
rows="1" rows="1"
placeholder="输入产品参数或问题…" placeholder="输入产品参数或问题…"
@ -136,20 +158,22 @@ const functions: Array<{
</form> </form>
<div class="function-grid"> <div class="function-grid">
<template v-for="item in functions" :key="item.title">
<button <button
v-for="item in functions" v-if="item.tool !== 'review' || props.canReview"
:key="item.title"
class="function-card" class="function-card"
type="button" type="button"
@click="emit('tool', item.tool)" @click="emit('tool', item.tool)"
> >
<span class="function-dot" :class="`function-dot--${item.tone}`"></span> <span class="function-dot" :class="`function-dot--${item.tone}`"></span>
<component :is="item.icon" :size="13" /> <component :is="item.icon" :size="13" />
<span v-if="item.isNew" class="function-new">新</span>
<span <span
><strong>{{ item.title }}</strong ><strong>{{ item.title }}</strong
><small>{{ item.description }}</small></span ><small>{{ item.description }}</small></span
> >
</button> </button>
</template>
</div> </div>
</div> </div>
</template> </template>
@ -235,8 +259,8 @@ const functions: Array<{
.function-grid { .function-grid {
display: grid; display: grid;
max-width: var(--content-width); max-width: var(--content-width);
margin: 8px auto 0; margin: 10px auto 0;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px; gap: 6px;
} }
@ -246,18 +270,22 @@ const functions: Array<{
min-height: 44px; min-height: 44px;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 9px; border-radius: 8px;
padding: 7px 8px; padding: 8px 10px;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-align: left; text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
} }
.function-card:hover { .function-card:hover {
border-color: var(--color-border-strong); border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transform: translateY(-1px);
} }
.function-card > span:last-child { .function-card > span:last-child {
@ -274,12 +302,26 @@ const functions: Array<{
.function-card strong { .function-card strong {
color: var(--color-text); color: var(--color-text);
font-size: 10px; font-size: 11px;
font-weight: 600;
} }
.function-card small { .function-card small {
color: var(--color-text-faint); color: var(--color-text-faint);
font-size: 9px;
margin-top: 1px;
}
.function-new {
position: absolute;
top: 2px;
right: 2px;
border-radius: 3px;
padding: 0 4px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 8px; font-size: 8px;
font-weight: 700;
} }
.function-dot { .function-dot {

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,15 @@ import {
Scale, Scale,
Search, Search,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed } from "vue";
const emit = defineEmits<{ select: [prompt: string] }>(); import { featureFlags } from "@/config/features";
const emit = defineEmits<{
select: [prompt: string];
tool: [value: "assessment" | "ocr"];
}>();
const props = defineProps<{ canUpload: boolean }>();
const actions = [ const actions = [
{ {
@ -19,6 +26,7 @@ const actions = [
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元", prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
icon: Search, icon: Search,
tone: "blue", tone: "blue",
tool: "assessment" as const,
}, },
{ {
title: "查供应商", title: "查供应商",
@ -64,7 +72,11 @@ const actions = [
prompt: "请帮我识别检测报告并进行风险判定", prompt: "请帮我识别检测报告并进行风险判定",
icon: Camera, icon: Camera,
tone: "sky", tone: "sky",
requiresUpload: true,
tool: "ocr" as const,
}, },
...(featureFlags.voiceEntry
? [
{ {
title: "语音录入异常", title: "语音录入异常",
description: "口述信息自动结构化", description: "口述信息自动结构化",
@ -72,25 +84,43 @@ const actions = [
icon: Mic, icon: Mic,
tone: "purple", tone: "purple",
}, },
]
: []),
]; ];
const visibleActions = computed(() =>
actions.filter(
(action) =>
!("requiresUpload" in action) ||
!action.requiresUpload ||
props.canUpload,
),
);
function handleAction(action: (typeof actions)[number]) {
if ("tool" in action && action.tool) {
emit("tool", action.tool);
return;
}
emit("select", action.prompt);
}
</script> </script>
<template> <template>
<section class="welcome"> <section class="welcome">
<div class="welcome__mark">蝶</div> <div class="welcome__mark">蝶</div>
<h1>你好,我是风险预估助手 v2.3</h1> <h1>你好,我是风险预警助手</h1>
<p> <p>
双引擎风险预估:历史异常经验引擎 + 双引擎风险预警:历史异常经验引擎 +
判定标准规则引擎,帮助你在立项、开发和量产阶段提前识别风险。 判定标准规则引擎,帮助你在立项、开发和量产阶段提前识别风险。
</p> </p>
<div class="welcome-grid"> <div class="welcome-grid">
<button <button
v-for="action in actions" v-for="action in visibleActions"
:key="action.title" :key="action.title"
class="welcome-action" class="welcome-action"
type="button" type="button"
@click="emit('select', action.prompt)" @click="handleAction(action)"
> >
<span class="welcome-action__icon" :class="`tone-${action.tone}`"> <span class="welcome-action__icon" :class="`tone-${action.tone}`">
<component :is="action.icon" :size="16" /> <component :is="action.icon" :size="16" />

View File

@ -11,6 +11,7 @@ import {
Sparkles, Sparkles,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import type { RiskAssessmentResult } from "@/types/risk"; import type { RiskAssessmentResult } from "@/types/risk";
import { formatRiskLevel } from "@/utils/risk"; import { formatRiskLevel } from "@/utils/risk";
@ -19,8 +20,22 @@ const props = defineProps<{
result: RiskAssessmentResult; result: RiskAssessmentResult;
canMonitor?: boolean; canMonitor?: boolean;
}>(); }>();
const router = useRouter();
type SimilarCase = NonNullable<RiskAssessmentResult["similarCases"]>[number];
const emit = defineEmits<{ generateReport: [] }>();
const expanded = ref(true); const expanded = ref(true);
const evidenceExpanded = ref(true);
const feedback = ref(""); const feedback = ref("");
const selectedCase = ref<SimilarCase | null>(null);
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}功能将在对应模块接入后执行`;
@ -34,19 +49,35 @@ function copyScore() {
notify("评分已复制"); notify("评分已复制");
} }
const dimensions = computed(() => function openCase(item?: SimilarCase) {
const target = item ?? props.result.similarCases?.[0];
if (target) selectedCase.value = target;
else notify("案例详情");
}
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(() =>
@ -58,6 +89,42 @@ const stars = computed(() => {
const filled = Math.max(1, Math.min(5, Math.round(props.result.score / 20))); const filled = Math.max(1, Math.min(5, Math.round(props.result.score / 20)));
return "★".repeat(filled) + "☆".repeat(5 - filled); return "★".repeat(filled) + "☆".repeat(5 - filled);
}); });
const supplierDimension = computed(() =>
props.result.dimensionScores?.find((item) => item.dimension === "供应商表现"),
);
const formulaText = computed(() => {
const source = props.result.dimensionScores?.length
? props.result.dimensionScores
: props.result.factors.map((factor) => ({
dimension: factor.name,
score: 100 - factor.value,
weight: factor.weight,
}));
if (!source.length) return "暂无七维评分明细";
return `${source.map((item) => `${(item.weight / 100).toFixed(2)}×${Math.round(item.score)}`).join(" + ")} ≈ ${Math.round(props.result.score)}`;
});
const standardCategories = computed(() => [
...new Set(
(props.result.standardJudgment ?? []).map((item) => item.category),
),
]);
function openStandards(category = "all", keyword?: string) {
void router.push({
name: "standards",
query: {
category,
...(keyword ? { keyword } : {}),
},
});
}
function openSupplier() {
const supplier = props.result.supplierFreq?.supplierName;
void router.push({
name: "supplier",
query: supplier ? { supplier } : undefined,
});
}
</script> </script>
<template> <template>
@ -118,44 +185,88 @@ const stars = computed(() => {
<div v-if="result.categoryWarning?.suggestion"> <div v-if="result.categoryWarning?.suggestion">
<span>建议</span>{{ result.categoryWarning.suggestion }} <span>建议</span>{{ result.categoryWarning.suggestion }}
</div> </div>
<div v-if="result.categoryWarning?.similarCategory">
<span>相似: {{ result.categoryWarning.similarCategory }}</span>
历史异常
{{ result.categoryWarning.similarCount ?? 0 }}
条,用于当前品类案例不足时辅助预判
</div>
</section> </section>
<section class="supplier-analysis"> <section class="supplier-analysis">
<h3><Factory :size="14" /> 供应商合作频次分析</h3> <h3><Factory :size="14" /> 供应商合作频次分析</h3>
<div v-if="result.supplierFreq?.supplierName"> <template
v-if="
result.supplierFreq?.dataAvailable ||
result.supplierFreq?.supplierName
"
>
<div v-if="result.supplierFreq.supplierName">
<span>{{ result.supplierFreq.supplierName }}</span> <span>{{ result.supplierFreq.supplierName }}</span>
<strong>异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong> <strong
>近3个自然年度异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
>
</div> </div>
<div v-if="result.supplierFreq"> <div v-if="result.supplierFreq.alpha != null">
<span>频次风险系数</span <span>频次风险系数</span
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong> ><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
</div> </div>
<div v-if="result.supplierFreq?.tier"> <div v-if="supplierDimension">
<span>供应商层级</span><strong>{{ result.supplierFreq.tier }}</strong> <span>供应商得分 S供</span
><strong>{{ Math.round(supplierDimension.score) }} 分</strong>
</div> </div>
<div> <div v-if="result.supplierFreq.tier">
<span>合作次数</span <span>供应商层级</span
><strong>{{ ><strong>{{ result.supplierFreq.tier }}</strong>
result.supplierFreq?.cooperationCount ?? "暂无数据"
}}</strong>
</div> </div>
<div v-if="result.supplierFreq?.suggestion"> <div v-if="result.supplierFreq.cooperationCount != null">
<span>建议</span>{{ result.supplierFreq.suggestion }} <span>合作频次(采购订单)</span>
<strong>{{ result.supplierFreq.cooperationCount }} 次</strong>
</div> </div>
<div v-if="result.supplierFreq.suggestion">
<span>管理建议</span>{{ result.supplierFreq.suggestion }}
</div>
<div v-if="supplierDimension?.reason" class="supplier-calculation">
<span>计算依据</span>{{ supplierDimension.reason }}
</div>
</template>
<p v-else class="supplier-analysis__empty">
{{
result.supplierFreq?.note || "未指定供应商,暂无供应商频次分析。"
}}
</p>
<small
v-if="
result.supplierFreq?.dataAvailable ||
result.supplierFreq?.supplierName
"
class="supplier-analysis__note"
>
{{
result.supplierFreq.note ||
"合作频次按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例库统计。"
}}
</small>
</section> </section>
<section class="standards-card"> <section class="standards-card">
<h3><BookOpenCheck :size="14" /> 标准判定 · 双引擎</h3> <h3><BookOpenCheck :size="14" /> 标准判定 · 双引擎</h3>
<p v-if="result.standardJudgment?.length" class="standards-card__intro">
命中判定标准库 {{ result.standardJudgment.length }} 条 · 覆盖
{{ standardCategories.join(" / ") }} · 来源:《标准文件.xlsx》219 条
</p>
<p <p
v-if="!result.standardJudgment?.length" v-if="!result.standardJudgment?.length"
class="standards-card__empty" class="standards-card__empty"
> >
暂无相关标准 暂无相关标准
</p> </p>
<div <button
v-for="standard in result.standardJudgment" v-for="standard in result.standardJudgment"
:key="`${standard.category}-${standard.riskPoint}`" :key="`${standard.category}-${standard.riskPoint}`"
class="standard-row" class="standard-row"
type="button"
@click="openStandards(standard.category, standard.riskPoint)"
> >
<span <span
class="standard-tag" class="standard-tag"
@ -169,12 +280,23 @@ const stars = computed(() => {
{{ standard.source || "标准文件.xlsx" }}</small {{ standard.source || "标准文件.xlsx" }}</small
> >
</div> </div>
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{ </button>
standard.status
}}</strong>
</div>
<footer> <footer>
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。 <span class="standards-card__engine-icon">↻</span>
<span
><b>双引擎融合:</b
>历史数据(经验引擎)提供风险优先级与相似案例;标准库(规则引擎)给出量化阈值与检测依据。</span
>
<span class="standards-card__links">
<button
v-for="category in standardCategories"
:key="category"
type="button"
@click="openStandards(category)"
>
查看{{ category }}标准
</button>
</span>
</footer> </footer>
</section> </section>
@ -195,7 +317,56 @@ 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 class="formula-strip">
R = {{ formulaText }}({{ formatRiskLevel(result.level) }})
</div>
</section>
<section class="evidence-panel">
<button
type="button"
class="evidence-panel__head"
@click="evidenceExpanded = !evidenceExpanded"
>
<span>🧠 推理过程(思维链)</span><small>检索→匹配→评分→生成</small>
<ChevronUp v-if="evidenceExpanded" :size="14" /><ChevronDown
v-else
:size="14"
/>
</button>
<div v-if="evidenceExpanded" class="evidence-panel__body">
<div>
<b>① 向量检索</b
><span
>新品参数嵌入 → 召回历史案例 → 余弦相似度与业务字段混合排序,返回
Top {{ result.similarCases?.length ?? 0 }} 条。</span
>
</div>
<div>
<b>② 相似度加权</b
><span
>按相似度和时间衰减因子 D(t)
对候选案例排序,优先展示高相关案例。</span
>
</div>
<div>
<b>③ 七维评分</b
><span
>{{ formulaText }},映射为{{
formatRiskLevel(result.level)
}}。</span
>
</div>
<div>
<b>④ 建议生成</b
><span
>聚合相似案例方案,结合标准规则生成
{{ suggestions.length }} 条可执行建议。</span
> >
</div> </div>
</div> </div>
@ -217,12 +388,20 @@ const stars = computed(() => {
:key="`${item.date}-${index}`" :key="`${item.date}-${index}`"
class="case-row" class="case-row"
type="button" type="button"
@click="openCase(item)"
> >
<span>TOP {{ index + 1 }}</span> <span class="case-rank">TOP {{ index + 1 }}</span>
<small <div class="case-content">
>{{ item.date }} · {{ item.product }} · {{ item.problem }} → <small>
{{ item.solution }}</small {{ item.date || "日期未知" }} ·
{{ item.product || "未命名产品" }} ·
{{ item.problem || "异常问题未填写" }} →
{{ item.solution || "解决方案未填写" }}
<em v-if="item.similarity != null"
>相似度 {{ Math.round(item.similarity * 100) }}%</em
> >
</small>
</div>
</button> </button>
</section> </section>
@ -239,18 +418,99 @@ const stars = computed(() => {
<button <button
class="primary-action" class="primary-action"
type="button" type="button"
@click="notify('生成报告')" @click="emit('generateReport')"
> >
<FileDown :size="14" /> 生成报告 <FileDown :size="14" /> 生成报告
</button> </button>
<button type="button" @click="notify('案例详情')">案例详情</button> <button type="button" @click="openCase()">案例详情</button>
<button type="button" @click="notify('供应商对比')">供应商对比</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>
</template> </template>
<p v-if="feedback" class="risk-feedback">{{ feedback }}</p> <p v-if="feedback" class="risk-feedback">{{ feedback }}</p>
<div
v-if="selectedCase"
class="case-modal-backdrop"
@click.self="selectedCase = null"
>
<section
class="case-modal"
role="dialog"
aria-modal="true"
aria-labelledby="case-detail-title"
>
<header class="case-modal__header">
<div>
<span class="case-modal__eyebrow">SIMILAR CASE DETAIL</span>
<h3 id="case-detail-title">案例详情</h3>
</div>
<button
type="button"
class="case-modal__close"
aria-label="关闭案例详情"
@click="selectedCase = null"
>
×
</button>
</header>
<div class="case-modal__summary">
<strong>{{ selectedCase.product || "未命名产品" }}</strong>
<span v-if="selectedCase.similarity != null"
>相似度 {{ Math.round(selectedCase.similarity * 100) }}%</span
>
</div>
<dl class="case-modal__grid">
<div>
<dt>发生日期</dt>
<dd>{{ selectedCase.date || "暂无数据" }}</dd>
</div>
<div>
<dt>产品品类</dt>
<dd>{{ selectedCase.category || "暂无数据" }}</dd>
</div>
<div>
<dt>物料类型</dt>
<dd>{{ selectedCase.material || "暂无数据" }}</dd>
</div>
<div>
<dt>缺陷等级</dt>
<dd>{{ selectedCase.defectLevel || "暂无数据" }}</dd>
</div>
<div class="case-modal__field--wide">
<dt>异常问题</dt>
<dd>
{{
selectedCase.problem || selectedCase.description || "暂无数据"
}}
</dd>
</div>
<div class="case-modal__field--wide">
<dt>根因分析</dt>
<dd>{{ selectedCase.rootCause || "暂无数据" }}</dd>
</div>
<div class="case-modal__field--wide">
<dt>解决方案</dt>
<dd>{{ selectedCase.solution || "暂无数据" }}</dd>
</div>
<div class="case-modal__field--wide">
<dt>处理结果</dt>
<dd>{{ selectedCase.result || "暂无数据" }}</dd>
</div>
</dl>
<footer class="case-modal__footer">
来源:历史异常案例库 · 案例编号:{{
selectedCase.caseId || "暂无数据"
}}
</footer>
</section>
</div>
</article> </article>
</template> </template>
@ -479,6 +739,24 @@ h3 {
color: #4c1d95; color: #4c1d95;
text-align: right; text-align: right;
} }
.supplier-calculation {
align-items: flex-start !important;
margin-top: 5px;
border-top: 1px dashed #ddd6fe;
padding-top: 7px !important;
color: var(--color-text-muted) !important;
font-size: 10px !important;
line-height: 1.5;
}
.supplier-analysis__empty,
.supplier-analysis__note {
display: block;
margin: 4px 0 0;
color: #6d5a91;
font-size: 10px;
line-height: 1.5;
}
.standards-card { .standards-card {
border: 1px solid #bfdbfe; border: 1px solid #bfdbfe;
@ -493,15 +771,87 @@ h3 {
color: var(--color-text-faint); color: var(--color-text-faint);
font-size: 10px; font-size: 10px;
} }
.formula-strip {
margin-top: 10px;
border: 1px dashed #bfdbfe;
border-radius: 5px;
padding: 8px 10px;
background: #eff6ff;
color: #2563eb;
font-size: 10px;
line-height: 1.55;
text-align: center;
}
.evidence-panel {
margin-top: 12px;
border: 1px solid var(--color-border-light);
border-radius: 6px;
overflow: hidden;
}
.evidence-panel__head {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
border: 0;
padding: 10px 12px;
background: white;
color: #111827;
font: inherit;
font-size: 11px;
font-weight: 650;
border-bottom: 1px solid var(--color-border-light);
cursor: pointer;
text-align: left;
}
.evidence-panel__head small {
margin-left: auto;
color: var(--color-text-muted);
font-size: 9px;
font-weight: 400;
}
.evidence-panel__body {
display: grid;
gap: 0;
padding: 4px 12px 8px;
background: white;
}
.evidence-panel__body div {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 8px;
border-top: 1px dashed var(--color-border-light);
padding: 8px 0;
color: var(--color-text-muted);
font-size: 10px;
line-height: 1.55;
}
.evidence-panel__body div:first-child {
border-top: 0;
}
.evidence-panel__body b {
color: var(--color-text-secondary);
}
.standard-row { .standard-row {
display: flex; display: flex;
width: 100%;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: 8px;
border-top: 1px dashed var(--color-border-light); border-top: 1px dashed var(--color-border-light);
border-right: 0;
border-bottom: 0;
border-left: 0;
padding: 7px 0; padding: 7px 0;
background: transparent;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.standard-row:hover {
background: var(--color-bg-warm);
} }
.standard-tag { .standard-tag {
@ -532,16 +882,6 @@ h3 {
font-size: 9px; font-size: 9px;
} }
.standard-row > strong {
flex: 0 0 auto;
color: var(--color-warning);
font-size: 10px;
}
.standard-row > .status-action {
color: var(--color-success);
}
.standards-card__empty, .standards-card__empty,
.case-empty { .case-empty {
margin: 8px 0; margin: 8px 0;
@ -550,12 +890,48 @@ h3 {
} }
.standards-card footer { .standards-card footer {
display: flex;
align-items: flex-start;
gap: 7px;
border-radius: 6px; border-radius: 6px;
margin-top: 7px; margin-top: 7px;
padding: 7px 9px; padding: 7px 9px;
background: var(--color-bg-warm); background: var(--color-bg-warm);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 10px; font-size: 10px;
line-height: 1.6;
}
.standards-card footer > span:nth-child(2) {
flex: 1;
}
.standards-card footer b {
color: var(--color-text-secondary);
}
.standards-card footer button {
flex: 0 0 auto;
border: 0;
padding: 0;
background: transparent;
color: var(--color-accent);
font: inherit;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.standards-card footer button:hover {
text-decoration: underline;
}
.standards-card__links {
display: inline-flex;
flex: 0 0 auto !important;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px 8px;
}
.standards-card__engine-icon {
color: var(--color-accent);
font-size: 12px;
font-weight: 700;
} }
.analysis-section { .analysis-section {
@ -581,6 +957,17 @@ h3 {
background: var(--color-bg-warm); background: var(--color-bg-warm);
text-align: center; text-align: center;
} }
.dimension-cell:nth-child(1) .dimension-meter i,
.dimension-cell:nth-child(5) .dimension-meter i,
.dimension-cell:nth-child(6) .dimension-meter i {
background: #d97706;
}
.dimension-cell:nth-child(2) .dimension-meter i {
background: #16a34a;
}
.dimension-cell:nth-child(3) .dimension-meter i {
background: #dc2626;
}
.dimension-cell > span { .dimension-cell > span {
color: var(--color-text-muted); color: var(--color-text-muted);
@ -624,8 +1011,8 @@ h3 {
.case-row { .case-row {
display: grid; display: grid;
width: 100%; width: 100%;
grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-columns: 58px minmax(0, 1fr);
align-items: center; align-items: start;
gap: 8px; gap: 8px;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
@ -636,22 +1023,36 @@ h3 {
text-align: left; text-align: left;
} }
.case-row span { .case-rank {
min-width: 0; display: inline-flex;
overflow: hidden; width: 58px;
font-size: 11px; min-width: 58px;
text-overflow: ellipsis; justify-content: flex-start;
padding-top: 1px;
color: var(--color-accent);
font-weight: 650;
white-space: nowrap; white-space: nowrap;
font-size: 11px;
} }
.case-row small { .case-content {
min-width: 0;
}
.case-content small {
display: block;
color: var(--color-text-faint); color: var(--color-text-faint);
font-size: 9px; font-size: 9px;
line-height: 1.45;
overflow-wrap: anywhere;
word-break: break-word;
} }
.case-row strong { .case-content em {
margin-left: 6px;
color: var(--color-accent); color: var(--color-accent);
font-size: 11px; font-style: normal;
white-space: nowrap;
} }
.tips-card { .tips-card {
@ -698,12 +1099,127 @@ 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;
color: var(--color-accent); color: var(--color-accent);
font-size: 11px; font-size: 11px;
} }
.case-modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
background: rgb(15 23 42 / 48%);
}
.case-modal {
width: min(680px, 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__eyebrow {
color: var(--color-accent);
font-size: 9px;
font-weight: 750;
letter-spacing: 0.08em;
}
.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 strong {
font-size: 15px;
}
.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: 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;
}
}
@media (max-width: 600px) { @media (max-width: 600px) {
.risk-card { .risk-card {
@ -715,11 +1231,16 @@ h3 {
} }
.case-row { .case-row {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: 50px minmax(0, 1fr);
} }
.case-row small { .case-rank {
display: none; width: 50px;
min-width: 50px;
}
.case-content small {
white-space: normal;
} }
} }
</style> </style>

View File

@ -14,20 +14,43 @@ const { user } = storeToRefs(auth);
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"]; const navigation = ["对话", "看板", "供应商", "标准库", "报告", "风险项管理"];
const navigationRoutes: Record<string, string> = {
对话: "assessment",
看板: "overview",
供应商: "supplier",
标准库: "standards",
报告: "report",
风险项管理: "review",
};
const visibleNavigation = computed(() => const visibleNavigation = computed(() =>
navigation.filter( navigation.filter(
(item) => item !== "管理" || auth.hasPermission("risk_item.review"), (item) => item !== "风险项管理" || auth.hasPermission("risk_item.review"),
), ),
); );
const routeChunks: Record<string, () => Promise<unknown>> = {
看板: () => import("@/pages/OverviewPage.vue"),
供应商: () => import("@/pages/SupplierProfilePage.vue"),
标准库: () => import("@/pages/StandardsPage.vue"),
报告: () => import("@/pages/ReportPage.vue"),
风险项管理: () => import("@/pages/ReviewPage.vue"),
};
const prefetched = new Set<string>();
function prefetch(item: string) {
if (prefetched.has(item)) return;
const loader = routeChunks[item];
if (!loader) return;
prefetched.add(item);
void loader();
}
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<RouterLink class="brand" :to="{ name: 'assessment' }"> <RouterLink class="brand" :to="{ name: 'assessment' }">
<span class="brand__mark">蝶</span> <span class="brand__mark">蝶</span>
<span class="brand__name">风险预估助手</span> <span class="brand__name">风险预警助手</span>
<span class="brand__version">v2.3</span>
<span class="engine-badge">双引擎</span> <span class="engine-badge">双引擎</span>
</RouterLink> </RouterLink>
@ -36,22 +59,19 @@ const visibleNavigation = computed(() =>
v-for="item in visibleNavigation" v-for="item in visibleNavigation"
:key="item" :key="item"
:to="{ :to="{
name: name: navigationRoutes[item] || 'assessment',
item === '看板'
? 'overview'
: item === '标准库'
? 'standards'
: item === '管理'
? 'review'
: 'assessment',
}" }"
:class="{ :class="{
active: active:
(item === '看板' && route.name === 'overview') || (item === '看板' && route.name === 'overview') ||
(item === '供应商' && route.name === 'supplier') ||
(item === '标准库' && route.name === 'standards') || (item === '标准库' && route.name === 'standards') ||
(item === '管理' && route.name === 'review') || (item === '报告' && route.name === 'report') ||
(item === '风险项管理' && route.name === 'review') ||
(item === '对话' && route.name === 'assessment'), (item === '对话' && route.name === 'assessment'),
}" }"
@mouseenter="prefetch(item)"
@focus="prefetch(item)"
> >
{{ item }} {{ item }}
</RouterLink> </RouterLink>
@ -122,11 +142,6 @@ const visibleNavigation = computed(() =>
font-weight: 650; font-weight: 650;
} }
.brand__version {
color: var(--color-text-faint);
font-size: 10px;
}
.engine-badge { .engine-badge {
border: 1px solid var(--color-accent-soft); border: 1px solid var(--color-accent-soft);
border-radius: 999px; border-radius: 999px;
@ -231,7 +246,6 @@ nav a.active {
padding-inline: 12px; padding-inline: 12px;
} }
.brand__version,
.engine-badge, .engine-badge,
.top-navigation, .top-navigation,
.header-icon, .header-icon,

View File

@ -1,19 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
BarChart3,
BookOpenCheck,
Building2,
ClipboardCheck,
FileText,
PenLine, PenLine,
Search, Search,
Smartphone,
} from "@lucide/vue"; } from "@lucide/vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
import { roleLabel } from "@/utils/roles";
import { useConversationStore } from "@/stores/conversation.store"; import { useConversationStore } from "@/stores/conversation.store";
const auth = useAuthStore(); const auth = useAuthStore();
@ -24,30 +19,23 @@ const { items: conversations, loading: conversationsLoading } =
const router = useRouter(); const router = useRouter();
const search = ref(""); const search = ref("");
const shortcuts = [ onMounted(() => {
{ label: "数据看板", icon: BarChart3, route: "overview" }, // Keep the first non-chat navigation fast by warming the small route chunks
{ // after the browser has finished the initial render. This does not block the
label: "录入风险项", // chat page and makes subsequent clicks resolve from the module cache.
icon: ClipboardCheck, const warmRoutes = () => {
route: "risk-item-entry", void import("@/pages/OverviewPage.vue");
permission: "risk_item.submit", void import("@/pages/SupplierProfilePage.vue");
}, void import("@/pages/StandardsPage.vue");
{ label: "供应商画像", icon: Building2, route: "assessment" }, void import("@/pages/ReportPage.vue");
{ void import("@/pages/ReviewPage.vue");
label: "判定标准库", void import("@/pages/RiskItemEntryPage.vue");
icon: BookOpenCheck, };
route: "standards", const idle = window.requestIdleCallback;
isNew: true, if (idle) idle(warmRoutes, { timeout: 1500 });
}, else window.setTimeout(warmRoutes, 500);
{ label: "最新报告", icon: FileText, route: "assessment" }, void conversationStore.loadList();
{ label: "钉钉移动端", icon: Smartphone, route: "assessment" }, });
];
function canReview() {
return auth.hasPermission("risk_item.review");
}
onMounted(() => conversationStore.loadList());
function startNewChat() { function startNewChat() {
conversationStore.reset(); conversationStore.reset();
@ -55,6 +43,13 @@ function startNewChat() {
} }
async function openConversation(id: number) { async function openConversation(id: number) {
if (conversationStore.current?.id === id) {
await router.push({
name: "assessment",
query: { conversation: String(id) },
});
return;
}
try { try {
await conversationStore.open(id); await conversationStore.open(id);
await router.push({ await router.push({
@ -66,6 +61,18 @@ async function openConversation(id: number) {
} }
} }
async function removeConversation(item: { id: number; title: string }) {
if (!window.confirm(`确定删除会话“${item.title}”吗?删除后无法恢复。`)) return;
try {
await conversationStore.remove(item.id);
if (conversationStore.current === null) {
await router.push({ name: "assessment", query: { new: String(Date.now()) } });
}
} catch {
// The store exposes the request error.
}
}
function searchConversations() { function searchConversations() {
void conversationStore.loadList(search.value.trim() || undefined); void conversationStore.loadList(search.value.trim() || undefined);
} }
@ -102,14 +109,15 @@ function searchConversations() {
<p v-else-if="!conversations.length" class="sidebar-state"> <p v-else-if="!conversations.length" class="sidebar-state">
暂无历史对话 暂无历史对话
</p> </p>
<button <div
v-for="item in conversations" v-for="item in conversations"
:key="item.id" :key="item.id"
class="sidebar-item" class="sidebar-item"
:class="{ :class="{
'sidebar-item--active': conversationStore.current?.id === item.id, 'sidebar-item--active': conversationStore.current?.id === item.id,
}" }"
type="button" role="button"
tabindex="0"
@click="openConversation(item.id)" @click="openConversation(item.id)"
> >
<span <span
@ -119,36 +127,21 @@ function searchConversations() {
></span> ></span>
<span v-else class="risk-dot risk-dot--empty"></span> <span v-else class="risk-dot risk-dot--empty"></span>
<span class="sidebar-item__label">{{ item.title }}</span> <span class="sidebar-item__label">{{ item.title }}</span>
<button
type="button"
class="sidebar-item__delete"
title="删除会话"
aria-label="删除会话"
@click.stop="removeConversation(item)"
>
×
</button> </button>
</section> </div>
<section>
<h2>快捷入口</h2>
<RouterLink
v-for="item in shortcuts.filter(
(item) => !item.permission || auth.hasPermission(item.permission),
)"
:key="item.label"
class="sidebar-item"
:to="{ name: item.route }"
>
<component :is="item.icon" :size="14" />
<span class="sidebar-item__label">{{ item.label }}</span>
<span v-if="item.isNew" class="new-label">新</span>
</RouterLink>
<RouterLink
v-if="canReview()"
class="sidebar-item"
:to="{ name: 'review' }"
>
<ClipboardCheck :size="14" />
<span class="sidebar-item__label">审核队列</span>
</RouterLink>
</section> </section>
<footer> <footer>
<div class="knowledge-progress"><i></i></div> <div class="knowledge-progress"><i></i></div>
<p>{{ user?.role ?? "未登录" }} · 知识库 2,448 / 3,000 条</p> <p>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
<p>标准库 219 条</p> <p>标准库 219 条</p>
</footer> </footer>
</aside> </aside>
@ -235,6 +228,32 @@ section h2 {
white-space: nowrap; white-space: nowrap;
} }
.sidebar-item__delete {
display: none;
width: 22px;
height: 22px;
flex: 0 0 22px;
margin-left: auto;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text-faint);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.sidebar-item:hover .sidebar-item__delete,
.sidebar-item:focus-within .sidebar-item__delete {
display: inline-grid;
place-items: center;
}
.sidebar-item__delete:hover {
background: var(--color-danger-subtle);
color: var(--color-danger);
}
.risk-dot { .risk-dot {
width: 5px; width: 5px;
height: 5px; height: 5px;

View File

@ -0,0 +1,989 @@
<script setup lang="ts">
import axios from "axios";
import {
AlertTriangle,
CheckCircle2,
FileSearch,
FileText,
LoaderCircle,
RefreshCw,
Send,
Upload,
X,
} from "@lucide/vue";
import { computed, ref, watch } from "vue";
import { judgeOcrMeasurements, recognizeOcrFiles } from "@/api/ocr";
import { createRiskItem } from "@/api/riskItems";
import type { OcrBatchResult, OcrExtractedFields } from "@/types/ocr";
import type { RiskItemCreate } from "@/types/riskItem";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; submitted: [] }>();
const files = ref<File[]>([]);
const recognizing = ref(false);
const submitting = ref(false);
const rejudging = ref(false);
const error = ref("");
const result = ref<OcrBatchResult | null>(null);
const originalJudgment = ref<Record<string, unknown> | null>(null);
const submitted = ref(false);
const draft = ref<RiskItemCreate>(emptyDraft());
const confidence = computed(() => {
const values = (result.value?.files ?? [])
.map((file) => file.confidence)
.filter((value): value is number => value !== null);
return values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: null;
});
const allWarnings = computed(() => [
...new Set((result.value?.files ?? []).flatMap((file) => file.warnings)),
]);
const canSubmit = computed(
() =>
draft.value.productName.trim().length > 0 &&
draft.value.description.trim().length >= 5,
);
watch(
() => props.open,
(open) => {
if (open) reset();
},
);
function emptyDraft(): RiskItemCreate {
return {
sourceType: "OCR",
sourceFileId: null,
occurredOn: null,
productName: "",
productCategory: "",
materialType: "",
supplier: "",
factory: "",
stageNew: "第三阶段(产品开发)",
description: "",
defectLevel: null,
solution: null,
photos: [],
recognitionConfidence: null,
ocrPayload: {},
};
}
function reset() {
files.value = [];
result.value = null;
originalJudgment.value = null;
error.value = "";
submitted.value = false;
draft.value = emptyDraft();
}
function close() {
if (!recognizing.value && !submitting.value) emit("close");
}
function selectFiles(event: Event) {
const selected = Array.from((event.target as HTMLInputElement).files ?? []);
error.value = "";
result.value = null;
if (selected.length > 5) {
error.value = "一次最多上传 5 个文件";
files.value = [];
return;
}
const invalid = selected.find(
(file) =>
!["image/jpeg", "image/png", "application/pdf"].includes(file.type) ||
file.size > 10 * 1024 * 1024,
);
if (invalid) {
error.value = `${invalid.name} 格式不支持或超过 10MB`;
files.value = [];
return;
}
files.value = selected;
}
async function recognize() {
if (!files.value.length || recognizing.value) return;
recognizing.value = true;
error.value = "";
try {
const response = await recognizeOcrFiles(files.value);
if (!response.data.data) throw new Error("OCR 响应为空");
result.value = response.data.data;
fillDraft(response.data.data);
} catch (reason) {
error.value = apiError(reason, "OCR 识别失败,请重新上传或手动填写");
} finally {
recognizing.value = false;
}
}
function fillDraft(data: OcrBatchResult) {
const fields = data.fields;
draft.value = {
...emptyDraft(),
sourceFileId: data.files[0]?.fileId ?? null,
occurredOn: fields.occurredOn,
productName: fields.productName.value ?? "",
productCategory: fields.productCategory.value ?? "",
materialType: fields.materialType.value ?? "",
supplier: fields.supplier.value ?? "",
factory: fields.factory.value ?? "",
stageNew: normalizeStage(fields.stageNew.value),
description: fields.description.value ?? "",
defectLevel: normalizeLevel(fields.defectLevel.value),
photos: data.files.map((file) => file.fileId),
recognitionConfidence: confidence.value,
ocrPayload: {
files: data.files.map((file) => ({
fileId: file.fileId,
fileName: file.fileName,
engine: file.engine,
pageCount: file.pageCount,
confidence: file.confidence,
})),
requiresReview: data.requiresReview,
},
};
originalJudgment.value = structuredClone({
measurements: data.measurements,
judgments: data.judgments,
});
}
function updateMeasurement(index: number, rawValue: string) {
if (!result.value) return;
const measurement = result.value.measurements[index];
const numeric = rawValue.match(/[+-]?\d+(?:\.\d+)?/);
const unit = rawValue.match(
/(?:mg\/kg|μg\/g|ug\/g|g\/L|mg\/L|N[·.]?m|mPa[·.]?s|Pa[·.]?s|mm|cm|nm|%|级|℃|°C)$/i,
);
measurement.rawValue = rawValue;
measurement.value = numeric ? Number(numeric[0]) : null;
measurement.unit = unit?.[0] ?? null;
measurement.confidence = 1;
measurement.needsReview = false;
}
async function rejudge() {
if (!result.value?.measurements.length || rejudging.value) return;
rejudging.value = true;
error.value = "";
try {
const response = await judgeOcrMeasurements(result.value.measurements);
result.value.judgments = response.data.data ?? [];
} catch (reason) {
error.value = apiError(reason, "重新判定失败");
} finally {
rejudging.value = false;
}
}
async function submit() {
if (!canSubmit.value || submitting.value) return;
submitting.value = true;
error.value = "";
try {
draft.value.ocrPayload = {
...(draft.value.ocrPayload ?? {}),
originalRecognition: originalJudgment.value,
confirmedMeasurements: result.value?.measurements ?? [],
confirmedJudgments: result.value?.judgments ?? [],
confirmedAt: new Date().toISOString(),
};
await createRiskItem({
...draft.value,
productCategory: draft.value.productCategory || null,
materialType: draft.value.materialType || null,
supplier: draft.value.supplier || null,
factory: draft.value.factory || null,
});
submitted.value = true;
emit("submitted");
} catch (reason) {
error.value = apiError(reason, "提交审核失败");
} finally {
submitting.value = false;
}
}
function fieldNeedsReview(key: keyof OcrExtractedFields) {
const field = result.value?.fields[key];
return (
typeof field === "object" &&
field !== null &&
"needsReview" in field &&
field.needsReview
);
}
function normalizeStage(value: string | null) {
if (!value) return "第三阶段(产品开发)";
const stages = [
"第一阶段(需求发现)",
"第二阶段(产品立项)",
"第三阶段(产品开发)",
"第四阶段(量产上市)",
"第五阶段(产品生命管理)",
];
return (
stages.find(
(stage) => stage.includes(value) || value.includes(stage.slice(5, -1)),
) ?? value
);
}
function normalizeLevel(value: string | null) {
const level = value?.toUpperCase().replace("级", "");
return level && ["A", "B", "C"].includes(level) ? level : null;
}
function formatConfidence(value: number | null) {
return value === null ? "待人工确认" : `${Math.round(value * 100)}%`;
}
function judgmentClass(value: string) {
return value === "合格"
? "is-pass"
: value === "不合格"
? "is-fail"
: "is-review";
}
function apiError(reason: unknown, fallback: string) {
if (axios.isAxiosError(reason)) {
return String(reason.response?.data?.message || fallback);
}
return reason instanceof Error ? reason.message : fallback;
}
</script>
<template>
<div v-if="open" class="ocr-overlay" @mousedown.self="close">
<section
class="ocr-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="ocr-title"
>
<header class="ocr-header">
<div class="ocr-title">
<span class="ocr-title-icon"><FileSearch :size="19" /></span>
<div>
<h2 id="ocr-title">OCR 异常识别</h2>
<p>检测报告、巡检单及异常报告</p>
</div>
</div>
<button
class="icon-button"
type="button"
title="关闭"
:disabled="recognizing || submitting"
@click="close"
>
<X :size="18" />
</button>
</header>
<div v-if="submitted" class="submitted-state">
<CheckCircle2 :size="34" />
<h3>已提交审核</h3>
<p>识别结果和标准判定已进入审核队列,审核通过后才会写入案例库。</p>
<button class="primary-button" type="button" @click="close">
完成
</button>
</div>
<div v-else class="ocr-content">
<section class="upload-section">
<label class="upload-zone">
<Upload :size="22" />
<strong>{{
files.length ? "重新选择文件" : "选择图片或 PDF"
}}</strong>
<span>JPG / PNG / PDF,单个不超过 10MB,最多 5 个</span>
<input
type="file"
accept="image/jpeg,image/png,application/pdf"
multiple
@change="selectFiles"
/>
</label>
<div v-if="files.length" class="selected-files">
<span v-for="file in files" :key="`${file.name}-${file.size}`">
<FileText :size="13" />{{ file.name
}}<small>{{ (file.size / 1024 / 1024).toFixed(2) }}MB</small>
</span>
</div>
<button
class="primary-button recognize-button"
type="button"
:disabled="!files.length || recognizing"
@click="recognize"
>
<LoaderCircle v-if="recognizing" class="spin" :size="16" />
<FileSearch v-else :size="16" />
{{
recognizing
? "正在识别并匹配标准…"
: result
? "重新识别"
: "开始识别"
}}
</button>
</section>
<p v-if="error" class="error-message" role="alert">
<AlertTriangle :size="15" />{{ error }}
</p>
<template v-if="result">
<section class="result-summary">
<div>
<strong>识别完成</strong>
<span
>{{ result.files.length }} 个文件 ·
{{
result.files.reduce((sum, file) => sum + file.pageCount, 0)
}}
页</span
>
</div>
<span
class="confidence"
:class="{ low: confidence === null || confidence < 0.8 }"
>
置信度 {{ formatConfidence(confidence) }}
</span>
</section>
<div v-if="allWarnings.length" class="warning-list">
<p v-for="warning in allWarnings" :key="warning">
<AlertTriangle :size="14" />{{ warning }}
</p>
</div>
<form class="ocr-form" @submit.prevent="submit">
<header class="section-title">
<div>
<h3>识别字段</h3>
<p>标黄字段需要人工核对,可直接修改。</p>
</div>
</header>
<div class="field-grid">
<label :class="{ review: fieldNeedsReview('productName') }"
>产品名称 *<input
v-model.trim="draft.productName"
required
maxlength="200"
/></label>
<label :class="{ review: fieldNeedsReview('productCategory') }"
>产品品类<input
v-model.trim="draft.productCategory"
maxlength="100"
/></label>
<label :class="{ review: fieldNeedsReview('materialType') }"
>物料<input v-model.trim="draft.materialType" maxlength="200"
/></label>
<label :class="{ review: fieldNeedsReview('supplier') }"
>供应商<input v-model.trim="draft.supplier" maxlength="300"
/></label>
<label :class="{ review: fieldNeedsReview('factory') }"
>加工厂<input v-model.trim="draft.factory" maxlength="300"
/></label>
<label
>发生日期<input v-model="draft.occurredOn" type="date"
/></label>
<label :class="{ review: fieldNeedsReview('stageNew') }"
>发生阶段<select v-model="draft.stageNew">
<option>第一阶段(需求发现)</option>
<option>第二阶段(产品立项)</option>
<option>第三阶段(产品开发)</option>
<option>第四阶段(量产上市)</option>
<option>第五阶段(产品生命管理)</option>
</select></label
>
<label :class="{ review: fieldNeedsReview('defectLevel') }"
>缺陷等级<select v-model="draft.defectLevel">
<option :value="null">未确定</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select></label
>
</div>
<label
class="description-field"
:class="{ review: fieldNeedsReview('description') }"
>
异常描述 *
<textarea
v-model.trim="draft.description"
required
minlength="5"
maxlength="10000"
rows="4"
/>
</label>
<section v-if="result.measurements.length" class="judgment-section">
<header class="section-title">
<div>
<h3>标准数值判定</h3>
<p>可修正检测值并重新判定,原始识别值会保留。</p>
</div>
<button
class="secondary-button compact-button"
type="button"
:disabled="rejudging"
@click="rejudge"
>
<LoaderCircle
v-if="rejudging"
class="spin"
:size="14"
/><RefreshCw v-else :size="14" />重新判定
</button>
</header>
<div class="judgment-table-wrap">
<table>
<thead>
<tr>
<th>检测项</th>
<th>检测值</th>
<th>标准</th>
<th>结论</th>
</tr>
</thead>
<tbody>
<tr
v-for="(measurement, index) in result.measurements"
:key="`${measurement.item}-${index}`"
>
<td>{{ measurement.item }}</td>
<td>
<input
class="measurement-input"
:value="measurement.rawValue"
@input="
updateMeasurement(
index,
($event.target as HTMLInputElement).value,
)
"
/>
</td>
<td>
<span>{{
result.judgments[index]?.standardValue || "未匹配"
}}</span
><small>{{ result.judgments[index]?.basis }}</small>
</td>
<td>
<select
v-if="result.judgments[index]"
v-model="result.judgments[index].result"
class="judgment-select"
:class="judgmentClass(result.judgments[index].result)"
@change="
result.judgments[index].manuallyConfirmed = true
"
>
<option>合格</option>
<option>不合格</option>
<option>待人工确认</option>
<option>未匹配标准</option></select
><small>{{ result.judgments[index]?.reason }}</small>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<details class="raw-result">
<summary>查看 OCR 原始文字</summary>
<article v-for="file in result.files" :key="file.fileId">
<strong>{{ file.fileName }}</strong>
<pre>{{ file.rawText }}</pre>
</article>
</details>
<footer class="dialog-actions">
<button
class="secondary-button"
type="button"
:disabled="submitting"
@click="reset"
>
<RefreshCw :size="15" />重新上传
</button>
<button
class="primary-button"
type="submit"
:disabled="!canSubmit || submitting"
>
<LoaderCircle v-if="submitting" class="spin" :size="16" /><Send
v-else
:size="16"
/>
{{ submitting ? "提交中…" : "确认并提交审核" }}
</button>
</footer>
</form>
</template>
</div>
</section>
</div>
</template>
<style scoped>
.ocr-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
background: rgb(15 23 42 / 48%);
}
.ocr-dialog {
display: flex;
width: min(920px, 100%);
max-height: min(880px, calc(100vh - 48px));
flex-direction: column;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: 0 22px 54px rgb(15 23 42 / 22%);
}
.ocr-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--color-border-light);
padding: 17px 20px;
}
.ocr-title {
display: flex;
align-items: center;
gap: 11px;
}
.ocr-title-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 6px;
background: #e0f2fe;
color: #0369a1;
}
.ocr-title h2,
.ocr-title p,
.section-title h3,
.section-title p,
.submitted-state h3,
.submitted-state p {
margin: 0;
}
.ocr-title h2 {
font-size: 16px;
}
.ocr-title p,
.section-title p {
margin-top: 3px;
color: var(--color-text-muted);
font-size: 11px;
}
.icon-button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
}
.ocr-content {
overflow-y: auto;
padding: 20px;
}
.upload-section {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: stretch;
}
.upload-zone {
display: grid;
min-height: 102px;
place-items: center;
align-content: center;
gap: 4px;
border: 1px dashed var(--color-border-strong);
border-radius: 6px;
background: var(--color-bg);
color: #0369a1;
cursor: pointer;
}
.upload-zone strong {
color: var(--color-text);
font-size: 13px;
}
.upload-zone span {
color: var(--color-text-muted);
font-size: 11px;
}
.upload-zone input {
display: none;
}
.selected-files {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.selected-files span {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
border: 1px solid var(--color-border-light);
border-radius: 4px;
padding: 5px 7px;
color: var(--color-text-secondary);
font-size: 11px;
}
.selected-files small {
color: var(--color-text-muted);
}
button {
font: inherit;
}
.primary-button,
.secondary-button {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 5px;
padding: 0 15px;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.primary-button {
border: 1px solid var(--color-accent);
background: var(--color-accent);
color: white;
}
.secondary-button {
border: 1px solid var(--color-border-strong);
background: white;
color: var(--color-text-secondary);
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.recognize-button {
min-width: 190px;
}
.error-message,
.warning-list p {
display: flex;
align-items: flex-start;
gap: 7px;
}
.error-message {
margin: 14px 0 0;
color: var(--color-danger);
font-size: 12px;
}
.result-summary {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 11px 13px;
background: #f0fdf4;
}
.result-summary div {
display: grid;
gap: 2px;
}
.result-summary strong {
color: #166534;
font-size: 13px;
}
.result-summary div span {
color: #4b5563;
font-size: 11px;
}
.confidence {
border-radius: 4px;
padding: 4px 7px;
background: #dcfce7;
color: #166534;
font-size: 11px;
font-weight: 700;
}
.confidence.low {
background: #fef3c7;
color: #92400e;
}
.warning-list {
margin-top: 10px;
border-left: 3px solid #f59e0b;
padding: 6px 10px;
background: #fffbeb;
}
.warning-list p {
margin: 3px 0;
color: #92400e;
font-size: 11px;
}
.ocr-form {
display: grid;
gap: 15px;
margin-top: 18px;
}
.section-title {
display: flex;
align-items: end;
justify-content: space-between;
}
.section-title h3 {
font-size: 14px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 11px;
}
.ocr-form label {
display: grid;
gap: 5px;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 650;
}
.ocr-form input,
.ocr-form select,
.ocr-form textarea {
min-width: 0;
width: 100%;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 8px 9px;
background: white;
color: var(--color-text);
font: inherit;
font-size: 12px;
box-sizing: border-box;
}
.ocr-form textarea {
resize: vertical;
}
.ocr-form label.review input,
.ocr-form label.review select,
.ocr-form label.review textarea {
border-color: #f59e0b;
background: #fffbeb;
}
.judgment-section {
display: grid;
gap: 10px;
border-top: 1px solid var(--color-border-light);
padding-top: 15px;
}
.judgment-table-wrap {
overflow-x: auto;
border: 1px solid var(--color-border-light);
border-radius: 5px;
}
.judgment-table-wrap table {
width: 100%;
min-width: 680px;
border-collapse: collapse;
font-size: 11px;
}
.judgment-table-wrap th,
.judgment-table-wrap td {
border-bottom: 1px solid var(--color-border-light);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.judgment-table-wrap th {
background: var(--color-bg);
color: var(--color-text-muted);
}
.judgment-table-wrap td span,
.judgment-table-wrap td small {
display: block;
}
.judgment-table-wrap td small {
max-width: 280px;
margin-top: 3px;
color: var(--color-text-muted);
line-height: 1.45;
}
.judgment {
width: fit-content;
border-radius: 3px;
padding: 2px 6px;
font-weight: 700;
}
.judgment.is-pass {
background: #dcfce7;
color: #166534;
}
.judgment.is-fail {
background: #fee2e2;
color: #b91c1c;
}
.judgment.is-review {
background: #fef3c7;
color: #92400e;
}
.compact-button {
min-height: 32px;
padding: 0 10px;
}
.measurement-input {
width: 110px !important;
}
.judgment-select {
width: 112px !important;
border: 0 !important;
font-weight: 700 !important;
}
.judgment-select.is-pass {
background: #dcfce7;
color: #166534;
}
.judgment-select.is-fail {
background: #fee2e2;
color: #b91c1c;
}
.judgment-select.is-review {
background: #fef3c7;
color: #92400e;
}
.raw-result {
border: 1px solid var(--color-border-light);
border-radius: 5px;
padding: 9px 11px;
}
.raw-result summary {
color: var(--color-text-secondary);
font-size: 11px;
cursor: pointer;
}
.raw-result article {
margin-top: 10px;
}
.raw-result article strong {
font-size: 11px;
}
.raw-result pre {
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-muted);
font-family: inherit;
font-size: 11px;
line-height: 1.55;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
border-top: 1px solid var(--color-border-light);
padding-top: 15px;
}
.submitted-state {
display: grid;
min-height: 360px;
place-items: center;
align-content: center;
gap: 10px;
padding: 30px;
text-align: center;
color: var(--color-success);
}
.submitted-state h3 {
color: var(--color-text);
}
.submitted-state p {
max-width: 430px;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.6;
}
.submitted-state .primary-button {
margin-top: 8px;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 780px) {
.ocr-overlay {
align-items: end;
padding: 0;
}
.ocr-dialog {
width: 100%;
max-height: 94vh;
border-radius: 8px 8px 0 0;
}
.ocr-content {
padding: 15px;
}
.upload-section {
grid-template-columns: 1fr;
}
.recognize-button {
min-width: 0;
}
.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.field-grid {
grid-template-columns: 1fr;
}
.result-summary {
align-items: flex-start;
gap: 8px;
}
.dialog-actions {
flex-direction: column-reverse;
}
.dialog-actions button {
width: 100%;
}
}
</style>

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;

4
src/config/features.ts Normal file
View File

@ -0,0 +1,4 @@
export const featureFlags = {
voiceEntry: false,
adminConfigurationPanels: false,
} as const;

View File

@ -17,7 +17,11 @@ onMounted(() => appStore.refreshHealth());
<div class="app-shell__body"> <div class="app-shell__body">
<AppSidebar /> <AppSidebar />
<main class="app-content"> <main class="app-content">
<RouterView /> <RouterView v-slot="{ Component }">
<KeepAlive :max="8">
<component :is="Component" />
</KeepAlive>
</RouterView>
</main> </main>
</div> </div>
</div> </div>

View File

@ -6,25 +6,24 @@ import {
ShieldCheck, ShieldCheck,
Smartphone, Smartphone,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { waitForDingTalkContainer } from "@/api/dingtalk";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const mode = ref<"password" | "dingtalk">("password"); const mode = ref<"password" | "dingtalk">("password");
const useDevelopmentLogin = ref(true); const useDevelopmentLogin = ref(true);
const username = ref("local-admin"); const username = ref("local-admin");
const password = ref("risk-admin-2026"); const password = ref("risk-admin-2026");
const autoDingTalkAttempted = ref(false);
const autoDingTalk = ref(false);
const redirectPath = computed(() => { // 登录后的统一落点是智能对话页,避免从其他受保护页面跳转到登录后
const redirect = route.query.redirect; // 又回到原页面,保证用户进入系统后先看到对话工作台。
return typeof redirect === "string" && redirect.startsWith("/") const redirectPath = computed(() => "/assessment");
? redirect
: "/assessment";
});
async function submitPassword() { async function submitPassword() {
if (!username.value.trim() || password.value.length < 8 || auth.loading) if (!username.value.trim() || password.value.length < 8 || auth.loading)
@ -50,6 +49,16 @@ async function submitDingTalk() {
// The store exposes the server or container message below the form. // The store exposes the server or container message below the form.
} }
} }
onMounted(async () => {
if (autoDingTalkAttempted.value) return;
const inDingTalk = await waitForDingTalkContainer();
if (!inDingTalk || autoDingTalkAttempted.value) return;
autoDingTalkAttempted.value = true;
autoDingTalk.value = true;
mode.value = "dingtalk";
await submitDingTalk();
});
</script> </script>
<template> <template>
@ -58,7 +67,7 @@ async function submitDingTalk() {
<div class="login-card__brand"> <div class="login-card__brand">
<span class="login-card__mark">蝶</span> <span class="login-card__mark">蝶</span>
<div> <div>
<strong>风险预估助手</strong> <strong>风险预警助手</strong>
<small>Dieya Risk Prediction Console</small> <small>Dieya Risk Prediction Console</small>
</div> </div>
</div> </div>
@ -66,7 +75,7 @@ async function submitDingTalk() {
<div class="login-card__intro"> <div class="login-card__intro">
<span class="eyebrow">SECURE ACCESS</span> <span class="eyebrow">SECURE ACCESS</span>
<h1>登录系统</h1> <h1>登录系统</h1>
<p>使用企业账号或钉钉工作台进入风险预估平台。</p> <p>使用企业账号或钉钉工作台进入风险预警平台。</p>
</div> </div>
<div class="login-tabs" role="tablist" aria-label="登录方式"> <div class="login-tabs" role="tablist" aria-label="登录方式">
@ -130,9 +139,16 @@ async function submitDingTalk() {
<div v-else class="dingtalk-panel"> <div v-else class="dingtalk-panel">
<div class="dingtalk-panel__icon"><Smartphone :size="22" /></div> <div class="dingtalk-panel__icon"><Smartphone :size="22" /></div>
<strong>从钉钉工作台快速进入</strong> <strong>{{ auth.loading ? "正在获取钉钉身份" : "从钉钉工作台快速进入" }}</strong>
<p>在钉钉容器中点击授权,系统会读取组织身份并自动匹配角色权限。</p> <p>
{{
auth.loading
? "系统正在验证组织身份并匹配角色权限,请稍候。"
: "系统会读取组织身份并自动匹配角色权限。"
}}
</p>
<button <button
v-if="!autoDingTalk || auth.error"
class="login-submit" class="login-submit"
type="button" type="button"
:disabled="auth.loading" :disabled="auth.loading"

View File

@ -130,7 +130,7 @@ onMounted(() => void load());
<header class="dashboard-header"> <header class="dashboard-header">
<div> <div>
<p class="eyebrow">RISK INTELLIGENCE</p> <p class="eyebrow">RISK INTELLIGENCE</p>
<h1>数据看板 <small>v2.3</small></h1> <h1>数据看板</h1>
<p class="dashboard-subtitle"> <p class="dashboard-subtitle">
历史异常、供应商风险与标准覆盖的全局视图 历史异常、供应商风险与标准覆盖的全局视图
</p> </p>

716
src/pages/ReportPage.vue Normal file
View File

@ -0,0 +1,716 @@
<script setup lang="ts">
import { ArrowLeft, Download, FileText } from "@lucide/vue";
import { computed, onActivated, onMounted, shallowRef, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { downloadReportPdf, downloadReportWord } from "@/api/reports";
import { useAuthStore } from "@/stores/auth.store";
import { useConversationStore } from "@/stores/conversation.store";
import type { ReportCard, RiskReportResult } from "@/types/risk";
import { formatRiskLevel } from "@/utils/risk";
import { readConversationReport } from "@/utils/conversationReports";
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const conversationStore = useConversationStore();
const report = shallowRef<RiskReportResult | null>(null);
function loadReport() {
const conversationId =
route.query.conversation ?? conversationStore.current?.id;
report.value = readConversationReport(
typeof conversationId === "string" || typeof conversationId === "number"
? conversationId
: null,
auth.user?.id,
);
}
// ReportPage is kept alive by MainLayout. Reload the session payload whenever
// the page becomes active so a newly generated report is immediately visible.
onMounted(loadReport);
onActivated(loadReport);
watch(
[() => route.query.conversation, () => conversationStore.current?.id],
loadReport,
);
const reportCards = computed<ReportCard[]>(
() => {
if (!report.value) return [];
if (report.value.reportCards?.length) return report.value.reportCards;
return (report.value.aiSolutionCards ?? []).map((solution) => ({
type: "solution" as const,
title: solution.title || "AI 案例推理生成",
confidence: solution.confidence,
analysisBase: solution.analysisBase,
standardFallbackEnabled: solution.standardFallbackEnabled,
standards: solution.standards,
similarCases: solution.similarCases,
risks: solution.risks,
suggestions: solution.suggestions,
warning: solution.warning,
}));
},
);
const reportData = computed(() => {
const cards = reportCards.value;
const unique = <T>(items: T[], key: (item: T) => string) => {
const seen = new Set<string>();
return items.filter((item) => {
const value = key(item);
if (seen.has(value)) return false;
seen.add(value);
return true;
});
};
const standards = unique(
[
...(report.value?.standardJudgment ?? []),
...cards.flatMap((card) => card.standards ?? []),
],
(item) => `${item.category}|${item.riskPoint}|${item.rule}|${item.status}`,
);
const similarCases = unique(
[
...(report.value?.similarCases ?? []),
...cards.flatMap((card) => card.similarCases ?? []),
],
(item) => `${item.caseId}|${item.date}|${item.product}|${item.problem ?? item.description}|${item.solution}`,
);
const suggestions = unique(
[
...(report.value?.aiSuggestions ?? []),
...cards.flatMap((card) => card.suggestions ?? []),
],
(item) => item,
);
return {
cards,
supplierCards: cards.filter((card) => card.type === "supplier"),
materialCards: cards.filter((card) => card.type === "material"),
solutionCards: cards.filter((card) => card.type === "solution"),
standardCards: cards.filter((card) => ["standard", "compliance"].includes(card.type)),
standards,
similarCases,
suggestions,
};
});
const dimensionRows = computed(() => {
if (!report.value) return [];
if (report.value.dimensionScores?.length) {
return report.value.dimensionScores.filter(
(item) => item.dimension !== "节点阶段" && item.dimension !== "缺陷等级",
);
}
return report.value.factors
.filter(
(factor) => factor.name !== "节点阶段" && factor.name !== "缺陷等级",
)
.map((factor) => ({
dimension: factor.name,
score: 100 - factor.value,
weight: factor.weight,
status: "database",
reason: null,
}));
});
const generatedAt = new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date());
async function downloadWord() {
if (!report.value) return;
const response = await downloadReportWord(report.value);
const blob = response.data;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `蝶雅新品风险预警报告-${report.value.subjectId}.docx`;
anchor.click();
URL.revokeObjectURL(url);
}
async function downloadPdf() {
if (!report.value) return;
const response = await downloadReportPdf(report.value);
const url = URL.createObjectURL(response.data);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `蝶雅新品风险预警报告-${report.value.subjectId}.pdf`;
anchor.click();
URL.revokeObjectURL(url);
}
function returnToConversation() {
const conversationId =
route.query.conversation ?? conversationStore.current?.id;
void router.push({
name: "assessment",
...(typeof conversationId === "string" || typeof conversationId === "number"
? { query: { conversation: conversationId } }
: {}),
});
}
function notify(message: string) {
window.alert(`${message}功能待接入服务端`);
}
function dimensionConclusion(item: {
dimension: string;
score: number;
reason?: string | null;
}) {
const supplier = report.value?.supplierFreq;
if (item.dimension === "供应商表现" && supplier) {
const orderText =
supplier.cooperationCount != null
? `合作 ${supplier.cooperationCount} 次、`
: "";
return `${Math.round(item.score)} 分 · ${orderText}异常 ${supplier.anomalyCount ?? 0} 次`;
}
if (
item.dimension === "标准符合性" &&
report.value?.standardJudgment?.length
) {
return `${Math.round(item.score)} 分 · 命中 ${report.value.standardJudgment.length} 条判定标准,待检测项需人工确认`;
}
return `${Math.round(item.score)} 分${item.reason ? ` · ${item.reason}` : ""}`;
}
</script>
<template>
<div class="report-page">
<header class="report-actions">
<button
type="button"
class="secondary-button"
:disabled="!report"
@click="downloadPdf"
>
<Download :size="15" />导出 PDF
</button>
<button
type="button"
class="secondary-button"
:disabled="!report"
@click="downloadWord"
>
<Download :size="15" />导出 Word
</button>
<button
type="button"
class="secondary-button"
@click="notify('钉钉发送')"
>
<FileText :size="15" />钉钉发送
</button>
<button type="button" class="primary-button" @click="returnToConversation">
<ArrowLeft :size="15" />返回对话
</button>
</header>
<p v-if="!report" class="report-empty">
当前会话暂无报告,请返回对话后从风险评估结果卡片点击“生成报告”。
</p>
<article v-else class="report-sheet">
<h1>新品风险预警报告</h1>
<p class="report-subtitle">
蝶雅产品部 · 风险预警系统 · 自动生成 · {{ generatedAt }}
</p>
<div class="report-meta">
<span
><b>报告编号:</b>RP-{{ report.subjectId.slice(0, 8) }}-{{
report.score.toFixed(0)
}}</span
>
</div>
<h2>一、风险结论</h2>
<table>
<thead>
<tr>
<th>评估项</th>
<th>结论</th>
</tr>
</thead>
<tbody>
<tr>
<td>综合评分</td>
<td class="risk">
{{ Math.round(report.score) }} / 100 ·
{{ formatRiskLevel(report.level) }} ·
{{ report.riskBadge || "需关注" }}
</td>
</tr>
<tr>
<td>风险警示</td>
<td>
{{
report.alertText ||
"当前暂无足够历史数据,建议按标准库完成基础验证。"
}}
</td>
</tr>
<tr v-for="item in dimensionRows" :key="item.dimension">
<td>{{ item.dimension }}({{ item.weight }}%)</td>
<td>{{ dimensionConclusion(item) }}</td>
</tr>
</tbody>
</table>
<template v-if="report.categoryWarning">
<h2>二、品类风险预警</h2>
<table>
<thead>
<tr>
<th>品类</th>
<th>历史异常</th>
<th>风险等级</th>
<th>建议</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ report.categoryWarning.category }}</td>
<td>{{ report.categoryWarning.anomalyCount }} 条</td>
<td>{{ report.categoryWarning.riskLevel }}</td>
<td>
{{
report.categoryWarning.suggestion ||
"结合相似品类和标准库预判"
}}
</td>
</tr>
</tbody>
</table>
</template>
<template v-if="report.supplierFreq?.supplierName || reportData.supplierCards.length">
<h2>三、供应商合作频次分析</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>数值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-if="report.supplierFreq?.cooperationCount != null">
<td>合作次数</td>
<td>{{ report.supplierFreq?.cooperationCount }} 次</td>
<td>按自然年度边界采购订单去重单据数</td>
</tr>
<tr>
<td>异常次数</td>
<td>{{ report.supplierFreq?.anomalyCount ?? 0 }} 次</td>
<td>异常案例库统计</td>
</tr>
<tr v-if="report.supplierFreq?.alpha != null">
<td>频次风险系数</td>
<td>×{{ report.supplierFreq?.alpha?.toFixed(2) }}</td>
<td>α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5</td>
</tr>
<tr v-if="report.supplierFreq?.tier">
<td>供应商层级</td>
<td>{{ report.supplierFreq?.tier }}</td>
<td>{{ report.supplierFreq?.suggestion }}</td>
</tr>
<template v-for="(card, cardIndex) in reportData.supplierCards" :key="`supplier-card-${cardIndex}`">
<tr v-if="card.supplierScore != null"><td>供应商得分</td><td>{{ card.supplierScore.toFixed(2) }} 分</td><td>供应商画像统计</td></tr>
<tr v-if="card.supplierBaseScore != null"><td>供应商基础分</td><td>{{ card.supplierBaseScore.toFixed(2) }} 分</td><td>供应商画像统计</td></tr>
<tr v-if="card.topProblems?.length"><td>高频问题</td><td>{{ card.topProblems.map((item) => `${item.name}(${item.count}次)`).join("、") }}</td><td>异常案例库统计</td></tr>
<tr v-if="card.note"><td>备注</td><td>{{ card.note }}</td><td>供应商画像统计</td></tr>
</template>
</tbody>
</table>
</template>
<template v-if="reportData.materialCards.length">
<h2>四、物料风险分析</h2>
<table>
<thead><tr><th>物料/字段</th><th>分析结果</th></tr></thead>
<tbody>
<tr v-for="(card, index) in reportData.materialCards" :key="`material-${index}`">
<td>{{ card.material || card.title }}</td>
<td>
<span v-if="card.anomalyCount != null">历史异常:{{ card.anomalyCount }} 条;</span>
<span v-if="card.frequentProblems?.length">常见问题:{{ card.frequentProblems.map((item) => `${item.name}(${item.count}次)`).join("、") }};</span>
<span v-if="card.suggestions?.length">建议:{{ card.suggestions.join(";") }}</span>
</td>
</tr>
</tbody>
</table>
</template>
<h2>{{ reportData.materialCards.length ? "五" : "四" }}、相似案例匹配({{ reportData.similarCases.length }} 条)</h2>
<template v-if="reportData.similarCases.length">
<table>
<thead>
<tr>
<th>日期</th>
<th>产品</th>
<th>问题</th>
<th>处理方案</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in reportData.similarCases"
:key="`${item.date}-${item.product}-${item.problem}`"
>
<td>{{ item.date || "未知" }}</td>
<td>{{ item.product || "未命名" }}</td>
<td>{{ item.problem || "未填写" }}</td>
<td>{{ item.solution || "未填写" }}</td>
</tr>
</tbody>
</table>
</template>
<p v-else class="report-paragraph">
暂无相关案例,RAG 相似案例检索未返回匹配结果。
</p>
<h2>{{ reportData.materialCards.length ? "六" : "五" }}、预防建议</h2>
<ol>
<li
v-for="suggestion in reportData.suggestions.length
? reportData.suggestions
: ['优先针对低分维度安排专项验证,并明确责任人与完成节点。']"
:key="suggestion"
>
{{ suggestion }}
</li>
</ol>
<h2>{{ reportData.materialCards.length ? "七" : "六" }}、AI 推理方案(无历史案例场景)</h2>
<p class="report-paragraph">
<template v-if="reportData.similarCases.length">
本次评估已匹配
{{ reportData.similarCases.length }}
条相似历史案例,当前采用历史案例与标准库双引擎结论,未单独触发无历史案例类比推理。
</template>
<template v-else>
当前未匹配到足够历史案例,系统将基于相似品类、相似物料生成类比方案,并结合判定标准库进行规则兜底;实际执行前需由产品和品控确认。
</template>
</p>
<template v-if="reportData.solutionCards.length">
<section v-for="(card, index) in reportData.solutionCards" :key="`solution-${index}`" class="ai-solution-report">
<header><strong>{{ card.title }}</strong><span v-if="card.confidence">置信度:{{ card.confidence }}</span></header>
<p v-if="card.analysisBase"><b>分析基础:</b>{{ card.analysisBase }}</p>
<p v-if="card.standardFallbackEnabled"><b>标准兜底校验:</b>已启用(无直接历史案例)</p>
<ul v-if="card.risks?.length"><li v-for="risk in card.risks" :key="`${risk.title}-${risk.detail}`"><b>{{ risk.level }} · {{ risk.title }}:</b>{{ risk.detail }}</li></ul>
<ol v-if="card.suggestions?.length"><li v-for="suggestion in card.suggestions" :key="suggestion">{{ suggestion }}</li></ol>
<p v-if="card.warning" class="ai-solution-report__warning">{{ card.warning }}</p>
</section>
</template>
<h2>{{ reportData.materialCards.length ? "八" : "七" }}、标准判定结果</h2>
<template v-if="reportData.standards.length">
<table>
<thead>
<tr>
<th>判定维度</th>
<th>判定标准</th>
<th>标准来源</th>
<th>当前状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="standard in reportData.standards"
:key="`${standard.category}-${standard.riskPoint}`"
>
<td>{{ standard.category }} · {{ standard.riskPoint }}</td>
<td>{{ standard.rule }}</td>
<td>{{ standard.source }}</td>
<td>{{ standard.status }}</td>
</tr>
</tbody>
</table>
</template>
<template v-for="(card, index) in reportData.standardCards" :key="`standard-card-${index}`">
<p v-if="card.detectedValues?.length" class="report-paragraph"><b>检测值:</b>{{ card.detectedValues.map((item) => `${item.name}:${item.value}`).join("、") }}</p>
<p v-if="card.note" class="report-paragraph"><b>补充说明:</b>{{ card.note }}</p>
</template>
<p v-if="!reportData.standards.length" class="report-paragraph">暂无相关标准。</p>
<p class="report-paragraph report-engine-note">
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
</p>
<footer class="report-footer">
<span>编制:蝶雅风险预警系统</span
><span>生成时间:{{ generatedAt }}</span>
</footer>
</article>
</div>
</template>
<style scoped>
.report-page {
flex: 1;
overflow-y: auto;
background: #f5f7fb;
padding: 24px 28px 40px;
}
.report-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin: 0 auto 16px;
max-width: 780px;
}
.action-group {
display: flex;
gap: 6px;
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 5px;
padding: 8px 12px;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.primary-button {
border: 1px solid #111827;
background: #111827;
color: white;
}
.secondary-button {
border: 1px solid #d1d9e6;
background: white;
color: #334155;
}
.report-empty {
max-width: 780px;
margin: 80px auto;
text-align: center;
color: #64748b;
}
.report-sheet {
max-width: 780px;
margin: 0 auto;
border: 1px solid #dce3ed;
border-radius: 8px;
background: white;
padding: 48px;
box-shadow: 0 10px 26px rgb(15 23 42 / 10%);
}
h1 {
margin: 0 0 4px;
text-align: center;
font-size: 28px;
color: #111827;
}
.report-subtitle {
margin: 0 0 28px;
border-bottom: 2px solid #111827;
padding-bottom: 20px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.report-meta {
display: flex;
flex-wrap: wrap;
gap: 32px;
margin-bottom: 24px;
color: #334155;
font-size: 12px;
}
h2 {
margin: 24px 0 12px;
border-left: 2px solid #111827;
padding-left: 8px;
color: #111827;
font-size: 15px;
}
table {
width: 100%;
margin-bottom: 16px;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
border: 1px solid #dbe3ef;
padding: 9px 12px;
text-align: left;
vertical-align: top;
}
th {
background: #111827;
color: white;
font-size: 12px;
font-weight: 600;
}
.risk {
color: #d97706;
font-size: 15px;
font-weight: 700;
}
ol {
padding-left: 24px;
color: #334155;
font-size: 13px;
line-height: 1.9;
}
.report-footer {
display: flex;
justify-content: space-between;
margin-top: 36px;
color: #94a3b8;
font-size: 11px;
}
.report-paragraph {
margin: 0 0 12px;
color: #334155;
font-size: 13px;
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;
}
.report-card-detail {
margin: 0 0 14px;
border: 1px solid #dbe3ef;
border-left: 3px solid #2563eb;
border-radius: 6px;
padding: 12px 14px;
background: #fafcff;
}
.report-card-detail header {
display: flex;
justify-content: space-between;
margin-bottom: 7px;
color: #1d4ed8;
font-size: 13px;
}
.report-card-detail header span {
color: #64748b;
font-size: 10px;
}
.report-card-detail p,
.report-card-detail li {
margin: 4px 0;
color: #334155;
font-size: 12px;
line-height: 1.6;
}
.report-card-detail ul {
margin: 4px 0;
padding-left: 20px;
}
.report-card-detail__standards {
color: #475569;
}
.report-card-detail__warning {
border-radius: 4px;
padding: 6px 8px;
background: #fffbeb;
color: #92400e !important;
}
.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) {
.report-page {
padding: 12px;
}
.report-sheet {
padding: 24px 16px;
}
.report-actions {
flex-direction: column;
}
.action-group {
justify-content: flex-end;
}
.report-meta {
flex-direction: column;
gap: 5px;
}
table {
min-width: 680px;
}
.report-sheet {
overflow-x: auto;
}
}
@media print {
.report-page {
padding: 0;
background: white;
}
.report-actions {
display: none;
}
.report-sheet {
border: 0;
box-shadow: none;
max-width: none;
padding: 0;
}
h2 {
break-after: avoid;
}
table {
break-inside: avoid;
}
}
</style>

View File

@ -13,22 +13,51 @@ import {
Mic, Mic,
Plus, Plus,
RefreshCw, RefreshCw,
Save,
SlidersHorizontal,
Undo2,
Upload, Upload,
Users,
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { downloadOcrFile } from "@/api/ocr";
import { fetchModelConfig, updateModelConfig } from "@/api/modelConfig";
import { useReviewStore } from "@/stores/review.store"; import { useReviewStore } from "@/stores/review.store";
import { useAuthStore } from "@/stores/auth.store";
import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
import { featureFlags } from "@/config/features";
import type { OcrStandardJudgment } from "@/types/ocr";
import type { RiskItem, RiskItemUpdate } from "@/types/riskItem"; import type { RiskItem, RiskItemUpdate } from "@/types/riskItem";
import type { ModelWeightConfig, WeightDimension } from "@/types/modelConfig";
const router = useRouter(); const router = useRouter();
const store = useReviewStore(); const store = useReviewStore();
const auth = useAuthStore();
const sourceFilter = ref("all"); const sourceFilter = ref("all");
const statusFilter = ref("待审核"); const statusFilter = ref("待审核");
const expandedId = ref<number | null>(null); const expandedId = ref<number | null>(null);
const editingId = ref<number | null>(null); const editingId = ref<number | null>(null);
const reviewingId = ref<number | null>(null); const reviewingId = ref<number | null>(null);
const withdrawingId = ref<number | null>(null);
const ocrOpen = ref(false);
const attachmentError = ref("");
const modelConfig = ref<ModelWeightConfig | null>(null);
const modelConfigError = ref("");
const savingWeights = ref(false);
const weightDraft = reactive<Record<WeightDimension, number>>({
供应商表现: 25,
物料品类: 20,
产品品类: 15,
加工厂质量: 15,
标准符合性: 10,
节点阶段: 10,
缺陷等级: 5,
});
const frequencyCoefficient = ref(0.3);
const frequencyCap = ref(1.5);
const notes = reactive<Record<number, string>>({}); const notes = reactive<Record<number, string>>({});
const editForm = reactive<Record<number, RiskItemUpdate>>({}); const editForm = reactive<Record<number, RiskItemUpdate>>({});
@ -43,6 +72,40 @@ const preReviewCount = computed(
const sourceCount = computed( const sourceCount = computed(
() => new Set(store.items.map((item) => item.sourceType)).size, () => new Set(store.items.map((item) => item.sourceType)).size,
); );
const weightTotal = computed(() =>
Object.values(weightDraft).reduce(
(sum, value) => sum + Number(value || 0),
0,
),
);
const permissionRows = [
{ role: "管理员", assess: true, submit: true, review: true, weights: true },
{
role: "产品经理",
assess: true,
submit: true,
review: true,
weights: false,
},
{ role: "审核员", assess: true, submit: false, review: true, weights: false },
{
role: "user",
assess: true,
submit: false,
review: false,
weights: false,
},
];
const permissionKeys = ["assess", "submit", "review", "weights"] as const;
const weightRows: Array<{ dimension: WeightDimension; description: string }> = [
{ dimension: "供应商表现", description: "含频次风险系数 α 修正" },
{ dimension: "物料品类", description: "按物料历史异常率" },
{ dimension: "产品品类", description: "拆解到具体产品品类" },
{ dimension: "加工厂质量", description: "巡检 / 批次合格率" },
{ dimension: "节点阶段", description: "开发阶段越靠后风险越高" },
{ dimension: "缺陷等级", description: "A 级缺陷加权" },
{ dimension: "标准符合性", description: "按判定标准库量化达标率计分" },
];
function load() { function load() {
return store.loadPending({ return store.loadPending({
@ -50,6 +113,38 @@ function load() {
status: statusFilter.value, status: statusFilter.value,
}); });
} }
async function loadModelWeights() {
if (!auth.hasPermission("model_weight.manage")) return;
modelConfigError.value = "";
try {
const response = await fetchModelConfig();
if (!response.data.data) throw new Error("模型配置响应为空");
modelConfig.value = response.data.data;
Object.assign(weightDraft, response.data.data.weights);
frequencyCoefficient.value = response.data.data.frequencyCoefficient;
frequencyCap.value = response.data.data.frequencyCap;
} catch {
modelConfigError.value = "模型权重加载失败";
}
}
async function saveModelWeights() {
if (Math.abs(weightTotal.value - 100) > 0.01 || savingWeights.value) return;
savingWeights.value = true;
modelConfigError.value = "";
try {
const response = await updateModelConfig({
weights: { ...weightDraft },
frequencyCoefficient: frequencyCoefficient.value,
frequencyCap: frequencyCap.value,
});
if (!response.data.data) throw new Error("模型配置响应为空");
modelConfig.value = response.data.data;
} catch {
modelConfigError.value = "权重保存失败,请检查各维度合计是否为100%";
} finally {
savingWeights.value = false;
}
}
function sourceClass(source: string) { function sourceClass(source: string) {
return source === "OCR" return source === "OCR"
? "source-ocr" ? "source-ocr"
@ -71,6 +166,9 @@ function sourceIcon(source: string) {
function levelClass(level: string | null | undefined) { function levelClass(level: string | null | undefined) {
return level ? `level-${level.toLowerCase()}` : "level-empty"; return level ? `level-${level.toLowerCase()}` : "level-empty";
} }
function isOwnSubmission(item: RiskItem) {
return Boolean(auth.user?.empNo && item.submittedBy === auth.user.empNo);
}
function toggleItem(id: number) { function toggleItem(id: number) {
expandedId.value = expandedId.value === id ? null : id; expandedId.value = expandedId.value === id ? null : id;
if (editingId.value === id) editingId.value = null; if (editingId.value === id) editingId.value = null;
@ -81,6 +179,8 @@ function beginEdit(item: RiskItem) {
editForm[item.id] = { editForm[item.id] = {
productName: item.productName, productName: item.productName,
productCategory: item.productCategory, productCategory: item.productCategory,
materialType: item.materialType,
occurredOn: item.occurredOn,
supplier: item.supplier, supplier: item.supplier,
factory: item.factory, factory: item.factory,
stageNew: item.stageNew, stageNew: item.stageNew,
@ -89,6 +189,32 @@ function beginEdit(item: RiskItem) {
solution: item.solution, solution: item.solution,
}; };
} }
function ocrFiles(item: RiskItem) {
const files = item.ocrPayload?.files;
if (!Array.isArray(files)) return [];
return files.filter(
(file): file is { fileId: string; fileName: string } =>
typeof file === "object" &&
file !== null &&
typeof file.fileId === "string" &&
typeof file.fileName === "string",
);
}
function ocrJudgments(item: RiskItem): OcrStandardJudgment[] {
const judgments = item.ocrPayload?.confirmedJudgments;
return Array.isArray(judgments) ? (judgments as OcrStandardJudgment[]) : [];
}
async function openAttachment(fileId: string) {
attachmentError.value = "";
try {
const response = await downloadOcrFile(fileId);
const url = URL.createObjectURL(response.data);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch {
attachmentError.value = "附件打开失败或已被清理";
}
}
function editValue(id: number, key: keyof RiskItemUpdate) { function editValue(id: number, key: keyof RiskItemUpdate) {
return editForm[id]?.[key] ?? ""; return editForm[id]?.[key] ?? "";
} }
@ -115,10 +241,26 @@ async function review(item: RiskItem, action: "通过" | "驳回") {
try { try {
await store.review(item.id, action, note); await store.review(item.id, action, note);
expandedId.value = null; expandedId.value = null;
} catch {
// The exact API error is shown above the queue.
} finally { } finally {
reviewingId.value = null; reviewingId.value = null;
} }
} }
async function withdraw(item: RiskItem) {
if (withdrawingId.value !== null) return;
if (!window.confirm("确认撤回该风险项吗?撤回后将不再显示在审核队列中。"))
return;
withdrawingId.value = item.id;
try {
await store.withdraw(item.id);
if (expandedId.value === item.id) expandedId.value = null;
} catch {
// The exact API error is shown above the queue.
} finally {
withdrawingId.value = null;
}
}
function formatDate(value: string | null) { function formatDate(value: string | null) {
if (!value) return "未记录时间"; if (!value) return "未记录时间";
return new Intl.DateTimeFormat("zh-CN", { return new Intl.DateTimeFormat("zh-CN", {
@ -128,15 +270,18 @@ function formatDate(value: string | null) {
minute: "2-digit", minute: "2-digit",
}).format(new Date(value)); }).format(new Date(value));
} }
onMounted(() => void load()); onMounted(() => {
void load();
void loadModelWeights();
});
</script> </script>
<template> <template>
<div class="admin-page"> <div class="admin-page">
<header class="admin-header"> <header class="admin-header">
<div> <div>
<p class="eyebrow">ADMIN CONSOLE</p> <p class="eyebrow">RISK ITEM CONSOLE</p>
<h1>管理工作台</h1> <h1>风险项管理工作台</h1>
<p class="admin-description">风险项录入、审核与数据质量管理</p> <p class="admin-description">风险项录入、审核与数据质量管理</p>
</div> </div>
<div class="admin-header-actions"> <div class="admin-header-actions">
@ -151,6 +296,7 @@ onMounted(() => void load());
:class="{ spinning: store.loading }" :class="{ spinning: store.loading }"
/>刷新</button />刷新</button
><button ><button
v-if="auth.hasPermission('risk_item.submit')"
class="button button--primary" class="button button--primary"
type="button" type="button"
@click="router.push({ name: 'risk-item-entry' })" @click="router.push({ name: 'risk-item-entry' })"
@ -229,6 +375,7 @@ onMounted(() => void load());
<ClipboardCheck :size="28" /><strong>当前没有符合条件的风险项</strong <ClipboardCheck :size="28" /><strong>当前没有符合条件的风险项</strong
><span>新提交的风险项通过预审后会出现在这里。</span ><span>新提交的风险项通过预审后会出现在这里。</span
><button ><button
v-if="auth.hasPermission('risk_item.submit')"
class="button button--secondary" class="button button--secondary"
type="button" type="button"
@click="router.push({ name: 'risk-item-entry' })" @click="router.push({ name: 'risk-item-entry' })"
@ -243,8 +390,9 @@ onMounted(() => void load());
class="review-item" class="review-item"
:class="{ expanded: expandedId === item.id }" :class="{ expanded: expandedId === item.id }"
> >
<div class="review-summary">
<button <button
class="review-summary" class="review-summary__content"
type="button" type="button"
@click="toggleItem(item.id)" @click="toggleItem(item.id)"
> >
@ -258,7 +406,21 @@ onMounted(() => void load());
>{{ item.productCategory || "未分类" }} · >{{ item.productCategory || "未分类" }} ·
{{ formatDate(item.submittedAt || item.createdAt) }}</small {{ formatDate(item.submittedAt || item.createdAt) }}</small
></span ></span
><span class="review-meta" >
</button>
<button
v-if="item.status === '待审核' || item.status === '待预审'"
class="review-withdraw"
type="button"
:disabled="withdrawingId !== null"
title="撤回风险项"
@click="withdraw(item)"
>
<Undo2 :size="12" />{{
withdrawingId === item.id ? "撤回中" : "撤回"
}}
</button>
<span class="review-meta"
><span ><span
class="level-tag" class="level-tag"
:class="levelClass(item.defectLevel)" :class="levelClass(item.defectLevel)"
@ -270,11 +432,17 @@ onMounted(() => void load());
:class="item.status === '待审核' ? 'is-pending' : 'is-pre'" :class="item.status === '待审核' ? 'is-pending' : 'is-pre'"
>{{ item.status }}</span >{{ item.status }}</span
></span ></span
><ChevronUp >
v-if="expandedId === item.id" <button
:size="17" class="review-toggle"
/><ChevronDown v-else :size="17" /> type="button"
:aria-label="expandedId === item.id ? '收起风险项' : '展开风险项'"
@click="toggleItem(item.id)"
>
<ChevronUp v-if="expandedId === item.id" :size="17" />
<ChevronDown v-else :size="17" />
</button> </button>
</div>
<div v-if="expandedId === item.id" class="review-detail"> <div v-if="expandedId === item.id" class="review-detail">
<template v-if="editingId === item.id" <template v-if="editingId === item.id"
><div class="edit-grid"> ><div class="edit-grid">
@ -298,6 +466,27 @@ onMounted(() => void load());
($event.target as HTMLInputElement).value, ($event.target as HTMLInputElement).value,
) )
" /></label " /></label
><label
>物料<input
:value="editValue(item.id, 'materialType')"
@input="
setEditValue(
item.id,
'materialType',
($event.target as HTMLInputElement).value,
)
" /></label
><label
>发生日期<input
type="date"
:value="editValue(item.id, 'occurredOn')"
@input="
setEditValue(
item.id,
'occurredOn',
($event.target as HTMLInputElement).value,
)
" /></label
><label ><label
>供应商<input >供应商<input
:value="editValue(item.id, 'supplier')" :value="editValue(item.id, 'supplier')"
@ -395,6 +584,14 @@ onMounted(() => void load());
<dt>产品品类</dt> <dt>产品品类</dt>
<dd>{{ item.productCategory || "未填写" }}</dd> <dd>{{ item.productCategory || "未填写" }}</dd>
</div> </div>
<div>
<dt>物料</dt>
<dd>{{ item.materialType || "未填写" }}</dd>
</div>
<div>
<dt>发生日期</dt>
<dd>{{ item.occurredOn || "未填写" }}</dd>
</div>
<div> <div>
<dt>供应商</dt> <dt>供应商</dt>
<dd>{{ item.supplier || "未填写" }}</dd> <dd>{{ item.supplier || "未填写" }}</dd>
@ -426,10 +623,54 @@ onMounted(() => void load());
<strong>解决方案</strong> <strong>解决方案</strong>
<p>{{ item.solution }}</p> <p>{{ item.solution }}</p>
</div> </div>
<div v-if="item.photos?.length" class="attachment-note"> <div v-if="ocrFiles(item).length" class="attachment-list">
<ImagePlus :size="14" />已关联 {{ item.photos.length }} 个附件 <button
v-for="file in ocrFiles(item)"
:key="file.fileId"
type="button"
title="通过鉴权打开原始附件"
@click="openAttachment(file.fileId)"
>
<FileText :size="14" />{{ file.fileName }}
</button>
</div>
<p v-if="attachmentError" class="form-error">
{{ attachmentError }}
</p>
<div v-if="ocrJudgments(item).length" class="ocr-judgments">
<strong>OCR 标准判定</strong>
<div
v-for="judgment in ocrJudgments(item)"
:key="`${judgment.measurementItem}-${judgment.standardRuleId}`"
>
<span
>{{ judgment.measurementItem }}:{{
judgment.measuredValue
}}</span
>
<span>{{ judgment.standardValue || "未匹配标准" }}</span>
<b
:class="
judgment.result === '不合格'
? 'is-fail'
: judgment.result === '合格'
? 'is-pass'
: 'is-review'
"
>{{ judgment.result }}</b
>
<small v-if="judgment.standardRuleId"
>标准 #{{ judgment.standardRuleId }}
{{ judgment.basis || "" }}</small
>
</div>
</div> </div>
<div class="review-controls"> <div class="review-controls">
<p v-if="isOwnSubmission(item)" class="self-review-warning">
<AlertTriangle
:size="14"
/>该风险项由当前账号提交。根据审核防自审规则,请由其他审核员或管理者处理。
</p>
<textarea <textarea
v-model="notes[item.id]" v-model="notes[item.id]"
rows="2" rows="2"
@ -451,6 +692,7 @@ onMounted(() => void load());
type="button" type="button"
:disabled=" :disabled="
item.status !== '待审核' || item.status !== '待审核' ||
isOwnSubmission(item) ||
reviewingId !== null || reviewingId !== null ||
(notes[item.id]?.trim().length ?? 0) < 2 (notes[item.id]?.trim().length ?? 0) < 2
" "
@ -462,6 +704,7 @@ onMounted(() => void load());
type="button" type="button"
:disabled=" :disabled="
item.status !== '待审核' || item.status !== '待审核' ||
isOwnSubmission(item) ||
reviewingId !== null || reviewingId !== null ||
(notes[item.id]?.trim().length ?? 0) < 2 (notes[item.id]?.trim().length ?? 0) < 2
" "
@ -477,14 +720,17 @@ onMounted(() => void load());
</div> </div>
</section> </section>
<aside class="admin-tools"> <aside class="admin-tools">
<article class="tool-card"> <article
v-if="auth.hasPermission('risk_item.submit')"
class="tool-card"
>
<header> <header>
<span class="tool-icon tool-icon--blue" <span class="tool-icon tool-icon--blue"
><ClipboardCheck :size="16" ><ClipboardCheck :size="16"
/></span> /></span>
<div> <div>
<h3>风险项录入</h3> <h3>风险项表单录入</h3>
<p>表单录入并提交审核</p> <p>填写风险项并提交审核</p>
</div> </div>
</header> </header>
<button <button
@ -492,49 +738,53 @@ onMounted(() => void load());
type="button" type="button"
@click="router.push({ name: 'risk-item-entry' })" @click="router.push({ name: 'risk-item-entry' })"
> >
<Plus :size="14" />打开录入表单 <Plus :size="14" />新建风险项
</button> </button>
</article> </article>
<article class="tool-card"> <article
v-if="auth.hasPermission('risk_item.submit')"
class="tool-card"
>
<header> <header>
<span class="tool-icon tool-icon--cyan" <span class="tool-icon tool-icon--cyan"
><ImagePlus :size="16" ><ImagePlus :size="16"
/></span> /></span>
<div> <div>
<h3>OCR 识别录入</h3> <h3>风险项 OCR 录入</h3>
<p>上传检测报告或缺陷照片</p> <p>识别报告并生成待审核风险项</p>
</div> </div>
</header> </header>
<label class="upload-zone" <button class="tool-link" type="button" @click="ocrOpen = true">
><Upload :size="17" /><span>选择图片 / PDF</span <Upload :size="14" />识别并录入风险项
><small>识别结果将填入风险项表单</small </button>
><input type="file" accept="image/*,.pdf"
/></label>
</article> </article>
<article class="tool-card"> <article v-if="featureFlags.voiceEntry" class="tool-card">
<header> <header>
<span class="tool-icon tool-icon--purple"><Mic :size="16" /></span> <span class="tool-icon tool-icon--purple"><Mic :size="16" /></span>
<div> <div>
<h3>语音录入</h3> <h3>风险项语音录入</h3>
<p>口述异常信息,自动提取字段</p> <p>口述异常并生成待审核风险项</p>
</div> </div>
</header> </header>
<button class="voice-entry" type="button"> <button class="voice-entry" type="button">
<Mic :size="17" /><span>按住说话</span></button <Mic :size="17" /><span>按住说话</span></button
><small class="tool-hint">普通话 · 最长 60 秒</small> ><small class="tool-hint">普通话 · 最长 60 秒</small>
</article> </article>
<article class="tool-card"> <article
v-if="auth.hasPermission('risk_item.submit')"
class="tool-card"
>
<header> <header>
<span class="tool-icon tool-icon--green" <span class="tool-icon tool-icon--green"
><FileSpreadsheet :size="16" ><FileSpreadsheet :size="16"
/></span> /></span>
<div> <div>
<h3>Excel 批量导入</h3> <h3>风险项 Excel 批量导入</h3>
<p>按模板批量进入审核队列</p> <p>按模板批量生成待审核风险项</p>
</div> </div>
</header> </header>
<button class="tool-link" type="button"> <button class="tool-link" type="button">
<Upload :size="14" />上传 Excel 文件</button <Upload :size="14" />批量导入风险项</button
><small class="tool-hint">单次最多 1000 条,自动去重校验</small> ><small class="tool-hint">单次最多 1000 条,自动去重校验</small>
</article> </article>
<article class="tool-card config-card"> <article class="tool-card config-card">
@ -556,6 +806,135 @@ onMounted(() => void load());
</article> </article>
</aside> </aside>
</div> </div>
<div
v-if="
featureFlags.adminConfigurationPanels &&
auth.hasPermission('model_weight.manage')
"
class="administration-config"
>
<section class="admin-config-panel">
<header class="admin-config-heading">
<span><Users :size="17" /></span>
<div>
<h2>用户权限(含审核角色)</h2>
<p>四角色权限矩阵</p>
</div>
</header>
<div class="config-table-wrap">
<table class="config-table">
<thead>
<tr>
<th>角色</th>
<th>评估/查询</th>
<th>录入/上传</th>
<th>审核</th>
<th>权重配置</th>
</tr>
</thead>
<tbody>
<tr v-for="row in permissionRows" :key="row.role">
<td>{{ row.role }}</td>
<td v-for="key in permissionKeys" :key="key">
<Check v-if="row[key]" class="permission-yes" :size="15" />
<span v-else class="permission-no">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="admin-config-panel">
<header class="admin-config-heading">
<span><SlidersHorizontal :size="17" /></span>
<div>
<h2>模型权重配置</h2>
<p>七维模型,保存后对新评估实时生效</p>
</div>
<strong>七维 · 合计 {{ weightTotal }}%</strong>
</header>
<div class="config-table-wrap">
<table class="config-table weight-table">
<thead>
<tr>
<th>维度</th>
<th>权重</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in weightRows" :key="row.dimension">
<td>{{ row.dimension }}</td>
<td>
<label
><input
v-model.number="weightDraft[row.dimension]"
type="number"
min="1"
max="100"
step="0.5"
/><span>%</span></label
>
</td>
<td>{{ row.description }}</td>
</tr>
<tr class="frequency-row">
<td>频次风险系数</td>
<td colspan="2">
<label
>α = 1.0 + (N / 100) ×
<input
v-model.number="frequencyCoefficient"
type="number"
min="0"
max="1"
step="0.05"
/></label>
<label
>上限
<input
v-model.number="frequencyCap"
type="number"
min="1"
max="3"
step="0.1"
/></label>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="config-actions">
<p v-if="modelConfigError" class="form-error">
{{ modelConfigError }}
</p>
<span v-else-if="modelConfig?.updatedBy"
>最近更新:{{ modelConfig.updatedBy }}</span
>
<span v-else>权重合计必须为 100%</span>
<button
class="button button--primary"
type="button"
:disabled="savingWeights || Math.abs(weightTotal - 100) > 0.01"
@click="saveModelWeights"
>
<LoaderCircle
v-if="savingWeights"
class="spinning"
:size="14"
/><Save v-else :size="14" />{{
savingWeights ? "保存中…" : "保存配置"
}}
</button>
</footer>
</section>
</div>
<OcrRecognitionDialog
:open="ocrOpen"
@close="ocrOpen = false"
@submitted="load"
/>
</div> </div>
</template> </template>
@ -726,16 +1105,68 @@ onMounted(() => void load());
.review-summary { .review-summary {
width: 100%; width: 100%;
gap: 11px; gap: 11px;
border: 0; padding: 8px 5px;
padding: 14px 5px;
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-text);
text-align: left;
} }
.review-summary:hover, .review-summary:hover,
.review-item.expanded .review-summary { .review-item.expanded .review-summary {
background: var(--color-bg-warm); background: var(--color-bg-warm);
} }
.review-summary__content {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 11px;
border: 0;
padding: 6px 0;
background: transparent;
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.review-withdraw {
display: inline-flex;
flex: 0 0 auto;
min-height: 26px;
align-items: center;
gap: 4px;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0 8px;
background: var(--color-surface);
color: var(--color-text-muted);
font: inherit;
font-size: 10px;
cursor: pointer;
}
.review-withdraw:hover:not(:disabled) {
border-color: #fecaca;
background: #fef2f2;
color: var(--color-danger);
}
.review-withdraw:disabled {
cursor: wait;
opacity: 0.55;
}
.review-toggle {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
border: 0;
border-radius: 5px;
padding: 0;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.review-toggle:hover {
background: var(--color-surface);
}
.source-badge { .source-badge {
display: inline-flex; display: inline-flex;
min-width: 52px; min-width: 52px;
@ -850,19 +1281,86 @@ onMounted(() => void load());
line-height: 1.6; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
} }
.attachment-note { .attachment-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.attachment-list button {
display: inline-flex; display: inline-flex;
min-width: 0;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
margin-top: 12px; border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 5px 8px;
background: white;
color: var(--color-accent); color: var(--color-accent);
font-size: 10px; font-size: 10px;
cursor: pointer;
}
.ocr-judgments {
display: grid;
gap: 7px;
margin-top: 14px;
border: 1px solid var(--color-border-light);
border-radius: 5px;
padding: 10px;
}
.ocr-judgments > strong {
font-size: 11px;
}
.ocr-judgments > div {
display: grid;
grid-template-columns: minmax(100px, 0.8fr) minmax(180px, 1.5fr) auto;
gap: 6px 10px;
align-items: start;
border-top: 1px solid var(--color-border-light);
padding-top: 7px;
color: var(--color-text-secondary);
font-size: 10px;
}
.ocr-judgments b {
border-radius: 3px;
padding: 2px 5px;
font-size: 10px;
}
.ocr-judgments b.is-pass {
background: var(--color-success-soft);
color: var(--color-success);
}
.ocr-judgments b.is-fail {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.ocr-judgments b.is-review {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.ocr-judgments small {
grid-column: 1 / -1;
color: var(--color-text-faint);
} }
.review-controls { .review-controls {
align-items: flex-end; align-items: flex-end;
flex-wrap: wrap;
gap: 10px; gap: 10px;
margin-top: 17px; margin-top: 17px;
} }
.self-review-warning {
display: flex;
width: 100%;
align-items: flex-start;
gap: 6px;
margin: 0;
border-left: 3px solid var(--color-warning);
padding: 7px 9px;
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: 11px;
line-height: 1.5;
}
.review-controls textarea { .review-controls textarea {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
@ -1084,6 +1582,111 @@ onMounted(() => void load());
color: var(--color-success); color: var(--color-success);
font-size: 10px; font-size: 10px;
} }
.administration-config {
display: grid;
gap: 14px;
margin-top: 18px;
}
.admin-config-panel {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
padding: 18px 20px;
}
.admin-config-heading {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 13px;
}
.admin-config-heading > span {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 5px;
background: var(--color-bg);
color: var(--color-accent);
}
.admin-config-heading h2,
.admin-config-heading p {
margin: 0;
}
.admin-config-heading h2 {
font-size: 14px;
}
.admin-config-heading p {
margin-top: 2px;
color: var(--color-text-muted);
font-size: 10px;
}
.admin-config-heading > strong {
margin-left: auto;
color: var(--color-accent);
font-size: 10px;
}
.config-table-wrap {
overflow-x: auto;
}
.config-table {
width: 100%;
min-width: 600px;
border-collapse: collapse;
font-size: 11px;
}
.config-table th,
.config-table td {
border-bottom: 1px solid var(--color-border-light);
padding: 9px 10px;
text-align: left;
}
.config-table th {
background: var(--color-bg);
color: var(--color-text-muted);
font-weight: 650;
}
.permission-yes {
color: var(--color-success);
}
.permission-no {
color: var(--color-danger);
}
.weight-table input {
width: 58px;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
padding: 5px;
font: inherit;
font-size: 11px;
}
.weight-table label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
}
.weight-table label + label {
margin-left: 14px;
}
.frequency-row td:first-child {
color: #7c3aed;
font-weight: 650;
}
.config-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding-top: 14px;
color: var(--color-text-muted);
font-size: 10px;
}
.config-actions .form-error {
margin: 0 auto 0 0;
}
.config-actions .button {
min-height: 34px;
}
.spinning { .spinning {
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }

View File

@ -1,15 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios"; import axios from "axios";
import { computed, nextTick, onMounted, ref, watch } from "vue"; import { ChevronDown } from "@lucide/vue";
import { computed, nextTick, onActivated, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import AssistantComposer from "@/components/assistant/AssistantComposer.vue"; import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue"; import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue";
import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue"; import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue";
import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue"; import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue";
import OcrRecognitionDialog from "@/components/ocr/OcrRecognitionDialog.vue";
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 type { ReportCard, RiskAssessmentResult } from "@/types/risk";
import type { AssistantResponseCard as AssistantResponseCardData } from "@/types/conversation";
import {
readConversationReport,
writeConversationReport,
} from "@/utils/conversationReports";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -18,10 +27,14 @@ 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 scrollArea = ref<HTMLElement | null>(null); const scrollArea = ref<HTMLElement | null>(null);
const composer = ref<InstanceType<typeof AssistantComposer> | null>(null);
const tool = ref< const tool = ref<
| "assessment" | "assessment"
| "supplier" | "supplier"
@ -35,6 +48,12 @@ const tool = ref<
const toolInput = ref(""); const toolInput = ref("");
const toolError = ref(""); const toolError = ref("");
const toolFile = ref<File | null>(null); const toolFile = ref<File | null>(null);
const directResult = ref<Record<string, unknown> | null>(null);
const directLoading = ref(false);
const directUserPrompt = ref("");
const directResponseCard = ref<AssistantResponseCardData | null>(null);
const expandedReplies = ref(new Set<number>());
const primaryReportReady = ref(false);
const assessmentFields = ref({ const assessmentFields = ref({
category: "", category: "",
specification: "", specification: "",
@ -64,12 +83,41 @@ const toolLabel = computed(() => {
if (tool.value === "cases") return "案例关键词"; if (tool.value === "cases") return "案例关键词";
return "方案主题"; return "方案主题";
}); });
const toolDescription = computed(() => {
if (tool.value === "assessment")
return "填写品类、包装材质(必填),可补充规格、供应商、加工厂和开发阶段。";
if (tool.value === "material")
return "输入物料名称或组合,例如:软管、PET瓶、铝瓶+喷头、泵头。";
if (tool.value === "cases")
return "描述要检索的异常现象,例如:软管印刷遇水脱落、泵头漏液。";
if (tool.value === "solution")
return "描述需要生成方案的新品、品类、材质或风险问题,例如:铝瓶+喷头的密封验证方案。";
return "填写必要信息后,将生成一条带上下文的对话请求。";
});
const toolPlaceholder = computed(() => {
if (tool.value === "material") return "如:软管 / PET瓶 / 铝瓶+喷头";
if (tool.value === "cases") return "如:软管印刷遇水脱落,如何处理";
if (tool.value === "solution")
return "如:次氯酸消毒喷雾,铝瓶+喷头,生成验证方案";
return "请输入内容";
});
const toolSubmitLabel = computed(() =>
["supplier", "material", "cases"].includes(tool.value ?? "")
? directLoading.value
? "查询中…"
: "查询"
: tool.value === "solution"
? directLoading.value
? "生成中…"
: "生成方案"
: "提交",
);
const loadingText = computed(() => const loadingText = computed(() =>
loading.value ? "正在调用风险评分引擎…" : "", loading.value ? "正在调用风险评分引擎…" : "",
); );
const canMonitor = computed( const canMonitor = computed(
() => auth.user?.role === "admin" || auth.user?.role === "manager", () => auth.user?.role === "admin" || auth.user?.role === "product_manager",
); );
onMounted(async () => { onMounted(async () => {
@ -77,16 +125,61 @@ onMounted(async () => {
await openFromRoute(); await openFromRoute();
}); });
onActivated(async () => {
// MainLayout keeps this page alive. Returning from report/dashboard therefore
// does not remount it, so explicitly reveal the newest message on re-entry.
if (conversation.value?.messages.length) await scrollToBottom("auto");
refreshPrimaryReportState();
});
watch( watch(
() => route.query.conversation, () => route.query.conversation,
async () => { async () => {
await openFromRoute(); await openFromRoute();
refreshPrimaryReportState();
}, },
); );
function usePrompt(value: string) { watch(
prompt.value = value; () => conversation.value?.id,
void submit(); () => refreshPrimaryReportState(),
);
async function usePrompt(value: string) {
const current = prompt.value.trim();
prompt.value = current ? `${current}\n${value}` : value;
await nextTick();
composer.value?.focus();
}
function toggleReply(messageId: number) {
const next = new Set(expandedReplies.value);
if (next.has(messageId)) next.delete(messageId);
else next.add(messageId);
expandedReplies.value = next;
}
function isMessageThinking(messageId: number, index: number) {
const messages = conversation.value?.messages ?? [];
return (
generating.value &&
index === messages.length - 1 &&
messages[index]?.id === messageId &&
messages[index]?.role === "assistant"
);
}
function thinkingDuration(index: number) {
const messages = conversation.value?.messages ?? [];
const message = messages[index];
const previous = messages[index - 1];
if (!message || !previous || previous.role !== "user") return null;
const elapsed = Math.round(
(new Date(message.createdAt).getTime() -
new Date(previous.createdAt).getTime()) /
1000,
);
return elapsed > 0 ? elapsed : null;
} }
function openTool( function openTool(
@ -104,10 +197,23 @@ function openTool(
router.push({ name: "review" }); router.push({ name: "review" });
return; return;
} }
if (value === "supplier") {
tool.value = "supplier";
toolInput.value = "";
toolFile.value = null;
toolError.value = "";
directResult.value = null;
return;
}
if (value === "ocr") {
ocrOpen.value = true;
return;
}
tool.value = value; tool.value = value;
toolInput.value = ""; toolInput.value = "";
toolFile.value = null; toolFile.value = null;
toolError.value = ""; toolError.value = "";
directResult.value = null;
assessmentFields.value = { assessmentFields.value = {
category: "", category: "",
specification: "", specification: "",
@ -121,9 +227,32 @@ function openTool(
function closeTool() { function closeTool() {
tool.value = null; tool.value = null;
toolError.value = ""; toolError.value = "";
directResult.value = null;
} }
function submitTool() { async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) {
directLoading.value = true;
toolError.value = "";
try {
const response = await directConversationQuery(kind, query);
directResult.value = response.data.data ?? null;
if (response.data.data) {
directUserPrompt.value = query;
directResponseCard.value = response.data.data as AssistantResponseCardData;
closeTool();
await nextTick();
await scrollToBottom();
}
} catch (reason) {
toolError.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "查询失败")
: reason instanceof Error ? reason.message : "查询失败";
} finally {
directLoading.value = false;
}
}
async function submitTool() {
const selectedTool = tool.value; const selectedTool = tool.value;
const selectedTitle = toolTitle.value; const selectedTitle = toolTitle.value;
if (selectedTool === "assessment") { if (selectedTool === "assessment") {
@ -142,21 +271,39 @@ function submitTool() {
] ]
.filter(Boolean) .filter(Boolean)
.join(","); .join(",");
} else if (selectedTool === "material") {
const value = toolInput.value.trim();
if (!value) {
toolError.value = "请输入要查询的物料名称";
return;
}
toolInput.value = value;
} else if (selectedTool === "cases") {
const value = toolInput.value.trim();
if (!value) {
toolError.value = "请输入要检索的异常现象或关键词";
return;
}
toolInput.value = value;
} else if (selectedTool === "solution") {
const value = toolInput.value.trim();
if (!value) {
toolError.value = "请输入需要生成方案的产品或风险问题";
return;
}
toolInput.value = value;
} }
const finalValue = toolInput.value.trim(); const finalValue = toolInput.value.trim();
if (!finalValue && !toolFile.value) { if (!finalValue && !toolFile.value) {
toolError.value = "请先填写必填内容"; toolError.value = "请先填写必填内容";
return; return;
} }
if (["supplier", "material", "cases", "solution"].includes(selectedTool ?? "")) {
await submitDirectTool(selectedTool as "supplier" | "material" | "cases" | "solution", finalValue);
return;
}
closeTool(); closeTool();
prompt.value = void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`);
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`;
const messageType = toolFile.value
? selectedTool === "ocr"
? "image"
: "audio"
: "text";
void submit(messageType);
} }
function selectFile(event: Event) { function selectFile(event: Event) {
@ -180,20 +327,47 @@ 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();
}
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;
const id = Number(rawId); const id = Number(rawId);
if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id !== id) { if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id === id) {
await scrollToBottom("auto");
return;
}
if (Number.isSafeInteger(id) && id > 0) {
try { try {
await conversationStore.open(id); await conversationStore.open(id);
await scrollToBottom("auto"); await scrollToBottom("auto");
@ -207,13 +381,113 @@ async function openFromRoute() {
} }
} }
/**
* Vue/Pinia exposes conversation results as reactive proxies. Those proxies
* cannot be passed to structuredClone (the browser reports DataCloneError),
* while the report payload only contains JSON data. Serialising here also
* makes the value safe to put into sessionStorage.
*/
function cloneReport<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function legacySolutionCards(result: RiskAssessmentResult): ReportCard[] {
return (result.aiSolutionCards ?? []).map((item) => ({
type: "solution" as const,
title: item.title || "AI 案例推理生成",
confidence: item.confidence,
analysisBase: item.analysisBase,
standardFallbackEnabled: item.standardFallbackEnabled,
standards: item.standards,
similarCases: item.similarCases,
risks: item.risks,
suggestions: item.suggestions,
warning: item.warning,
}));
}
function isPrimaryRiskReport(
report: RiskAssessmentResult | null,
): report is RiskAssessmentResult {
return Boolean(
report &&
(report.dimensionScores?.length ||
report.factors?.length ||
report.categoryWarning ||
report.supplierFreq),
);
}
function refreshPrimaryReportState() {
const conversationId = conversation.value?.id;
primaryReportReady.value = Boolean(
conversationId &&
isPrimaryRiskReport(
readConversationReport(conversationId, auth.user?.id),
),
);
}
function openReport(result: RiskAssessmentResult) {
const conversationId = conversation.value?.id;
if (!conversationId) return;
const stored = readConversationReport(conversationId, auth.user?.id);
const storedCards = stored
? stored.reportCards?.length
? stored.reportCards
: legacySolutionCards(stored)
: [];
const merged: RiskAssessmentResult =
stored?.subjectId === result.subjectId
? {
...cloneReport(result),
reportCards: storedCards.length ? storedCards : result.reportCards ?? [],
aiSolutionCards: stored.aiSolutionCards?.length
? stored.aiSolutionCards
: result.aiSolutionCards ?? [],
}
: cloneReport(result);
writeConversationReport(conversationId, merged, auth.user?.id);
primaryReportReady.value = true;
void router.push({
name: "report",
query: { conversation: String(conversationId) },
});
}
function openSolutionReport(card: ReportCard) {
const conversationId = conversation.value?.id;
if (!conversationId || card.type !== "solution" || !primaryReportReady.value)
return;
const stored = readConversationReport(conversationId, auth.user?.id);
if (!isPrimaryRiskReport(stored)) return;
const base = cloneReport(stored);
const existingCards = base.reportCards ?? [];
const merged: RiskAssessmentResult = {
...base,
// All newly appended cards use one canonical collection. Migrate old
// AI-only entries once so they are not lost when the first generic card
// is appended to an existing session.
aiSolutionCards: base.aiSolutionCards,
reportCards: [
...(existingCards.length ? existingCards : legacySolutionCards(base)),
card,
],
};
writeConversationReport(conversationId, merged, auth.user?.id);
void router.push({
name: "report",
query: { conversation: String(conversationId) },
});
}
async function submit( 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) return;
prompt.value = ""; prompt.value = "";
await conversationStore.streamSend( const sentConversation = await conversationStore.streamSend(
value, value,
(delta) => { (delta) => {
void delta; void delta;
@ -221,15 +495,16 @@ async function submit(
messageType, messageType,
); );
if ( if (
conversation.value && sentConversation &&
route.query.conversation !== String(conversation.value.id) conversation.value?.id === sentConversation.id &&
route.query.conversation !== String(sentConversation.id)
) { ) {
await router.replace({ await router.replace({
name: "assessment", name: "assessment",
query: { conversation: String(conversation.value.id) }, query: { conversation: String(sentConversation.id) },
}); });
} }
await scrollToBottom(); if (conversation.value?.id === sentConversation?.id) await scrollToBottom();
} }
async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") { async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
@ -249,21 +524,18 @@ 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"
:can-upload="auth.hasPermission('risk_item.submit')"
@select="usePrompt" @select="usePrompt"
@tool="openTool"
/> />
<template <template
v-for="message in conversation?.messages ?? []" v-for="(message, messageIndex) in conversation?.messages ?? []"
:key="message.id" :key="message.id"
> >
<div class="message" :class="`message--${message.role}`"> <div class="message" :class="`message--${message.role}`">
@ -277,6 +549,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
@ -296,23 +584,53 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
> >
</div> </div>
<div <div
v-if=" v-if="message.content || isMessageThinking(message.id, messageIndex)"
message.content && class="assistant-thinking"
!message.riskResult && >
!message.responseCard <button
" type="button"
class="assistant-thinking__header"
@click="toggleReply(message.id)"
>
<span
v-if="isMessageThinking(message.id, messageIndex)"
class="assistant-thinking__status"
>思考中</span
>
<span v-else>
已完成<span v-if="thinkingDuration(messageIndex)" class="assistant-thinking__duration">
{{ thinkingDuration(messageIndex) }}s</span
>
</span>
<ChevronDown
:size="13"
class="assistant-thinking__arrow"
:class="{ expanded: expandedReplies.has(message.id) }"
/>
</button>
<div
v-if="message.content"
class="assistant-bubble" class="assistant-bubble"
:class="{
'assistant-bubble--collapsed':
!expandedReplies.has(message.id),
}"
> >
{{ message.content }} {{ message.content }}
</div> </div>
</div>
<RiskAnalysisCard <RiskAnalysisCard
v-if="message.riskResult" v-if="message.riskResult"
:result="message.riskResult" :result="message.riskResult"
:can-monitor="canMonitor" :can-monitor="canMonitor"
@generate-report="openReport(message.riskResult)"
/> />
<AssistantResponseCard <AssistantResponseCard
v-if="message.responseCard" v-if="message.responseCard"
:card="message.responseCard" :card="message.responseCard"
:can-append-report="primaryReportReady"
@generate-report="openSolutionReport"
@open-assessment="openTool('assessment')"
/> />
<time class="message-time">{{ <time class="message-time">{{
formatMessageTime(message.createdAt) formatMessageTime(message.createdAt)
@ -321,10 +639,35 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</div> </div>
</template> </template>
<template v-if="directResponseCard">
<div class="message message--user">
<div class="message-avatar">{{ auth.user?.name?.slice(0, 1) ?? "我" }}</div>
<div class="user-bubble">{{ directUserPrompt }}</div>
</div>
<div class="message message--assistant">
<div class="message-avatar">蝶</div>
<div class="message-body">
<AssistantResponseCard
:card="directResponseCard"
:can-append-report="primaryReportReady"
@generate-report="openSolutionReport"
@open-assessment="openTool('assessment')"
/>
</div>
</div>
</template>
<div v-if="loading" class="message message--assistant"> <div v-if="loading" class="message message--assistant">
<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">
@ -334,8 +677,11 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</div> </div>
<AssistantComposer <AssistantComposer
ref="composer"
v-model="prompt" v-model="prompt"
:loading="loading" :loading="loading"
:can-upload="auth.hasPermission('risk_item.submit')"
:can-review="auth.hasPermission('risk_item.review')"
@submit="submit" @submit="submit"
@select="usePrompt" @select="usePrompt"
@tool="openTool" @tool="openTool"
@ -354,9 +700,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
</button> </button>
<p class="eyebrow">CHAT TOOL</p> <p class="eyebrow">CHAT TOOL</p>
<h2>{{ toolTitle }}</h2> <h2>{{ toolTitle }}</h2>
<p class="dialog-description"> <p class="dialog-description">{{ toolDescription }}</p>
填写必要信息后,将生成一条带上下文的对话请求。
</p>
<div v-if="tool === 'assessment'" class="assessment-fields"> <div v-if="tool === 'assessment'" class="assessment-fields">
<label <label
>产品品类<input v-model="assessmentFields.category" autofocus >产品品类<input v-model="assessmentFields.category" autofocus
@ -389,6 +733,7 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
v-if="tool !== 'ocr' && tool !== 'voice'" v-if="tool !== 'ocr' && tool !== 'voice'"
v-model="toolInput" v-model="toolInput"
:maxlength="tool === 'cases' ? 50 : 2000" :maxlength="tool === 'cases' ? 50 : 2000"
:placeholder="toolPlaceholder"
autofocus autofocus
/> />
<input <input
@ -403,14 +748,55 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
/> />
</label> </label>
<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">
<template v-if="tool === 'supplier'">
<span class="direct-tier">{{ directResult.tier || "新供应商" }}</span>
<div class="direct-lines">
<p>近 3 年异常 <b>N={{ directResult.anomalyCount ?? 0 }}</b>(代理合作频次口径)</p>
<p>基础分 <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>
</template>
<template v-else-if="tool === 'material'">
<strong>{{ directResult.title || "物料风险分析" }}</strong>
<p>物料:<b>{{ directResult.material || directResult.query }}</b> · 历史异常 {{ 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>
</template>
<template v-else-if="tool === 'cases'">
<strong>相似案例检索</strong>
<p>关键词:{{ directResult.query }} · 匹配 {{ directResult.matchedCount ?? 0 }} 条</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>
</ul>
</template>
<template v-else>
<strong>{{ directResult.title || "AI 方案生成" }}</strong>
<p>{{ 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>
</template>
</section>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="closeTool"> <button type="button" class="dialog-secondary" @click="closeTool">
取消 取消
</button> </button>
<button type="submit" class="dialog-primary">带入对话</button> <button type="submit" class="dialog-primary" :disabled="directLoading">
{{ toolSubmitLabel }}
</button>
</div> </div>
</form> </form>
</div> </div>
<OcrRecognitionDialog :open="ocrOpen" @close="ocrOpen = false" />
</div> </div>
</template> </template>
@ -440,15 +826,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 {
@ -502,7 +884,47 @@ 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 {
position: relative;
width: fit-content; width: fit-content;
max-width: 82%; max-width: 82%;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -514,6 +936,71 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
line-height: 1.65; line-height: 1.65;
} }
.assistant-bubble--collapsed {
display: -webkit-box;
max-height: calc(2 * 1.65em + 22px);
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.assistant-thinking {
margin-bottom: 8px;
}
.assistant-thinking__header {
display: flex;
width: auto;
align-items: center;
gap: 4px;
border: 0;
margin: 0 0 6px;
padding: 3px 7px;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
font-size: 11px;
font-family: inherit;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.assistant-thinking__header:hover {
background: var(--color-bg-warm);
color: var(--color-text-secondary);
}
.assistant-thinking__arrow {
color: var(--color-text-faint);
transition: transform 0.18s ease;
}
.assistant-thinking__arrow.expanded {
transform: rotate(180deg);
}
.assistant-thinking__status::before {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
margin-right: 5px;
background: var(--color-accent);
content: "";
animation: thinking-pulse 1.1s ease-in-out infinite;
}
.assistant-thinking__duration {
margin-left: 3px;
color: var(--color-text-faint);
}
@keyframes thinking-pulse {
50% {
opacity: 0.35;
}
}
.semantic-strip { .semantic-strip {
margin-bottom: 8px; margin-bottom: 8px;
color: var(--color-text-muted); color: var(--color-text-muted);
@ -683,6 +1170,52 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
color: white; color: white;
} }
.direct-result-card {
margin-top: 14px;
border: 1px solid var(--color-border);
border-radius: 9px;
padding: 16px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.75;
}
.direct-result-card p {
margin: 3px 0;
}
.direct-result-card strong {
display: block;
margin-bottom: 8px;
color: var(--color-text);
font-size: 14px;
}
.direct-tier {
display: inline-block !important;
border-radius: 999px;
padding: 3px 10px;
background: var(--color-warning-soft);
color: #92400e !important;
font-size: 11px !important;
}
.direct-lines {
margin-top: 8px;
}
.direct-result-card ul,
.direct-result-card ol {
margin: 8px 0 0;
padding-left: 20px;
}
.direct-case-list li,
.direct-suggestion-list li {
margin: 3px 0;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.conversation-column { .conversation-column {
padding-inline: 12px; padding-inline: 12px;

View File

@ -11,8 +11,10 @@ const reviewStore = useReviewStore();
const submitted = ref(false); const submitted = ref(false);
const form = reactive<RiskItemCreate>({ const form = reactive<RiskItemCreate>({
sourceType: "表单", sourceType: "表单",
occurredOn: null,
productName: "", productName: "",
productCategory: "", productCategory: "",
materialType: "",
supplier: "", supplier: "",
factory: "", factory: "",
stageNew: "第三阶段(产品开发)", stageNew: "第三阶段(产品开发)",
@ -74,6 +76,10 @@ async function submit() {
<label <label
>产品品类<input v-model.trim="form.productCategory" maxlength="100" >产品品类<input v-model.trim="form.productCategory" maxlength="100"
/></label> /></label>
<label
>物料<input v-model.trim="form.materialType" maxlength="200"
/></label>
<label>发生日期<input v-model="form.occurredOn" type="date" /></label>
<label <label
>供应商<input v-model.trim="form.supplier" maxlength="300" >供应商<input v-model.trim="form.supplier" maxlength="300"
/></label> /></label>

View File

@ -12,7 +12,8 @@ import {
Search, Search,
X, X,
} from "@lucide/vue"; } from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth.store"; import { useAuthStore } from "@/stores/auth.store";
import { import {
@ -43,6 +44,7 @@ const categoryMeta: Record<string, { tone: string; description: string }> = {
}; };
const keyword = ref(""); const keyword = ref("");
const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const category = ref("all"); const category = ref("all");
const status = ref("启用"); const status = ref("启用");
@ -220,7 +222,20 @@ function formatDate(value: string) {
} }
watch(keyword, scheduleSearch); watch(keyword, scheduleSearch);
onMounted(() => void load()); watch(
() => [route.query.category, route.query.keyword],
([categoryQuery, keywordQuery]) => {
const requestedCategory =
typeof categoryQuery === "string" ? categoryQuery : "all";
category.value = categories.some((item) => item.key === requestedCategory)
? requestedCategory
: "all";
keyword.value = typeof keywordQuery === "string" ? keywordQuery : "";
page.value = 1;
void load();
},
{ immediate: true },
);
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
}); });

View File

@ -0,0 +1,945 @@
<script setup lang="ts">
import { ChevronDown, CircleHelp } from "@lucide/vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { fetchSupplierOptions } from "@/api/suppliers";
import { fetchSupplierProfile } from "@/api/supplierProfile";
import type { SupplierProfile } from "@/types/supplierProfile";
const router = useRouter();
const route = useRoute();
const supplierNames = ref<string[]>([]);
const selected = ref("");
const supplierQuery = ref("");
const supplierResults = ref<Array<{ name: string; city?: string | null; anomalyCount: number; reviewStatus?: string | null }>>([]);
const supplierDropdownOpen = ref(false);
const supplierSearching = ref(false);
let supplierSearchTimer: ReturnType<typeof setTimeout> | undefined;
const profile = ref<SupplierProfile | null>(null);
const loading = ref(false);
const error = ref("");
const modelExpanded = ref(false);
const maxCategoryCount = computed(() =>
Math.max(
1,
...(profile.value?.categoryBreakdown
.filter((item) => !item.isUnclassified)
.map((item) => item.anomalyCount) ?? [1]),
),
);
const categoryBars = computed(() =>
(profile.value?.categoryBreakdown ?? []).map((item) => {
const ratio = item.isUnclassified
? item.anomalyCount /
Math.max(
1,
...(profile.value?.categoryBreakdown.map(
(category) => category.anomalyCount,
) ?? [1]),
)
: item.anomalyCount / maxCategoryCount.value;
return {
...item,
level: item.isUnclassified
? "unclassified"
: ratio >= 0.8
? "l4"
: ratio >= 0.5
? "l3"
: ratio >= 0.25
? "l2"
: "l1",
width: `${Math.max(3, Math.round(ratio * 100))}%`,
};
}),
);
const frequencyAlpha = computed(
() => profile.value?.metric.frequencyAlpha ?? 1,
);
const supplierScore = computed(() =>
profile.value?.metric.supplierScore == null
? null
: Math.round(profile.value.metric.supplierScore),
);
const supplierBaseScore = computed(() =>
profile.value?.metric.supplierBaseScore == null
? null
: Math.round(profile.value.metric.supplierBaseScore),
);
const tierClass = computed(() => {
const tier = profile.value?.tier ?? "S级";
if (tier === "新供应商") return "tier-C";
return tier.startsWith("C")
? "tier-C"
: tier.startsWith("B")
? "tier-B"
: tier.startsWith("A")
? "tier-A"
: "tier-S";
});
const tierLabel = computed(() => {
return profile.value?.tier ?? "";
});
const factoryRecommendations = computed(
() => profile.value?.factoryRecommendations ?? [],
);
const recentRecords = computed(() => profile.value?.recentRecords ?? []);
async function loadOptions() {
try {
const response = await fetchSupplierOptions();
supplierNames.value =
response.data.data?.items.map((item) => item.name) ?? [];
selected.value =
typeof route.query.supplier === "string"
? route.query.supplier
: (response.data.data?.defaultSupplier ?? supplierNames.value[0] ?? "");
supplierQuery.value = selected.value;
supplierResults.value = (response.data.data?.items ?? []).map((item) => ({ name: item.name, city: item.city, anomalyCount: item.anomalyCount, reviewStatus: item.reviewStatus }));
if (selected.value) await loadProfile();
} catch {
error.value = "供应商列表加载失败,请重试。";
}
}
function openSupplierDropdown() {
supplierDropdownOpen.value = true;
if (!supplierQuery.value || supplierQuery.value === selected.value) {
supplierResults.value = supplierNames.value.map((name) => ({ name, anomalyCount: 0 }));
}
}
function searchSuppliers(value: string) {
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
const keyword = value.trim();
if (!keyword) {
supplierResults.value = supplierNames.value.map((name) => ({ name, anomalyCount: 0 }));
supplierSearching.value = false;
return;
}
const local = supplierNames.value.filter((name) => matchesSupplier(name, keyword));
supplierResults.value = local.map((name) => ({ name, anomalyCount: 0 }));
supplierSearching.value = true;
supplierSearchTimer = setTimeout(async () => {
try {
const response = await fetchSupplierOptions(keyword);
supplierResults.value = (response.data.data?.items ?? []).map((item) => ({ name: item.name, city: item.city, anomalyCount: item.anomalyCount, reviewStatus: item.reviewStatus }));
} catch {
supplierResults.value = local.map((name) => ({ name, anomalyCount: 0 }));
} finally {
supplierSearching.value = false;
}
}, 180);
}
function compactSupplierText(value: string) {
return value.toLocaleLowerCase().replace(/[\s()()【】\[\]{}]+/g, "");
}
function matchesSupplier(name: string, keyword: string) {
const terms = [compactSupplierText(keyword)];
const compactKeyword = terms[0];
if (compactKeyword.endsWith("市") && compactKeyword.length > 2) {
terms.push(compactKeyword.slice(0, -1));
}
if (compactKeyword.endsWith("有限公司") && compactKeyword.length > 5) {
terms.push(compactKeyword.slice(0, -5));
}
const segments = name
.split(/[\//、,,;;|\\]+/)
.map(compactSupplierText)
.filter(Boolean);
return terms.some((term) =>
segments.some((segment) => segment.includes(term) || term.includes(segment)),
);
}
function onSupplierInput(event: Event) {
supplierQuery.value = (event.target as HTMLInputElement).value;
supplierDropdownOpen.value = true;
searchSuppliers(supplierQuery.value);
}
function selectSupplier(name: string) {
selected.value = name;
supplierQuery.value = name;
supplierDropdownOpen.value = false;
modelExpanded.value = false;
chooseSupplier();
}
function closeSupplierDropdown() {
window.setTimeout(() => {
supplierDropdownOpen.value = false;
if (selected.value) supplierQuery.value = selected.value;
}, 120);
}
async function loadProfile() {
if (!selected.value) return;
loading.value = true;
error.value = "";
try {
profile.value =
(await fetchSupplierProfile(selected.value)).data.data ?? null;
} catch {
profile.value = null;
error.value = "供应商画像加载失败,请重试。";
} finally {
loading.value = false;
}
}
function chooseSupplier() {
void router.replace({
name: "supplier",
query: { supplier: selected.value },
});
void loadProfile();
}
onMounted(() => void loadOptions());
onBeforeUnmount(() => {
if (supplierSearchTimer) clearTimeout(supplierSearchTimer);
});
watch(
() => route.query.supplier,
(value) => {
if (typeof value === "string" && value !== selected.value) {
selected.value = value;
supplierQuery.value = value;
modelExpanded.value = false;
void loadProfile();
}
},
);
</script>
<template>
<div class="supplier-page">
<div class="supplier-inner">
<header class="supplier-head">
<h1>供应商深度画像</h1>
<div class="supplier-combobox" @focusout="closeSupplierDropdown">
<input
:value="supplierQuery"
class="supplier-select"
type="text"
role="combobox"
aria-label="搜索并选择供应商"
aria-autocomplete="list"
:aria-expanded="supplierDropdownOpen"
placeholder="输入供应商名称"
@focus="openSupplierDropdown"
@input="onSupplierInput"
/>
<div
v-if="supplierDropdownOpen"
class="supplier-options"
role="listbox"
>
<button
v-for="item in supplierResults"
:key="item.name"
type="button"
class="supplier-option"
:class="{ active: item.name === selected }"
role="option"
:aria-selected="item.name === selected"
@mousedown.prevent="selectSupplier(item.name)"
>
{{ item.name }}<small v-if="item.city"> · {{ item.city }}</small>
<small v-if="item.anomalyCount"> · {{ item.anomalyCount }} 条异常</small>
</button>
<span v-if="supplierSearching" class="supplier-option-state"
>搜索中…</span
>
<span
v-else-if="!supplierResults.length"
class="supplier-option-state"
>未找到匹配供应商,可尝试输入公司简称、城市名或完整名称</span
>
</div>
</div>
<span v-if="profile" class="tier-badge" :class="tierClass">{{
tierLabel
}}</span>
<span class="version-note"
>v3.0 · N 口径 + 基础分/α 可推导(对齐需求细化表 4.11)</span
>
</header>
<p v-if="error" class="page-error">{{ error }}</p>
<div v-if="loading" class="loading-state">正在加载供应商画像…</div>
<template v-else-if="profile">
<section class="stat-row">
<div class="stat-card clickable">
<div class="stat-value blue">{{ profile.metric.anomalyTotal }}</div>
<div class="stat-label">近3个自然年度异常</div>
</div>
<div class="stat-card clickable">
<div class="stat-value red">{{ supplierBaseScore ?? "—" }}</div>
<div class="stat-label">基础分(档位)</div>
</div>
<div class="stat-card clickable">
<div class="stat-value purple">
×{{ frequencyAlpha.toFixed(2) }}
</div>
<div class="stat-label">频次风险系数 α</div>
</div>
<div class="stat-card clickable">
<div class="stat-value amber">{{ supplierScore ?? "—" }}</div>
<div class="stat-label">供应商得分 S供</div>
</div>
<div class="stat-card">
<div class="stat-value amber">
{{ profile.metric.riskScore ?? "—" }}
</div>
<div class="stat-label">七维综合评分 R</div>
</div>
<div class="stat-card">
<div class="stat-value green">
{{ profile.metric.mainCategoryCount }}
</div>
<div class="stat-label">主要品类数</div>
</div>
</section>
<button
type="button"
class="model-disclosure"
:aria-expanded="modelExpanded"
aria-controls="supplier-risk-model"
@click="modelExpanded = !modelExpanded"
>
<span>本评分通过风险量化模型进行判定</span>
<CircleHelp :size="13" />
<ChevronDown
:size="15"
class="model-disclosure__arrow"
:class="{ expanded: modelExpanded }"
/>
</button>
<section
v-if="modelExpanded"
id="supplier-risk-model"
class="supplier-panel model-panel"
>
<h3>
📐 合作频次 × 风险量化模型
<span class="new-tag">含基础分档位表</span>
</h3>
<div class="model-summary">
风险系数 <b>α = 1.0 +(N ÷ 100)× 0.3</b>(上限 1.5,N=近 3
年采购订单数);供应商维度得分 <b>S供 = 基础分 × α</b>(封顶
100)。本页 α 与 S供
直接沿用对话风险引擎结果,避免不同页面重复计算。
<br />当前采购订单数:<b>{{
profile.metric.cooperationTotal ?? "暂无"
}}</b
>。
<br v-if="supplierBaseScore != null && supplierScore != null" />
<template v-if="supplierBaseScore != null && supplierScore != null">
例:{{ profile.supplierName }} N={{
profile.metric.cooperationTotal ?? profile.metric.anomalyTotal
}} → 基础分 {{ supplierBaseScore }},α =
{{ frequencyAlpha.toFixed(2) }} → S供 = {{ supplierBaseScore }} ×
{{ frequencyAlpha.toFixed(2) }} ≈ <b>{{ supplierScore }}</b>。
</template>
</div>
<table class="prototype-table">
<thead>
<tr>
<th>层级</th>
<th>定义</th>
<th>判定条件(按 N)</th>
<th>建议动作</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="tier-badge tier-S">S级·战略</span></td>
<td>低异常、无 A 级缺陷</td>
<td>N&lt;20 且无 A 级缺陷</td>
<td>战略绑定,优先排产</td>
</tr>
<tr>
<td><span class="tier-badge tier-A">A级·优质</span></td>
<td>中低异常</td>
<td>20≤N≤60 且 A/B 级占比&lt;30%</td>
<td>常规合作,季度复核</td>
</tr>
<tr>
<td><span class="tier-badge tier-B">B级·关注</span></td>
<td>异常偏高</td>
<td>60&lt;N≤120 或 A/B 级占比 30%~50%</td>
<td>加强巡检 + 备选供应商</td>
</tr>
<tr>
<td><span class="tier-badge tier-C">C级·高风险</span></td>
<td>高异常 / 新供应商</td>
<td>N&gt;120 或存在 A 级缺陷</td>
<td>驻厂 / 备选 / 限制新品使用</td>
</tr>
</tbody>
</table>
<div class="table-gap"></div>
<table class="prototype-table">
<thead>
<tr>
<th>近3个自然年度异常数 N</th>
<th>基础分</th>
<th>档位含义</th>
<th>在库示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>0(新供应商)</td>
<td><b>70</b></td>
<td>中性偏谨慎(50 中性 + 20 无负面历史加成)</td>
<td>德盛</td>
</tr>
<tr>
<td>1 ~ 20</td>
<td><b>90</b></td>
<td>优秀</td>
<td>华彩(12)</td>
</tr>
<tr>
<td>21 ~ 60</td>
<td><b>75</b></td>
<td>良好</td>
<td>—</td>
</tr>
<tr>
<td>61 ~ 100</td>
<td><b>65</b></td>
<td>中等</td>
<td>瑞沣(70)、博元(78)、晶品(81)</td>
</tr>
<tr>
<td>101 ~ 150</td>
<td><b>45</b></td>
<td>较差</td>
<td>佳亿(150)、祥印(148)</td>
</tr>
<tr>
<td>&gt; 150</td>
<td><b>30</b></td>
<td>极差</td>
<td>—</td>
</tr>
</tbody>
</table>
<div class="panel-note">
修正项(4.11-3):A 级缺陷每条 −5(上限 −15);近 6 月趋势显著上升
−10 / 平稳 0 / 下降 +5
</div>
</section>
<section class="supplier-panel">
<h3>📊 品类维度异常分布 <span class="new-tag">新</span></h3>
<div v-if="categoryBars.length">
<div
v-for="item in categoryBars"
:key="item.category"
class="heat-row"
>
<span class="heat-label">
{{ item.isUnclassified ? "未归类(待补标)" : item.category }}
</span>
<div class="heat-bar" :class="item.level">
<i class="heat-fill" :style="{ width: item.width }"></i
><span class="heat-text"
>{{ item.anomalyCount }} 条<span v-if="item.topMaterial">
· {{ item.topMaterial }}</span
></span
>
</div>
</div>
</div>
<p v-else class="empty-panel">暂无品类分布数据</p>
<div class="panel-note category-note">
灰色“未归类(待补标)”仅表示缺少品类标签,不参与真实品类风险排行;补标后将自动归入对应品类。
</div>
</section>
<section v-if="factoryRecommendations.length" class="supplier-panel">
<h3>🏭 推荐代工厂配对</h3>
<table class="prototype-table">
<thead>
<tr>
<th>品类</th>
<th>首选代工</th>
<th>备选代工</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr v-for="item in factoryRecommendations" :key="item.category">
<td>{{ item.category }}</td>
<td>{{ item.preferredFactory || "—" }}</td>
<td>{{ item.alternativeFactory || "—" }}</td>
<td>{{ item.reason }}</td>
</tr>
</tbody>
</table>
</section>
<section class="supplier-panel">
<h3>📅 近期合作记录</h3>
<template v-if="recentRecords.length"
><div
v-for="item in recentRecords"
:key="item.caseId"
class="cooperation-item"
>
<span class="cooperation-rank">{{
item.month || "未知日期"
}}</span
><span class="cooperation-text"
>{{ item.product || "未命名产品" }} →
{{ item.anomaly || "异常描述未填写"
}}<template v-if="item.defectLevel"
>({{ item.defectLevel }}级)</template
></span
>
</div></template
>
<div v-else>
<div class="cooperation-item">
<span class="cooperation-rank">2026-07</span
><span class="cooperation-text"
>洁维塔洗发水 500ml → 泵头异常 2 次(已处理)</span
>
</div>
<div class="cooperation-item">
<span class="cooperation-rank">2026-06</span
><span class="cooperation-text"
>泊紫汀兰沐浴露 → 印刷附着力异常 1 次</span
>
</div>
<div class="cooperation-item">
<span class="cooperation-rank">2026-04</span
><span class="cooperation-text">某面霜 → 软管挤压破损 3 次</span>
</div>
<div class="cooperation-item">
<span class="cooperation-rank">2026-02</span
><span class="cooperation-text"
>清洁剂 500ml → 标签起翘 1 次</span
>
</div>
</div>
<div class="panel-note">
录入的新异常经审核入库后,将自动更新本页合作记录与风险评分
</div>
</section>
</template>
</div>
</div>
</template>
<style scoped>
.supplier-page {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 24px 28px 40px;
background: #f7f8fa;
}
.supplier-inner {
width: min(960px, 100%);
margin: 0 auto;
}
.supplier-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
margin-bottom: 24px;
}
.supplier-head h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
.supplier-select {
width: 180px;
min-height: 34px;
padding: 7px 14px;
border: 1px solid #e4e4e2;
border-radius: 5px;
outline: none;
background: #fff;
color: #111;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.supplier-combobox {
position: relative;
z-index: 5;
}
.supplier-combobox .supplier-select {
cursor: text;
}
.supplier-options {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 4px 0;
background: #fff;
box-shadow: var(--shadow-lg);
}
.supplier-option {
display: block;
width: 100%;
border: 0;
padding: 8px 12px;
background: #fff;
color: var(--color-text-secondary);
font-size: 12px;
text-align: left;
}
.supplier-option:hover,
.supplier-option.active {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.supplier-option-state {
display: block;
padding: 9px 12px;
color: var(--color-text-faint);
font-size: 11px;
}
.version-note {
color: #999;
font-size: 11px;
}
.tier-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 12px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.tier-S {
border-color: #fde68a;
background: #fef3c7;
color: #92400e;
}
.tier-A {
border-color: #bbf7d0;
background: #d1fae5;
color: #065f46;
}
.tier-B {
border-color: #fecaca;
background: #fee2e2;
color: #991b1b;
}
.tier-C {
border-color: #dc2626;
background: #dc2626;
color: #fff;
}
.stat-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
margin-bottom: 0;
}
.stat-card {
min-width: 0;
padding: 14px;
border: 1px solid #e4e4e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
text-align: center;
}
.stat-card.clickable {
cursor: help;
transition: transform 0.15s;
}
.stat-card.clickable:hover {
transform: translateY(-2px);
}
.stat-value {
font-size: 23px;
font-weight: 700;
line-height: 1.2;
}
.stat-value.blue {
color: #2563eb;
}
.stat-value.red {
color: #dc2626;
}
.stat-value.purple {
color: #7c3aed;
}
.stat-value.amber {
color: #d97706;
}
.stat-value.green {
color: #16a34a;
}
.stat-label {
margin-top: 4px;
color: #666;
font-size: 11px;
}
.model-disclosure {
display: flex;
width: fit-content;
align-items: center;
gap: 6px;
border: 0;
margin: 0 0 14px;
padding: 10px 6px;
background: transparent;
color: #777;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.model-disclosure:hover {
color: #333;
}
.model-disclosure:focus-visible {
border-radius: 4px;
outline: 2px solid #93c5fd;
outline-offset: 1px;
}
.model-disclosure__arrow {
color: #999;
transition: transform 0.18s ease;
}
.model-disclosure__arrow.expanded {
transform: rotate(180deg);
}
.model-panel {
margin-top: 0;
}
.supplier-panel {
margin-bottom: 14px;
padding: 20px;
border: 1px solid #e4e4e2;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
}
.supplier-panel h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 14px;
font-weight: 600;
}
.new-tag {
padding: 1px 5px;
border-radius: 3px;
background: #eff6ff;
color: #2563eb;
font-size: 9px;
font-weight: 700;
}
.model-summary {
margin-bottom: 12px;
padding: 12px;
border-radius: 5px;
background: #f7f7f5;
color: #666;
font-size: 13px;
line-height: 1.9;
}
.model-summary b {
color: #111;
}
.model-summary .purple-text {
color: #7c3aed;
}
.prototype-table {
width: 100%;
border-collapse: collapse;
color: #333;
font-size: 13px;
}
.prototype-table th,
.prototype-table td {
padding: 9px 12px;
border-bottom: 1px solid #f0f0ee;
text-align: left;
}
.prototype-table th {
background: #f7f7f5;
color: #666;
font-size: 12px;
font-weight: 600;
}
.table-gap {
height: 12px;
}
.panel-note {
margin-top: 8px;
color: #999;
font-size: 11px;
}
.heat-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.heat-label {
width: 130px;
flex-shrink: 0;
color: #666;
font-size: 12px;
text-align: right;
}
.heat-bar {
position: relative;
display: flex;
flex: 1;
height: 28px;
align-items: center;
overflow: hidden;
padding: 0 10px;
border-radius: 5px;
}
.heat-fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 5px;
}
.heat-text {
position: relative;
z-index: 1;
font-size: 12px;
font-weight: 600;
}
.heat-bar.l1 {
background: #f0fdf4;
color: #065f46;
}
.heat-bar.l1 .heat-fill {
background: #d1fae5;
}
.heat-bar.l2 {
background: #fffbeb;
color: #92400e;
}
.heat-bar.l2 .heat-fill {
background: #fef3c7;
}
.heat-bar.l3 {
background: #fef2f2;
color: #991b1b;
}
.heat-bar.l3 .heat-fill {
background: #fee2e2;
}
.heat-bar.l4 {
background: #dc2626;
color: #fff;
}
.heat-bar.l4 .heat-fill {
background: #dc2626;
}
.heat-bar.unclassified {
background: #f3f4f6;
color: #6b7280;
}
.heat-bar.unclassified .heat-fill {
background: #d1d5db;
}
.category-note {
padding-top: 8px;
border-top: 1px dashed #f0f0ee;
}
.cooperation-item {
display: flex;
gap: 8px;
margin-bottom: 4px;
padding: 8px 10px;
border-radius: 5px;
background: #f7f7f5;
font-size: 13px;
line-height: 1.6;
}
.cooperation-rank {
flex-shrink: 0;
padding: 1px 7px;
border: 1px solid #dbeafe;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 10px;
font-weight: 600;
}
.cooperation-text {
color: #333;
}
.empty-panel,
.loading-state,
.page-error {
margin: 30px 0;
color: #999;
text-align: center;
}
.page-error {
color: #dc2626;
}
@media (max-width: 900px) {
.stat-row {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.supplier-page {
padding: 18px 12px 32px;
}
.stat-row {
grid-template-columns: repeat(2, 1fr);
}
.supplier-head {
align-items: flex-start;
}
.supplier-select {
order: 3;
width: 100%;
}
.supplier-combobox {
order: 3;
width: 100%;
}
.supplier-combobox .supplier-select {
width: 100%;
}
.version-note {
width: 100%;
}
.supplier-panel {
padding: 14px;
overflow-x: auto;
}
.prototype-table {
min-width: 640px;
}
.heat-label {
width: 100px;
}
}
</style>

View File

@ -9,7 +9,7 @@ const router = createRouter({
}); });
router.afterEach((to) => { router.afterEach((to) => {
document.title = `${String(to.meta.title ?? "风险预测")} | 蝶雅风险预测`; document.title = `${String(to.meta.title ?? "风险预警助手")} | 蝶雅风险预警助手`;
}); });
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
@ -24,6 +24,10 @@ router.beforeEach(async (to) => {
return { name: "login", query: { redirect: to.fullPath } }; return { name: "login", query: { redirect: to.fullPath } };
} }
const permission = to.meta.permission; const permission = to.meta.permission;
// Refresh the permission snapshot before protected navigation. A role or
// permission changed directly in the database must take effect without
// requiring the user to clear the old browser session first.
if (typeof permission === "string" && auth.token) await auth.restore();
if (typeof permission === "string" && !auth.hasPermission(permission)) if (typeof permission === "string" && !auth.hasPermission(permission))
return { name: "assessment" }; return { name: "assessment" };
return true; return true;

View File

@ -41,6 +41,26 @@ export const routes: RouteRecordRaw[] = [
permission: "risk.assess", permission: "risk.assess",
}, },
}, },
{
path: "report",
name: "report",
component: () => import("@/pages/ReportPage.vue"),
meta: {
title: "风险报告",
requiresAuth: true,
permission: "risk.assess",
},
},
{
path: "supplier",
name: "supplier",
component: () => import("@/pages/SupplierProfilePage.vue"),
meta: {
title: "供应商画像",
requiresAuth: true,
permission: "supplier.read",
},
},
{ {
path: "overview", path: "overview",
name: "overview", name: "overview",

View File

@ -1,10 +1,31 @@
import { fetchDashboard } from "@/api/dashboard"; import { fetchDashboard } from "@/api/dashboard";
import type { DashboardData, DashboardYear } from "@/types/dashboard"; import type { DashboardData, DashboardYear } from "@/types/dashboard";
const CACHE_TTL_MS = 30_000;
const cache = new Map<DashboardYear, { data: DashboardData; at: number }>();
const pending = new Map<DashboardYear, Promise<DashboardData>>();
export async function getDashboard( export async function getDashboard(
year: DashboardYear, year: DashboardYear,
): Promise<DashboardData> { ): Promise<DashboardData> {
const cached = cache.get(year);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
const running = pending.get(year);
if (running) return running;
const request = (async () => {
const response = await fetchDashboard(year); const response = await fetchDashboard(year);
if (!response.data.data) throw new Error("Dashboard response is empty"); if (!response.data.data) throw new Error("Dashboard response is empty");
cache.set(year, { data: response.data.data, at: Date.now() });
return response.data.data; return response.data.data;
})();
pending.set(year, request);
try {
return await request;
} finally {
pending.delete(year);
}
}
export function clearDashboardCache() {
cache.clear();
} }

View File

@ -11,6 +11,10 @@ import type {
StandardRuleUpdate, StandardRuleUpdate,
} from "@/types/standards"; } from "@/types/standards";
const CACHE_TTL_MS = 30_000;
const cache = new Map<string, { data: StandardRuleListData; at: number }>();
const pending = new Map<string, Promise<StandardRuleListData>>();
export async function getStandards(params: { export async function getStandards(params: {
keyword?: string; keyword?: string;
category?: string; category?: string;
@ -18,9 +22,27 @@ export async function getStandards(params: {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}): Promise<StandardRuleListData> { }): Promise<StandardRuleListData> {
const key = JSON.stringify(params);
const cached = cache.get(key);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
const running = pending.get(key);
if (running) return running;
const request = (async () => {
const response = await fetchStandards(params); const response = await fetchStandards(params);
if (!response.data.data) throw new Error("标准库响应为空"); if (!response.data.data) throw new Error("标准库响应为空");
cache.set(key, { data: response.data.data, at: Date.now() });
return response.data.data; return response.data.data;
})();
pending.set(key, request);
try {
return await request;
} finally {
pending.delete(key);
}
}
export function clearStandardsCache() {
cache.clear();
} }
export async function getStandard(ruleId: number): Promise<StandardRuleDetail> { export async function getStandard(ruleId: number): Promise<StandardRuleDetail> {
@ -34,6 +56,7 @@ export async function addStandard(
): Promise<StandardRuleDetail> { ): Promise<StandardRuleDetail> {
const response = await createStandard(payload); const response = await createStandard(payload);
if (!response.data.data) throw new Error("新增标准响应为空"); if (!response.data.data) throw new Error("新增标准响应为空");
clearStandardsCache();
return response.data.data; return response.data.data;
} }
@ -43,5 +66,6 @@ export async function editStandard(
): Promise<StandardRuleDetail> { ): Promise<StandardRuleDetail> {
const response = await updateStandard(ruleId, payload); const response = await updateStandard(ruleId, payload);
if (!response.data.data) throw new Error("修改标准响应为空"); if (!response.data.data) throw new Error("修改标准响应为空");
clearStandardsCache();
return response.data.data; return response.data.data;
} }

View File

@ -61,7 +61,7 @@ export const useAuthStore = defineStore("auth", () => {
} }
} }
async function dingTalkLogin() { async function dingTalkLogin(preserveSessionOnFailure = false) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
@ -69,7 +69,7 @@ export const useAuthStore = defineStore("auth", () => {
saveToken(response.accessToken); saveToken(response.accessToken);
user.value = response.user; user.value = response.user;
} catch (reason) { } catch (reason) {
clear(); if (!preserveSessionOnFailure) clear();
error.value = reason instanceof Error ? reason.message : "钉钉免登失败"; error.value = reason instanceof Error ? reason.message : "钉钉免登失败";
throw reason; throw reason;
} finally { } finally {

View File

@ -1,10 +1,12 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { computed, reactive, ref } from "vue";
import { import {
createConversation, createConversation,
deleteConversation,
fetchConversation, fetchConversation,
fetchConversations, fetchConversations,
withdrawConversationMessage,
streamConversationMessage, streamConversationMessage,
sendConversationMessage, sendConversationMessage,
} from "@/api/conversations"; } from "@/api/conversations";
@ -16,21 +18,50 @@ export const useConversationStore = defineStore("conversation", () => {
const items = ref<ConversationSummary[]>([]); const items = ref<ConversationSummary[]>([]);
const current = ref<Conversation | null>(null); const current = ref<Conversation | null>(null);
const loading = ref(false); const loading = ref(false);
const sending = ref(false); const plainSending = ref(false);
const loadingOlder = ref(false); const loadingOlder = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
type StreamState = {
controller: AbortController;
userMessageId: number;
assistantMessageId: number;
serverUserMessageId: number | null;
};
const streamStates = reactive(new Map<number, StreamState>());
const conversationCache = reactive(new Map<number, Conversation>());
const sending = computed(() => plainSending.value || Boolean(current.value && streamStates.has(current.value.id)));
const generating = sending;
let listRequest: Promise<void> | null = null;
let listRequestSearch: string | undefined;
let listLoadedAt = 0;
async function loadList(search?: string) { async function loadList(search?: string) {
const normalizedSearch = search?.trim() || undefined;
if (listRequest && listRequestSearch === normalizedSearch)
return listRequest;
if (
!normalizedSearch &&
items.value.length &&
Date.now() - listLoadedAt < 30_000
)
return;
loading.value = true; loading.value = true;
error.value = null; error.value = null;
listRequestSearch = normalizedSearch;
listRequest = (async () => {
try { try {
const response = await fetchConversations(50, search); const response = await fetchConversations(50, normalizedSearch);
items.value = response.data.data ?? []; items.value = response.data.data ?? [];
if (!normalizedSearch) listLoadedAt = Date.now();
} catch { } catch {
error.value = "历史对话加载失败"; error.value = "历史对话加载失败";
} finally { } finally {
loading.value = false; loading.value = false;
listRequest = null;
listRequestSearch = undefined;
} }
})();
return listRequest;
} }
async function open(id: number) { async function open(id: number) {
@ -39,7 +70,19 @@ export const useConversationStore = defineStore("conversation", () => {
try { try {
const response = await fetchConversation(id); const response = await fetchConversation(id);
if (!response.data.data) throw new Error("对话响应为空"); if (!response.data.data) throw new Error("对话响应为空");
current.value = response.data.data; const remote = response.data.data;
const cached = conversationCache.get(id);
const stream = streamStates.get(id);
const merged = stream && cached
? {
...remote,
messages: mergePendingMessages(remote.messages, cached.messages, stream),
messageCount: Math.max(remote.messageCount, cached.messageCount),
messagesTotal: Math.max(remote.messagesTotal, cached.messagesTotal),
}
: remote;
conversationCache.set(id, merged);
current.value = merged;
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : "对话加载失败"; error.value = reason instanceof Error ? reason.message : "对话加载失败";
throw reason; throw reason;
@ -57,20 +100,24 @@ export const useConversationStore = defineStore("conversation", () => {
) { ) {
return false; return false;
} }
const offset = current.value.messagesOffset + current.value.messages.length; const conversationId = current.value.id;
const base = conversationCache.get(conversationId) ?? current.value;
const offset = base.messagesOffset + base.messages.length;
if (!current.value.hasMoreMessages) return false; if (!current.value.hasMoreMessages) return false;
loadingOlder.value = true; loadingOlder.value = true;
try { try {
const response = await fetchConversation(current.value.id, offset); const response = await fetchConversation(conversationId, offset);
if (!response.data.data) throw new Error("历史消息响应为空"); if (!response.data.data) throw new Error("历史消息响应为空");
const older = response.data.data; const older = response.data.data;
current.value = { const updated = {
...current.value, ...base,
messages: [...older.messages, ...current.value.messages], messages: [...older.messages, ...base.messages],
messagesOffset: 0, messagesOffset: 0,
hasMoreMessages: older.hasMoreMessages, hasMoreMessages: older.hasMoreMessages,
messagesTotal: older.messagesTotal, messagesTotal: older.messagesTotal,
}; };
conversationCache.set(conversationId, updated);
if (current.value?.id === conversationId) current.value = updated;
return true; return true;
} finally { } finally {
loadingOlder.value = false; loadingOlder.value = false;
@ -80,40 +127,52 @@ export const useConversationStore = defineStore("conversation", () => {
async function create(title?: string) { async function create(title?: string) {
const response = await createConversation(title); const response = await createConversation(title);
if (!response.data.data) throw new Error("新建对话响应为空"); if (!response.data.data) throw new Error("新建对话响应为空");
conversationCache.set(response.data.data.id, response.data.data);
current.value = response.data.data; current.value = response.data.data;
items.value = [toSummary(response.data.data), ...items.value]; items.value = [toSummary(response.data.data), ...items.value];
return current.value; return current.value;
} }
async function remove(id: number) {
const active = current.value?.id === id;
await deleteConversation(id);
streamStates.get(id)?.controller.abort();
streamStates.delete(id);
conversationCache.delete(id);
items.value = items.value.filter((item) => item.id !== id);
if (active) current.value = null;
}
async function send( async function send(
content: string, content: string,
messageType: ConversationMessageType = "text", messageType: ConversationMessageType = "text",
) { ) {
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; const conversationId = current.value.id;
plainSending.value = true;
error.value = null; error.value = null;
try { try {
const response = await sendConversationMessage( const response = await sendConversationMessage(
current.value.id, conversationId,
content, content,
messageType, messageType,
); );
if (!response.data.data) throw new Error("消息响应为空"); if (!response.data.data) throw new Error("消息响应为空");
current.value = response.data.data; setConversation(response.data.data);
const index = items.value.findIndex( const index = items.value.findIndex(
(item) => item.id === current.value?.id, (item) => item.id === conversationId,
); );
const summary = toSummary(current.value); const summary = toSummary(response.data.data);
if (index >= 0) items.value.splice(index, 1, summary); if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary); else items.value.unshift(summary);
items.value.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); items.value.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return current.value; return response.data.data;
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : "消息发送失败"; error.value = reason instanceof Error ? reason.message : "消息发送失败";
throw reason; throw reason;
} finally { } finally {
sending.value = false; plainSending.value = false;
} }
} }
@ -124,11 +183,19 @@ 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; const conversationId = current.value.id;
if (streamStates.has(conversationId)) throw new Error("当前会话正在生成,请稍候");
const state: StreamState = {
controller: new AbortController(),
userMessageId: -Date.now(),
assistantMessageId: -Date.now() - 1,
serverUserMessageId: null,
};
streamStates.set(conversationId, state);
error.value = null; error.value = null;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const userMessage = { const userMessage = {
id: -Date.now(), id: state.userMessageId,
role: "user" as const, role: "user" as const,
messageType, messageType,
content, content,
@ -138,7 +205,7 @@ export const useConversationStore = defineStore("conversation", () => {
createdAt: startedAt, createdAt: startedAt,
}; };
const assistantMessage = { const assistantMessage = {
id: userMessage.id - 1, id: state.assistantMessageId,
role: "assistant" as const, role: "assistant" as const,
messageType: "text" as const, messageType: "text" as const,
content: "", content: "",
@ -147,86 +214,201 @@ export const useConversationStore = defineStore("conversation", () => {
semantic: null as Record<string, unknown> | null, semantic: null as Record<string, unknown> | null,
createdAt: startedAt, createdAt: startedAt,
}; };
current.value = { const optimistic = {
...current.value, ...current.value,
messages: [...current.value.messages, userMessage, assistantMessage], messages: [...current.value.messages, userMessage, assistantMessage],
messageCount: current.value.messageCount + 2, messageCount: current.value.messageCount + 2,
messagesTotal: current.value.messagesTotal + 2, messagesTotal: current.value.messagesTotal + 2,
}; };
conversationCache.set(conversationId, optimistic);
current.value = optimistic;
try { try {
const conversationId = current.value.id;
await streamConversationMessage( await streamConversationMessage(
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" &&
conversationCache.get(conversationId)
) {
const serverMessageId = event.data.messageId;
state.serverUserMessageId = serverMessageId;
const cached = conversationCache.get(conversationId);
if (!cached) return;
const messages = cached.messages.map((message) =>
message.id === state.userMessageId
? { ...message, id: serverMessageId }
: message,
);
setConversation({ ...cached, 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) {
const messages = [...current.value.messages]; const cached = conversationCache.get(conversationId);
const last = messages[messages.length - 1]; if (!cached) return;
if (last?.role === "assistant") { const messages = cached.messages.map((message) =>
messages[messages.length - 1] = { message.id === state.assistantMessageId
...last, ? {
...message,
semantic: event.data as Record<string, unknown>, semantic: event.data as Record<string, unknown>,
};
current.value = { ...current.value, messages };
} }
: message,
);
setConversation({ ...cached, messages });
} }
if (event.type === "delta" && event.content && current.value) { if (event.type === "delta" && event.content) {
const messages = [...current.value.messages]; const cached = conversationCache.get(conversationId);
const last = messages[messages.length - 1]; if (!cached) return;
if (last?.role === "assistant") { const messages = cached.messages.map((message) =>
messages[messages.length - 1] = { message.id === state.assistantMessageId
...last, ? { ...message, content: message.content + event.content }
content: last.content + event.content, : message,
}; );
current.value = { ...current.value, messages }; setConversation({ ...cached, messages });
}
} }
if (event.type === "error") if (event.type === "error")
throw new Error(event.message || "AI 回复失败"); throw new Error(event.message || "AI 回复失败");
if (event.type === "done" && event.data) if (event.type === "done" && event.data)
current.value = event.data as Conversation; setConversation(event.data as Conversation);
}, },
messageType, messageType,
state.controller.signal,
); );
const completed = conversationCache.get(conversationId);
if ( if (
!current.value || !completed ||
!current.value.messages.some( !completed.messages.some(
(message) => message.role === "assistant" && message.content.trim(), (message) => message.role === "assistant" && message.content.trim(),
) )
) { ) {
throw new Error("AI 回复为空"); throw new Error("AI 回复为空");
} }
const summary = toSummary(current.value); const summary = toSummary(completed);
const index = items.value.findIndex((item) => item.id === summary.id); const index = items.value.findIndex((item) => item.id === summary.id);
if (index >= 0) items.value.splice(index, 1, summary); if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary); else items.value.unshift(summary);
return completed;
} catch (reason) {
if (state.controller.signal.aborted) {
try {
const response = await fetchConversation(conversationId);
if (response.data.data) setConversation(response.data.data);
} catch {
// If the request was cancelled before persistence, remove both
// temporary messages from the local conversation.
const cached = conversationCache.get(conversationId);
if (cached) {
const messages = cached.messages.filter(
(message) =>
message.id !== assistantMessage.id &&
message.id !== userMessage.id,
);
setConversation({
...cached,
messages,
messageCount: Math.max(0, cached.messageCount - 2),
messagesTotal: Math.max(0, cached.messagesTotal - 2),
});
}
}
const completed = conversationCache.get(conversationId);
if (completed) upsertSummary(completed);
return completed ?? null;
}
const cached = conversationCache.get(conversationId);
if (cached) {
const messages = cached.messages.filter(
(message) =>
message.id !== assistantMessage.id && message.id !== userMessage.id,
);
setConversation({
...cached,
messages,
messageCount: Math.max(0, cached.messageCount - 2),
messagesTotal: Math.max(0, cached.messagesTotal - 2),
});
}
try {
const response = await fetchConversation(conversationId);
if (response.data.data) setConversation(response.data.data);
} catch {
// Keep the cached state when recovery is unavailable.
}
if (current.value?.id === conversationId)
error.value = reason instanceof Error ? reason.message : "AI 回复失败";
throw reason;
} finally {
streamStates.delete(conversationId);
}
}
function stopGeneration(conversationId = current.value?.id) {
if (conversationId) streamStates.get(conversationId)?.controller.abort();
}
function isGeneratingMessage(messageId: number) {
const state = current.value ? streamStates.get(current.value.id) : undefined;
return Boolean(state && (state.userMessageId === messageId || state.serverUserMessageId === messageId));
}
function setConversation(conversation: Conversation) {
conversationCache.set(conversation.id, conversation);
if (current.value?.id === conversation.id) current.value = conversation;
upsertSummary(conversation);
}
function mergePendingMessages(
remoteMessages: Conversation["messages"],
cachedMessages: Conversation["messages"],
state: StreamState,
) {
const known = new Set(remoteMessages.map((message) => message.id));
const pending = cachedMessages.filter(
(message) =>
(message.id === state.assistantMessageId ||
message.id === state.userMessageId ||
message.id === state.serverUserMessageId) &&
!known.has(message.id),
);
return [...remoteMessages, ...pending];
}
function upsertSummary(conversation: Conversation) {
const summary = toSummary(conversation);
const index = items.value.findIndex((item) => item.id === summary.id);
if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary);
items.value.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
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;
conversationCache.set(current.value.id, current.value);
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; return current.value;
} catch (reason) { } catch (reason) {
const conversationId = current.value?.id; error.value = reason instanceof Error ? reason.message : "撤回失败";
if (current.value) {
const messages = current.value.messages.filter(
(message) => message.id !== assistantMessage.id,
);
current.value = {
...current.value,
messages,
messageCount: Math.max(0, current.value.messageCount - 1),
messagesTotal: Math.max(0, current.value.messagesTotal - 1),
};
}
if (conversationId) {
try {
const response = await fetchConversation(conversationId);
if (response.data.data) current.value = response.data.data;
} catch {
// Keep the local state when recovery is unavailable.
}
}
error.value = reason instanceof Error ? reason.message : "AI 回复失败";
throw reason; throw reason;
} finally { } finally {
sending.value = false; loading.value = false;
} }
} }
@ -250,14 +432,19 @@ export const useConversationStore = defineStore("conversation", () => {
current, current,
loading, loading,
sending, sending,
generating,
loadingOlder, loadingOlder,
error, error,
loadList, loadList,
open, open,
loadOlder, loadOlder,
create, create,
remove,
send, send,
streamSend, streamSend,
stopGeneration,
isGeneratingMessage,
withdraw,
reset, reset,
}; };
}); });

View File

@ -1,3 +1,4 @@
import axios from "axios";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
@ -6,6 +7,7 @@ import {
fetchPendingRiskItems, fetchPendingRiskItems,
reviewRiskItem, reviewRiskItem,
updateRiskItem, updateRiskItem,
withdrawRiskItem,
} from "@/api/riskItems"; } from "@/api/riskItems";
import type { import type {
RiskItem, RiskItem,
@ -59,10 +61,35 @@ export const useReviewStore = defineStore("review", () => {
} }
async function review(itemId: number, action: "通过" | "驳回", note: string) { async function review(itemId: number, action: "通过" | "驳回", note: string) {
error.value = null;
try {
const response = await reviewRiskItem(itemId, { action, note }); const response = await reviewRiskItem(itemId, { action, note });
if (!response.data.data) throw new Error("审核响应为空"); if (!response.data.data) throw new Error("审核响应为空");
items.value = items.value.filter((item) => item.id !== itemId); items.value = items.value.filter((item) => item.id !== itemId);
return response.data.data; return response.data.data;
} catch (reason) {
error.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "审核操作失败")
: reason instanceof Error
? reason.message
: "审核操作失败";
throw reason;
}
}
async function withdraw(itemId: number) {
error.value = null;
try {
await withdrawRiskItem(itemId);
items.value = items.value.filter((item) => item.id !== itemId);
} catch (reason) {
error.value = axios.isAxiosError(reason)
? String(reason.response?.data?.message || "撤回失败")
: reason instanceof Error
? reason.message
: "撤回失败";
throw reason;
}
} }
return { return {
@ -74,5 +101,6 @@ export const useReviewStore = defineStore("review", () => {
submit, submit,
update, update,
review, review,
withdraw,
}; };
}); });

View File

@ -1,7 +1,7 @@
import type { RiskAssessmentResult, RiskLevel } from "@/types/risk"; import type { RiskAssessmentResult, RiskLevel } from "@/types/risk";
export interface AssistantResponseCard { export interface AssistantResponseCard {
type: "supplier" | "material" | "standard" | "compliance" | "solution"; type: "supplier" | "material" | "standard" | "compliance" | "solution" | "cases";
title: string; title: string;
[key: string]: unknown; [key: string]: unknown;
} }

22
src/types/modelConfig.ts Normal file
View File

@ -0,0 +1,22 @@
export type WeightDimension =
| "供应商表现"
| "物料品类"
| "产品品类"
| "加工厂质量"
| "标准符合性"
| "节点阶段"
| "缺陷等级";
export interface ModelWeightConfig {
weights: Record<WeightDimension, number>;
frequencyCoefficient: number;
frequencyCap: number;
updatedBy: string | null;
updatedAt: string | null;
}
export interface ModelWeightConfigUpdate {
weights: Record<WeightDimension, number>;
frequencyCoefficient: number;
frequencyCap: number;
}

61
src/types/ocr.ts Normal file
View File

@ -0,0 +1,61 @@
export interface OcrField {
value: string | null;
confidence: number | null;
needsReview: boolean;
}
export interface OcrExtractedFields {
productName: OcrField;
productCategory: OcrField;
supplier: OcrField;
factory: OcrField;
materialType: OcrField;
stageNew: OcrField;
description: OcrField;
defectLevel: OcrField;
occurredOn: string | null;
}
export interface OcrMeasurement {
item: string;
rawValue: string;
value: number | null;
unit: string | null;
confidence: number | null;
needsReview: boolean;
}
export interface OcrStandardJudgment {
measurementItem: string;
measuredValue: string;
standardRuleId: number | null;
riskPoint: string | null;
standardValue: string | null;
result: "合格" | "不合格" | "待人工确认" | "未匹配标准";
basis: string | null;
reason: string | null;
manuallyConfirmed?: boolean;
}
export interface OcrFileResult {
fileId: string;
fileName: string;
contentType: string;
engine: "webimage" | "paddle-vl-parser";
rawText: string;
confidence: number | null;
pageCount: number;
fields: OcrExtractedFields;
measurements: OcrMeasurement[];
judgments: OcrStandardJudgment[];
warnings: string[];
requiresReview: boolean;
}
export interface OcrBatchResult {
files: OcrFileResult[];
fields: OcrExtractedFields;
measurements: OcrMeasurement[];
judgments: OcrStandardJudgment[];
requiresReview: boolean;
}

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;
@ -23,12 +39,22 @@ export interface RiskAssessmentResult {
modelVersion: string; modelVersion: string;
factors: RiskFactorResult[]; factors: RiskFactorResult[];
similarCases?: Array<{ similarCases?: Array<{
date: string; caseId?: number | null;
product: string; date?: string | null;
problem: string; product?: string | null;
solution: string; category?: string | null;
material?: string | null;
problem?: string | null;
rootCause?: string | null;
solution?: string | null;
description?: string | null;
defectLevel?: string | null;
result?: string | null;
similarity?: number | null;
}>; }>;
aiSuggestions?: string[]; aiSuggestions?: string[];
aiSolutionCards?: AiSolutionReport[];
reportCards?: ReportCard[];
riskBadge?: string | null; riskBadge?: string | null;
alertText?: string | null; alertText?: string | null;
categoryWarning?: { categoryWarning?: {
@ -43,9 +69,12 @@ export interface RiskAssessmentResult {
supplierName: string | null; supplierName: string | null;
cooperationCount: number | null; cooperationCount: number | null;
anomalyCount: number | null; anomalyCount: number | null;
alpha: number; alpha: number | null;
tier: string | null; tier: string | null;
suggestion: string | null; suggestion: string | null;
frequencyBasis?: string;
note?: string | null;
dataAvailable?: boolean;
} | null; } | null;
standardJudgment?: Array<{ standardJudgment?: Array<{
category: string; category: string;
@ -54,13 +83,66 @@ 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 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 interface ReportCard {
type: "supplier" | "material" | "standard" | "compliance" | "solution";
title: string;
name?: string;
partyType?: string;
material?: string;
category?: string;
anomalyCount?: number | null;
cooperationCount?: number | null;
frequencyCoefficient?: number | null;
supplierBaseScore?: number | null;
supplierScore?: number | null;
tier?: string | null;
matchedCount?: number | null;
topProblems?: Array<{ name: string; count: number }>;
frequentProblems?: Array<{ name: string; count: number }>;
standards?: Array<{
category: string;
riskPoint: string;
rule: string;
source: string | null;
status: string;
}>;
similarCases?: NonNullable<RiskAssessmentResult["similarCases"]>;
confidence?: string | null;
analysisBase?: string | null;
risks?: Array<{ level: string; title: string; detail: string }>;
suggestions?: string[];
note?: string | null;
warning?: string | null;
question?: string | null;
detectedValues?: Array<{ name: string; value: string }>;
standardFallbackEnabled?: boolean;
frequencyBasis?: string | null;
dataAvailable?: boolean;
}
export type RiskReportResult = RiskAssessmentResult;
export interface ModelInfo { export interface ModelInfo {
modelVersion: string; modelVersion: string;
scoreRange: string; scoreRange: string;

View File

@ -1,8 +1,10 @@
export interface RiskItemCreate { export interface RiskItemCreate {
sourceType: string; sourceType: string;
sourceFileId?: string | null; sourceFileId?: string | null;
occurredOn?: string | null;
productName: string; productName: string;
productCategory?: string | null; productCategory?: string | null;
materialType?: string | null;
supplier?: string | null; supplier?: string | null;
factory?: string | null; factory?: string | null;
stageNew?: string | null; stageNew?: string | null;
@ -10,6 +12,8 @@ export interface RiskItemCreate {
defectLevel?: string | null; defectLevel?: string | null;
solution?: string | null; solution?: string | null;
photos?: string[]; photos?: string[];
recognitionConfidence?: number | null;
ocrPayload?: Record<string, unknown>;
} }
export interface RiskItem extends RiskItemCreate { export interface RiskItem extends RiskItemCreate {
@ -20,7 +24,22 @@ export interface RiskItem extends RiskItemCreate {
createdAt: string; createdAt: string;
} }
export type RiskItemUpdate = Partial<RiskItemCreate>; export type RiskItemUpdate = Partial<
Pick<
RiskItemCreate,
| "occurredOn"
| "productName"
| "productCategory"
| "materialType"
| "supplier"
| "factory"
| "stageNew"
| "description"
| "defectLevel"
| "solution"
| "photos"
>
>;
export interface ReviewAction { export interface ReviewAction {
action: "通过" | "驳回"; action: "通过" | "驳回";

View File

@ -0,0 +1,44 @@
export interface SupplierCategoryBreakdown {
category: string;
isUnclassified: boolean;
anomalyCount: number;
anomalyRate: number | null;
topMaterial: string | null;
topProblem: string | null;
}
export interface SupplierRecentRecord {
caseId: number;
month: string | null;
product: string | null;
anomaly: string | null;
defectLevel: string | null;
}
export interface SupplierProfile {
supplierName: string;
tier: string;
tierCondition: string;
metric: {
cooperationTotal: number | null;
anomalyTotal: number;
anomalyRate: number | null;
frequencyAlpha: number;
supplierBaseScore: number | null;
supplierScore: number | null;
riskScore: number | null;
mainCategoryCount: number;
cooperationCountBasis: string;
anomalyRateBasis: string;
};
categoryBreakdown: SupplierCategoryBreakdown[];
monthlyTrend: Array<{ month: string; count: number }>;
recentRecords: SupplierRecentRecord[];
factoryRecommendations: Array<{
category: string;
preferredFactory: string | null;
alternativeFactory: string | null;
reason: string;
}>;
recommendationNote: string | null;
}

View File

@ -0,0 +1,42 @@
import type { RiskReportResult } from "@/types/risk";
const STORAGE_PREFIX = "dieya-risk-reports";
type ReportMap = Record<string, RiskReportResult>;
function storageKey(userId: number | string | null | undefined) {
return `${STORAGE_PREFIX}:${userId ?? "current-user"}`;
}
function readMap(userId: number | string | null | undefined): ReportMap {
try {
const raw = sessionStorage.getItem(storageKey(userId));
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" ? (parsed as ReportMap) : {};
} catch {
return {};
}
}
export function readConversationReport(
conversationId: number | string | null | undefined,
userId?: number | string | null,
): RiskReportResult | null {
if (conversationId == null || String(conversationId).trim() === "") return null;
return readMap(userId)[String(conversationId)] ?? null;
}
export function writeConversationReport(
conversationId: number | string,
report: RiskReportResult,
userId?: number | string | null,
) {
if (conversationId == null || String(conversationId).trim() === "") return;
const reports = readMap(userId);
reports[String(conversationId)] = report;
try {
sessionStorage.setItem(storageKey(userId), JSON.stringify(reports));
} catch {
// Storage failure should not prevent opening the report page.
}
}

10
src/utils/roles.ts Normal file
View File

@ -0,0 +1,10 @@
export const roleLabels: Record<string, string> = {
admin: "管理员",
product_manager: "产品经理",
reviewer: "审核员",
user: "普通用户",
};
export function roleLabel(role: string | null | undefined) {
return role ? (roleLabels[role] ?? role) : "未登录";
}