Compare commits
No commits in common. "94fc1ef2d396253deaa2c63486bebc05be88bd00" and "b672f025dd343c983c2a844cc07a21fc8a4b5409" have entirely different histories.
94fc1ef2d3
...
b672f025dd
@ -4,10 +4,11 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
26
src/App.vue
26
src/App.vue
@ -2,7 +2,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -18,28 +17,9 @@ function handleAuthExpired() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncDingTalkUser() {
|
onMounted(() =>
|
||||||
if (
|
window.addEventListener("risk-auth-expired", handleAuthExpired),
|
||||||
!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),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { http } from "@/api/http";
|
import { http } from "@/api/http";
|
||||||
import type { Conversation, ConversationSummary } from "@/types/conversation";
|
|
||||||
import type { ApiResponse } from "@/types/api";
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { Conversation, ConversationSummary } from "@/types/conversation";
|
||||||
|
|
||||||
export interface ConversationStreamEvent {
|
export interface ConversationStreamEvent {
|
||||||
type: "start" | "semantic" | "delta" | "done" | "error";
|
type: "start" | "semantic" | "delta" | "done" | "error";
|
||||||
@ -12,7 +12,6 @@ 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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,21 +21,12 @@ 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,
|
||||||
@ -48,47 +38,25 @@ export function sendConversationMessage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withdrawConversationMessage(
|
|
||||||
conversationId: number,
|
|
||||||
messageId: number,
|
|
||||||
) {
|
|
||||||
return http.delete<ApiResponse<Conversation>>(
|
|
||||||
`/v1/conversations/${conversationId}/messages/${messageId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function streamConversationMessage(
|
export async function streamConversationMessage(
|
||||||
id: number,
|
id: number,
|
||||||
content: string,
|
content: string,
|
||||||
onEvent: (event: ConversationStreamEvent) => void,
|
onEvent: (event: ConversationStreamEvent) => void,
|
||||||
messageType: "text" | "image" | "file" | "audio" = "text",
|
messageType: "text" | "image" | "file" | "audio" = "text",
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
) {
|
||||||
const token = localStorage.getItem("risk_prediction_token");
|
const token = localStorage.getItem("risk_prediction_token");
|
||||||
const controller = new AbortController();
|
const response = await fetch(
|
||||||
const abortFromCaller = () => controller.abort();
|
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
|
||||||
if (signal?.aborted) controller.abort();
|
{
|
||||||
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
method: "POST",
|
||||||
const timeout = window.setTimeout(() => controller.abort(), 180_000);
|
headers: {
|
||||||
let response: Response;
|
Accept: "text/event-stream",
|
||||||
try {
|
"Content-Type": "application/json",
|
||||||
response = await fetch(
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
`${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content, messageType }),
|
|
||||||
signal: controller.signal,
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({ content, messageType }),
|
||||||
} 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"));
|
||||||
@ -96,55 +64,32 @@ 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 = "";
|
let completed = false;
|
||||||
let completed = false;
|
while (true) {
|
||||||
while (true) {
|
const { value, done } = await reader.read();
|
||||||
const { value, done } = await reader.read();
|
if (done) break;
|
||||||
if (done) break;
|
buffer += decoder.decode(value, { stream: true });
|
||||||
buffer += decoder.decode(value, { stream: true });
|
const lines = buffer.split("\n");
|
||||||
const lines = buffer.split("\n");
|
buffer = lines.pop() ?? "";
|
||||||
buffer = lines.pop() ?? "";
|
for (const line of lines) {
|
||||||
for (const line of lines) {
|
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;
|
onEvent(event);
|
||||||
try {
|
if (event.type === "done") completed = true;
|
||||||
event = JSON.parse(raw) as ConversationStreamEvent;
|
|
||||||
} catch {
|
|
||||||
throw new Error("AI 流式响应格式错误,请重试");
|
|
||||||
}
|
|
||||||
onEvent(event);
|
|
||||||
if (event.type === "done") completed = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (buffer.startsWith("data:")) {
|
|
||||||
const raw = buffer.slice(5).trim();
|
|
||||||
if (raw) {
|
|
||||||
let event: ConversationStreamEvent;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(raw) as ConversationStreamEvent;
|
|
||||||
} catch {
|
|
||||||
throw new Error("AI 流式响应格式错误,请重试");
|
|
||||||
}
|
|
||||||
onEvent(event);
|
|
||||||
if (event.type === "done") completed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
if (buffer.startsWith("data:")) {
|
||||||
|
const raw = buffer.slice(5).trim();
|
||||||
|
if (raw) {
|
||||||
|
const event = JSON.parse(raw) as ConversationStreamEvent;
|
||||||
|
onEvent(event);
|
||||||
|
if (event.type === "done") completed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!completed) throw new Error("AI 流式连接中断,请重试");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,43 +3,14 @@ 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";
|
||||||
|
|
||||||
declare global {
|
function isDingTalkContainer() {
|
||||||
interface Window {
|
return (
|
||||||
dingtalk?: { platform?: { invokeAPI?: unknown } };
|
typeof navigator !== "undefined" && /DingTalk/i.test(navigator.userAgent)
|
||||||
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 (!(await waitForDingTalkContainer()))
|
if (!isDingTalkContainer()) throw new Error("请从钉钉工作台打开此应用");
|
||||||
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("钉钉免登配置不完整");
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -28,10 +28,6 @@ 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`,
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -11,16 +11,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
|
|
||||||
import { ref } from "vue";
|
defineProps<{ modelValue: string; loading: boolean }>();
|
||||||
|
|
||||||
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];
|
||||||
@ -38,13 +29,6 @@ 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;
|
||||||
@ -52,7 +36,6 @@ 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";
|
||||||
}> = [
|
}> = [
|
||||||
@ -95,7 +78,6 @@ const functions: Array<{
|
|||||||
tone: "purple",
|
tone: "purple",
|
||||||
prompt: "帮我生成一个新品风险预防方案",
|
prompt: "帮我生成一个新品风险预防方案",
|
||||||
tool: "solution",
|
tool: "solution",
|
||||||
isNew: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "审核队列",
|
title: "审核队列",
|
||||||
@ -103,7 +85,6 @@ const functions: Array<{
|
|||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
tone: "red",
|
tone: "red",
|
||||||
tool: "review",
|
tool: "review",
|
||||||
isNew: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
@ -112,7 +93,6 @@ 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="语音录入"
|
||||||
@ -122,7 +102,6 @@ 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="拍照识别"
|
||||||
@ -132,7 +111,6 @@ 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="输入产品参数或问题…"
|
||||||
@ -158,22 +136,20 @@ 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>
|
||||||
|
<component :is="item.icon" :size="13" />
|
||||||
|
<span
|
||||||
|
><strong>{{ item.title }}</strong
|
||||||
|
><small>{{ item.description }}</small></span
|
||||||
>
|
>
|
||||||
<span class="function-dot" :class="`function-dot--${item.tone}`"></span>
|
</button>
|
||||||
<component :is="item.icon" :size="13" />
|
|
||||||
<span v-if="item.isNew" class="function-new">新</span>
|
|
||||||
<span
|
|
||||||
><strong>{{ item.title }}</strong
|
|
||||||
><small>{{ item.description }}</small></span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -259,8 +235,8 @@ const functions: Array<{
|
|||||||
.function-grid {
|
.function-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
max-width: var(--content-width);
|
max-width: var(--content-width);
|
||||||
margin: 10px auto 0;
|
margin: 8px auto 0;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,22 +246,18 @@ 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: 8px;
|
border-radius: 9px;
|
||||||
padding: 8px 10px;
|
padding: 7px 8px;
|
||||||
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 {
|
||||||
@ -302,26 +274,12 @@ const functions: Array<{
|
|||||||
|
|
||||||
.function-card strong {
|
.function-card strong {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
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
@ -9,15 +9,8 @@ import {
|
|||||||
Scale,
|
Scale,
|
||||||
Search,
|
Search,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
import { featureFlags } from "@/config/features";
|
const emit = defineEmits<{ select: [prompt: string] }>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [prompt: string];
|
|
||||||
tool: [value: "assessment" | "ocr"];
|
|
||||||
}>();
|
|
||||||
const props = defineProps<{ canUpload: boolean }>();
|
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
@ -26,7 +19,6 @@ const actions = [
|
|||||||
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
|
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
|
||||||
icon: Search,
|
icon: Search,
|
||||||
tone: "blue",
|
tone: "blue",
|
||||||
tool: "assessment" as const,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "查供应商",
|
title: "查供应商",
|
||||||
@ -72,55 +64,33 @@ const actions = [
|
|||||||
prompt: "请帮我识别检测报告并进行风险判定",
|
prompt: "请帮我识别检测报告并进行风险判定",
|
||||||
icon: Camera,
|
icon: Camera,
|
||||||
tone: "sky",
|
tone: "sky",
|
||||||
requiresUpload: true,
|
|
||||||
tool: "ocr" as const,
|
|
||||||
},
|
},
|
||||||
...(featureFlags.voiceEntry
|
{
|
||||||
? [
|
title: "语音录入异常",
|
||||||
{
|
description: "口述信息自动结构化",
|
||||||
title: "语音录入异常",
|
prompt: "记录一条新的质量异常",
|
||||||
description: "口述信息自动结构化",
|
icon: Mic,
|
||||||
prompt: "记录一条新的质量异常",
|
tone: "purple",
|
||||||
icon: Mic,
|
},
|
||||||
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>你好,我是风险预警助手</h1>
|
<h1>你好,我是风险预估助手 v2.3</h1>
|
||||||
<p>
|
<p>
|
||||||
双引擎风险预警:历史异常经验引擎 +
|
双引擎风险预估:历史异常经验引擎 +
|
||||||
判定标准规则引擎,帮助你在立项、开发和量产阶段提前识别风险。
|
判定标准规则引擎,帮助你在立项、开发和量产阶段提前识别风险。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="welcome-grid">
|
<div class="welcome-grid">
|
||||||
<button
|
<button
|
||||||
v-for="action in visibleActions"
|
v-for="action in actions"
|
||||||
:key="action.title"
|
:key="action.title"
|
||||||
class="welcome-action"
|
class="welcome-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="handleAction(action)"
|
@click="emit('select', action.prompt)"
|
||||||
>
|
>
|
||||||
<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" />
|
||||||
|
|||||||
@ -11,7 +11,6 @@ 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";
|
||||||
@ -20,22 +19,8 @@ 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}功能将在对应模块接入后执行`;
|
||||||
@ -49,35 +34,19 @@ function copyScore() {
|
|||||||
notify("评分已复制");
|
notify("评分已复制");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCase(item?: SimilarCase) {
|
const dimensions = computed(() =>
|
||||||
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.map((item) => ({
|
? props.result.dimensionScores
|
||||||
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(() =>
|
||||||
@ -89,42 +58,6 @@ 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>
|
||||||
@ -185,88 +118,44 @@ function openSupplier() {
|
|||||||
<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>
|
||||||
<template
|
<div v-if="result.supplierFreq?.supplierName">
|
||||||
v-if="
|
<span>{{ result.supplierFreq.supplierName }}</span>
|
||||||
result.supplierFreq?.dataAvailable ||
|
<strong>异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong>
|
||||||
result.supplierFreq?.supplierName
|
</div>
|
||||||
"
|
<div v-if="result.supplierFreq">
|
||||||
>
|
<span>频次风险系数</span
|
||||||
<div v-if="result.supplierFreq.supplierName">
|
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
||||||
<span>{{ result.supplierFreq.supplierName }}</span>
|
</div>
|
||||||
<strong
|
<div v-if="result.supplierFreq?.tier">
|
||||||
>近3个自然年度异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong
|
<span>供应商层级</span><strong>{{ result.supplierFreq.tier }}</strong>
|
||||||
>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div v-if="result.supplierFreq.alpha != null">
|
<span>合作次数</span
|
||||||
<span>频次风险系数</span
|
><strong>{{
|
||||||
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
|
result.supplierFreq?.cooperationCount ?? "暂无数据"
|
||||||
</div>
|
}}</strong>
|
||||||
<div v-if="supplierDimension">
|
</div>
|
||||||
<span>供应商得分 S供</span
|
<div v-if="result.supplierFreq?.suggestion">
|
||||||
><strong>{{ Math.round(supplierDimension.score) }} 分</strong>
|
<span>建议</span>{{ result.supplierFreq.suggestion }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="result.supplierFreq.tier">
|
|
||||||
<span>供应商层级</span
|
|
||||||
><strong>{{ result.supplierFreq.tier }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="result.supplierFreq.cooperationCount != null">
|
|
||||||
<span>合作频次(采购订单)</span>
|
|
||||||
<strong>{{ result.supplierFreq.cooperationCount }} 次</strong>
|
|
||||||
</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>
|
||||||
<button
|
<div
|
||||||
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"
|
||||||
@ -280,23 +169,12 @@ function openSupplier() {
|
|||||||
{{ standard.source || "标准文件.xlsx" }}</small
|
{{ standard.source || "标准文件.xlsx" }}</small
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
|
||||||
|
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>
|
||||||
|
|
||||||
@ -317,56 +195,7 @@ function openSupplier() {
|
|||||||
</div>
|
</div>
|
||||||
<small
|
<small
|
||||||
><b>{{ item.score }}</b
|
><b>{{ item.score }}</b
|
||||||
>分 · {{ item.weight }}
|
>分 · {{ item.weight }}</small
|
||||||
<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>
|
||||||
@ -388,20 +217,12 @@ function openSupplier() {
|
|||||||
:key="`${item.date}-${index}`"
|
:key="`${item.date}-${index}`"
|
||||||
class="case-row"
|
class="case-row"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openCase(item)"
|
|
||||||
>
|
>
|
||||||
<span class="case-rank">TOP {{ index + 1 }}</span>
|
<span>TOP {{ index + 1 }}</span>
|
||||||
<div class="case-content">
|
<small
|
||||||
<small>
|
>{{ item.date }} · {{ item.product }} · {{ item.problem }} →
|
||||||
{{ item.date || "日期未知" }} ·
|
{{ item.solution }}</small
|
||||||
{{ item.product || "未命名产品" }} ·
|
>
|
||||||
{{ item.problem || "异常问题未填写" }} →
|
|
||||||
{{ item.solution || "解决方案未填写" }}
|
|
||||||
<em v-if="item.similarity != null"
|
|
||||||
>相似度 {{ Math.round(item.similarity * 100) }}%</em
|
|
||||||
>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -418,99 +239,18 @@ function openSupplier() {
|
|||||||
<button
|
<button
|
||||||
class="primary-action"
|
class="primary-action"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('generateReport')"
|
@click="notify('生成报告')"
|
||||||
>
|
>
|
||||||
<FileDown :size="14" /> 生成报告
|
<FileDown :size="14" /> 生成报告
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="openCase()">案例详情</button>
|
<button type="button" @click="notify('案例详情')">案例详情</button>
|
||||||
<button type="button" @click="openSupplier">供应商对比</button>
|
<button type="button" @click="notify('供应商对比')">供应商对比</button>
|
||||||
<button
|
<button v-if="canMonitor" type="button" @click="notify('加入监控')">
|
||||||
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>
|
||||||
|
|
||||||
@ -739,24 +479,6 @@ 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;
|
||||||
@ -771,87 +493,15 @@ 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 {
|
||||||
@ -882,6 +532,16 @@ 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;
|
||||||
@ -890,48 +550,12 @@ 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 {
|
||||||
@ -957,17 +581,6 @@ 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);
|
||||||
@ -1011,8 +624,8 @@ h3 {
|
|||||||
.case-row {
|
.case-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-columns: 58px minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@ -1023,36 +636,22 @@ h3 {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-rank {
|
.case-row span {
|
||||||
display: inline-flex;
|
|
||||||
width: 58px;
|
|
||||||
min-width: 58px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-top: 1px;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-weight: 650;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.case-content {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-content small {
|
.case-row 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-content em {
|
.case-row strong {
|
||||||
margin-left: 6px;
|
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
font-style: normal;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-card {
|
.tips-card {
|
||||||
@ -1099,127 +698,12 @@ 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 {
|
||||||
@ -1231,16 +715,11 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.case-row {
|
.case-row {
|
||||||
grid-template-columns: 50px minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-rank {
|
.case-row small {
|
||||||
width: 50px;
|
display: none;
|
||||||
min-width: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.case-content small {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -14,43 +14,20 @@ 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>
|
||||||
|
|
||||||
@ -59,19 +36,22 @@ function prefetch(item: string) {
|
|||||||
v-for="item in visibleNavigation"
|
v-for="item in visibleNavigation"
|
||||||
:key="item"
|
:key="item"
|
||||||
:to="{
|
:to="{
|
||||||
name: navigationRoutes[item] || 'assessment',
|
name:
|
||||||
|
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 === 'report') ||
|
(item === '管理' && route.name === 'review') ||
|
||||||
(item === '风险项管理' && route.name === 'review') ||
|
|
||||||
(item === '对话' && route.name === 'assessment'),
|
(item === '对话' && route.name === 'assessment'),
|
||||||
}"
|
}"
|
||||||
@mouseenter="prefetch(item)"
|
|
||||||
@focus="prefetch(item)"
|
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@ -142,6 +122,11 @@ function prefetch(item: string) {
|
|||||||
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;
|
||||||
@ -246,6 +231,7 @@ nav a.active {
|
|||||||
padding-inline: 12px;
|
padding-inline: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand__version,
|
||||||
.engine-badge,
|
.engine-badge,
|
||||||
.top-navigation,
|
.top-navigation,
|
||||||
.header-icon,
|
.header-icon,
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
<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();
|
||||||
@ -19,23 +24,30 @@ const { items: conversations, loading: conversationsLoading } =
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = ref("");
|
const search = ref("");
|
||||||
|
|
||||||
onMounted(() => {
|
const shortcuts = [
|
||||||
// Keep the first non-chat navigation fast by warming the small route chunks
|
{ label: "数据看板", icon: BarChart3, route: "overview" },
|
||||||
// after the browser has finished the initial render. This does not block the
|
{
|
||||||
// chat page and makes subsequent clicks resolve from the module cache.
|
label: "录入风险项",
|
||||||
const warmRoutes = () => {
|
icon: ClipboardCheck,
|
||||||
void import("@/pages/OverviewPage.vue");
|
route: "risk-item-entry",
|
||||||
void import("@/pages/SupplierProfilePage.vue");
|
permission: "risk_item.submit",
|
||||||
void import("@/pages/StandardsPage.vue");
|
},
|
||||||
void import("@/pages/ReportPage.vue");
|
{ label: "供应商画像", icon: Building2, route: "assessment" },
|
||||||
void import("@/pages/ReviewPage.vue");
|
{
|
||||||
void import("@/pages/RiskItemEntryPage.vue");
|
label: "判定标准库",
|
||||||
};
|
icon: BookOpenCheck,
|
||||||
const idle = window.requestIdleCallback;
|
route: "standards",
|
||||||
if (idle) idle(warmRoutes, { timeout: 1500 });
|
isNew: true,
|
||||||
else window.setTimeout(warmRoutes, 500);
|
},
|
||||||
void conversationStore.loadList();
|
{ label: "最新报告", icon: FileText, route: "assessment" },
|
||||||
});
|
{ label: "钉钉移动端", icon: Smartphone, route: "assessment" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function canReview() {
|
||||||
|
return auth.hasPermission("risk_item.review");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => conversationStore.loadList());
|
||||||
|
|
||||||
function startNewChat() {
|
function startNewChat() {
|
||||||
conversationStore.reset();
|
conversationStore.reset();
|
||||||
@ -43,13 +55,6 @@ 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({
|
||||||
@ -61,18 +66,6 @@ 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);
|
||||||
}
|
}
|
||||||
@ -109,15 +102,14 @@ function searchConversations() {
|
|||||||
<p v-else-if="!conversations.length" class="sidebar-state">
|
<p v-else-if="!conversations.length" class="sidebar-state">
|
||||||
暂无历史对话
|
暂无历史对话
|
||||||
</p>
|
</p>
|
||||||
<div
|
<button
|
||||||
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,
|
||||||
}"
|
}"
|
||||||
role="button"
|
type="button"
|
||||||
tabindex="0"
|
|
||||||
@click="openConversation(item.id)"
|
@click="openConversation(item.id)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -127,21 +119,36 @@ 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
|
</button>
|
||||||
type="button"
|
</section>
|
||||||
class="sidebar-item__delete"
|
|
||||||
title="删除会话"
|
<section>
|
||||||
aria-label="删除会话"
|
<h2>快捷入口</h2>
|
||||||
@click.stop="removeConversation(item)"
|
<RouterLink
|
||||||
>
|
v-for="item in shortcuts.filter(
|
||||||
×
|
(item) => !item.permission || auth.hasPermission(item.permission),
|
||||||
</button>
|
)"
|
||||||
</div>
|
: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>{{ roleLabel(user?.role) }} · 知识库 2,448 / 3,000 条</p>
|
<p>{{ user?.role ?? "未登录" }} · 知识库 2,448 / 3,000 条</p>
|
||||||
<p>标准库 219 条</p>
|
<p>标准库 219 条</p>
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
@ -228,32 +235,6 @@ 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;
|
||||||
|
|||||||
@ -1,989 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ShieldCheck } from "@lucide/vue";
|
import { Plus, ShieldCheck, Trash2 } from "@lucide/vue";
|
||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
|
|
||||||
import type { RiskAssessmentRequest } from "@/types/risk";
|
import type { RiskAssessmentRequest, RiskIndicator } from "@/types/risk";
|
||||||
|
|
||||||
defineProps<{ loading: boolean }>();
|
defineProps<{ loading: boolean }>();
|
||||||
|
|
||||||
@ -10,99 +10,111 @@ const emit = defineEmits<{
|
|||||||
submit: [payload: RiskAssessmentRequest];
|
submit: [payload: RiskAssessmentRequest];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive<RiskAssessmentRequest>({
|
||||||
subjectId: "脱毛膏 100g 新品",
|
subjectId: "customer-001",
|
||||||
productCategory: "脱毛膏",
|
indicators: [
|
||||||
materialType: "PE软管",
|
{ name: "historicalDefault", value: 28, weight: 4 },
|
||||||
supplier: "瑞沣",
|
{ name: "debtBurden", value: 52, weight: 3 },
|
||||||
factory: "",
|
{ name: "cashflowVolatility", value: 36, weight: 3 },
|
||||||
stageNew: "第三阶段(产品开发)",
|
],
|
||||||
defectLevel: "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit() {
|
function addIndicator() {
|
||||||
emit("submit", {
|
form.indicators.push({
|
||||||
subjectId: form.subjectId,
|
name: `indicator${form.indicators.length + 1}`,
|
||||||
indicators: [],
|
value: 0,
|
||||||
productCategory: form.productCategory || null,
|
weight: 1,
|
||||||
materialType: form.materialType || null,
|
|
||||||
supplier: form.supplier || null,
|
|
||||||
factory: form.factory || null,
|
|
||||||
stageNew: form.stageNew || null,
|
|
||||||
defectLevel: form.defectLevel || null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeIndicator(index: number) {
|
||||||
|
if (form.indicators.length > 1) form.indicators.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const indicators: RiskIndicator[] = form.indicators.map((item) => ({
|
||||||
|
...item,
|
||||||
|
}));
|
||||||
|
emit("submit", { subjectId: form.subjectId, indicators });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="assessment-form" @submit.prevent="submit">
|
<form class="assessment-form" @submit.prevent="submit">
|
||||||
<div class="form-intro">
|
<div class="field-group">
|
||||||
<strong>新品风险参数</strong>
|
<label for="subject-id">评估对象</label>
|
||||||
<span>系统将结合近3个自然年度异常案例计算七维安全评分</span>
|
<input
|
||||||
|
id="subject-id"
|
||||||
|
v-model.trim="form.subjectId"
|
||||||
|
required
|
||||||
|
maxlength="128"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field-wide">
|
<div class="indicator-heading">
|
||||||
评估对象 *
|
<div>
|
||||||
<input v-model.trim="form.subjectId" required maxlength="128" />
|
<strong>风险指标</strong>
|
||||||
</label>
|
<span>指标值范围 0-100</span>
|
||||||
<label>
|
</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="form.productCategory"
|
v-model.trim="indicator.name"
|
||||||
required
|
required
|
||||||
maxlength="100"
|
maxlength="64"
|
||||||
placeholder="如:洗发水、面霜、脱毛膏"
|
aria-label="指标名称"
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
包装材质 *
|
|
||||||
<input
|
<input
|
||||||
v-model.trim="form.materialType"
|
v-model.number="indicator.value"
|
||||||
required
|
required
|
||||||
maxlength="200"
|
type="number"
|
||||||
placeholder="如:PET瓶、PE软管、泵头"
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
aria-label="指标值"
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
供应商
|
|
||||||
<input
|
<input
|
||||||
v-model.trim="form.supplier"
|
v-model.number="indicator.weight"
|
||||||
maxlength="300"
|
required
|
||||||
placeholder="可填写已知供应商或新供应商"
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
aria-label="权重"
|
||||||
/>
|
/>
|
||||||
</label>
|
<button
|
||||||
<label>
|
class="icon-button"
|
||||||
加工厂
|
type="button"
|
||||||
<input
|
title="删除指标"
|
||||||
v-model.trim="form.factory"
|
aria-label="删除指标"
|
||||||
maxlength="300"
|
:disabled="form.indicators.length === 1"
|
||||||
placeholder="如:博元、瑞沣"
|
@click="removeIndicator(index)"
|
||||||
/>
|
>
|
||||||
</label>
|
<Trash2 :size="16" />
|
||||||
<label>
|
</button>
|
||||||
开发阶段
|
</div>
|
||||||
<select v-model="form.stageNew">
|
|
||||||
<option>第一阶段(需求发现)</option>
|
|
||||||
<option>第二阶段(产品立项)</option>
|
|
||||||
<option>第三阶段(产品开发)</option>
|
|
||||||
<option>第四阶段(量产上市)</option>
|
|
||||||
<option>第五阶段(产品生命管理)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
已知缺陷等级
|
|
||||||
<select v-model="form.defectLevel">
|
|
||||||
<option value="">暂无</option>
|
|
||||||
<option>A</option>
|
|
||||||
<option>B</option>
|
|
||||||
<option>C</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="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"
|
||||||
@ -117,82 +129,99 @@ function submit() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.assessment-form {
|
.assessment-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 22px;
|
||||||
}
|
}
|
||||||
.form-intro {
|
|
||||||
|
.field-group,
|
||||||
|
.indicator-heading div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 7px;
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
|
||||||
padding-bottom: 14px;
|
|
||||||
}
|
}
|
||||||
.form-intro strong {
|
|
||||||
color: var(--color-text);
|
label,
|
||||||
font-size: 15px;
|
.indicator-heading strong {
|
||||||
}
|
|
||||||
.form-intro span {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.field-wide {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 38px;
|
|
||||||
border: 1px solid var(--color-border-strong);
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0 10px;
|
|
||||||
background: white;
|
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
input:focus,
|
|
||||||
select:focus {
|
.field-group input {
|
||||||
border-color: var(--color-accent);
|
width: min(420px, 100%);
|
||||||
outline: 2px solid var(--color-accent-subtle);
|
|
||||||
outline-offset: 0;
|
|
||||||
}
|
}
|
||||||
.data-note {
|
|
||||||
|
.indicator-heading {
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
gap: 7px;
|
padding: 10px 12px;
|
||||||
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;
|
.indicator-table__header {
|
||||||
height: 6px;
|
background: var(--color-surface-muted);
|
||||||
flex: 0 0 6px;
|
color: var(--color-text-muted);
|
||||||
border-radius: 50%;
|
font-size: 11px;
|
||||||
background: var(--color-warning);
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indicator-row + .indicator-row {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-row input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.submit-button {
|
.submit-button {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.form-grid {
|
.indicator-heading {
|
||||||
grid-template-columns: 1fr;
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.field-wide {
|
|
||||||
grid-column: auto;
|
.indicator-table__header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-row input:first-child {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-row .icon-button {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,23 +1,10 @@
|
|||||||
<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, RiskDimensionScore } from "@/types/risk";
|
import type { RiskAssessmentResult } from "@/types/risk";
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{ result: RiskAssessmentResult | null; error: string | null }>();
|
||||||
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>
|
||||||
@ -45,24 +32,18 @@ const dimensions = computed<RiskDimensionScore[]>(() =>
|
|||||||
<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 dimensions"
|
v-for="factor in result.factors"
|
||||||
:key="factor.dimension"
|
:key="factor.name"
|
||||||
class="factor-row"
|
class="factor-row"
|
||||||
:title="factor.reason || undefined"
|
|
||||||
>
|
>
|
||||||
<span>{{ factor.dimension }}</span>
|
<span>{{ factor.name }}</span>
|
||||||
<div class="factor-bar">
|
<div class="factor-bar">
|
||||||
<i :style="{ width: `${factor.score}%` }"></i>
|
<i :style="{ width: `${factor.value}%` }"></i>
|
||||||
</div>
|
</div>
|
||||||
<strong>{{ Math.round(factor.score) }}<small>分</small></strong>
|
<strong>{{ factor.contribution.toFixed(1) }}</strong>
|
||||||
<em v-if="factor.status === 'default'">默认</em>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -166,22 +147,6 @@ 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;
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
export const featureFlags = {
|
|
||||||
voiceEntry: false,
|
|
||||||
adminConfigurationPanels: false,
|
|
||||||
} as const;
|
|
||||||
@ -17,11 +17,7 @@ onMounted(() => appStore.refreshHealth());
|
|||||||
<div class="app-shell__body">
|
<div class="app-shell__body">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main class="app-content">
|
<main class="app-content">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView />
|
||||||
<KeepAlive :max="8">
|
|
||||||
<component :is="Component" />
|
|
||||||
</KeepAlive>
|
|
||||||
</RouterView>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,24 +6,25 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRoute, 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;
|
||||||
const redirectPath = computed(() => "/assessment");
|
return typeof redirect === "string" && redirect.startsWith("/")
|
||||||
|
? 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)
|
||||||
@ -49,16 +50,6 @@ 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>
|
||||||
@ -67,7 +58,7 @@ onMounted(async () => {
|
|||||||
<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>
|
||||||
@ -75,7 +66,7 @@ onMounted(async () => {
|
|||||||
<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="登录方式">
|
||||||
@ -139,16 +130,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<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>{{ auth.loading ? "正在获取钉钉身份" : "从钉钉工作台快速进入" }}</strong>
|
<strong>从钉钉工作台快速进入</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"
|
||||||
|
|||||||
@ -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>数据看板</h1>
|
<h1>数据看板 <small>v2.3</small></h1>
|
||||||
<p class="dashboard-subtitle">
|
<p class="dashboard-subtitle">
|
||||||
历史异常、供应商风险与标准覆盖的全局视图
|
历史异常、供应商风险与标准覆盖的全局视图
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,716 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -13,51 +13,22 @@ 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>>({});
|
||||||
|
|
||||||
@ -72,40 +43,6 @@ 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({
|
||||||
@ -113,38 +50,6 @@ 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"
|
||||||
@ -166,9 +71,6 @@ 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;
|
||||||
@ -179,8 +81,6 @@ 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,
|
||||||
@ -189,32 +89,6 @@ 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] ?? "";
|
||||||
}
|
}
|
||||||
@ -241,26 +115,10 @@ 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", {
|
||||||
@ -270,18 +128,15 @@ function formatDate(value: string | null) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => void load());
|
||||||
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">RISK ITEM CONSOLE</p>
|
<p class="eyebrow">ADMIN 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">
|
||||||
@ -296,7 +151,6 @@ onMounted(() => {
|
|||||||
: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' })"
|
||||||
@ -375,7 +229,6 @@ onMounted(() => {
|
|||||||
<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' })"
|
||||||
@ -390,37 +243,22 @@ onMounted(() => {
|
|||||||
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)"
|
>
|
||||||
>
|
<span class="source-badge" :class="sourceClass(item.sourceType)"
|
||||||
<span class="source-badge" :class="sourceClass(item.sourceType)"
|
><component :is="sourceIcon(item.sourceType)" :size="13" />{{
|
||||||
><component :is="sourceIcon(item.sourceType)" :size="13" />{{
|
item.sourceType
|
||||||
item.sourceType
|
}}</span
|
||||||
}}</span
|
><span class="review-title"
|
||||||
><span class="review-title"
|
><strong>{{ item.productName || "未填写产品名称" }}</strong
|
||||||
><strong>{{ item.productName || "未填写产品名称" }}</strong
|
><small
|
||||||
><small
|
>{{ 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)"
|
||||||
@ -432,17 +270,11 @@ onMounted(() => {
|
|||||||
:class="item.status === '待审核' ? 'is-pending' : 'is-pre'"
|
:class="item.status === '待审核' ? 'is-pending' : 'is-pre'"
|
||||||
>{{ item.status }}</span
|
>{{ item.status }}</span
|
||||||
></span
|
></span
|
||||||
>
|
><ChevronUp
|
||||||
<button
|
v-if="expandedId === item.id"
|
||||||
class="review-toggle"
|
:size="17"
|
||||||
type="button"
|
/><ChevronDown v-else :size="17" />
|
||||||
:aria-label="expandedId === item.id ? '收起风险项' : '展开风险项'"
|
</button>
|
||||||
@click="toggleItem(item.id)"
|
|
||||||
>
|
|
||||||
<ChevronUp v-if="expandedId === item.id" :size="17" />
|
|
||||||
<ChevronDown v-else :size="17" />
|
|
||||||
</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">
|
||||||
@ -466,27 +298,6 @@ onMounted(() => {
|
|||||||
($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')"
|
||||||
@ -584,14 +395,6 @@ onMounted(() => {
|
|||||||
<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>
|
||||||
@ -623,54 +426,10 @@ onMounted(() => {
|
|||||||
<strong>解决方案</strong>
|
<strong>解决方案</strong>
|
||||||
<p>{{ item.solution }}</p>
|
<p>{{ item.solution }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ocrFiles(item).length" class="attachment-list">
|
<div v-if="item.photos?.length" class="attachment-note">
|
||||||
<button
|
<ImagePlus :size="14" />已关联 {{ item.photos.length }} 个附件
|
||||||
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"
|
||||||
@ -692,7 +451,6 @@ onMounted(() => {
|
|||||||
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
|
||||||
"
|
"
|
||||||
@ -704,7 +462,6 @@ onMounted(() => {
|
|||||||
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
|
||||||
"
|
"
|
||||||
@ -720,17 +477,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside class="admin-tools">
|
<aside class="admin-tools">
|
||||||
<article
|
<article class="tool-card">
|
||||||
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
|
||||||
@ -738,53 +492,49 @@ onMounted(() => {
|
|||||||
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
|
<article class="tool-card">
|
||||||
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>
|
||||||
<button class="tool-link" type="button" @click="ocrOpen = true">
|
<label class="upload-zone"
|
||||||
<Upload :size="14" />识别并录入风险项
|
><Upload :size="17" /><span>选择图片 / PDF</span
|
||||||
</button>
|
><small>识别结果将填入风险项表单</small
|
||||||
|
><input type="file" accept="image/*,.pdf"
|
||||||
|
/></label>
|
||||||
</article>
|
</article>
|
||||||
<article v-if="featureFlags.voiceEntry" class="tool-card">
|
<article 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
|
<article class="tool-card">
|
||||||
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" />批量导入风险项</button
|
<Upload :size="14" />上传 Excel 文件</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">
|
||||||
@ -806,135 +556,6 @@ onMounted(() => {
|
|||||||
</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>
|
||||||
|
|
||||||
@ -1105,68 +726,16 @@ onMounted(() => {
|
|||||||
.review-summary {
|
.review-summary {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
padding: 8px 5px;
|
border: 0;
|
||||||
|
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;
|
||||||
@ -1281,86 +850,19 @@ onMounted(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.attachment-list {
|
.attachment-note {
|
||||||
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;
|
||||||
border: 1px solid var(--color-border-strong);
|
margin-top: 12px;
|
||||||
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;
|
||||||
@ -1582,111 +1084,6 @@ onMounted(() => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ChevronDown } from "@lucide/vue";
|
import { computed, nextTick, onMounted, ref, watch } from "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();
|
||||||
@ -27,14 +18,10 @@ const conversationStore = useConversationStore();
|
|||||||
const {
|
const {
|
||||||
current: conversation,
|
current: conversation,
|
||||||
sending: loading,
|
sending: loading,
|
||||||
generating,
|
|
||||||
error,
|
error,
|
||||||
} = storeToRefs(conversationStore);
|
} = storeToRefs(conversationStore);
|
||||||
const withdrawingId = ref<number | null>(null);
|
|
||||||
const prompt = ref("");
|
const prompt = ref("");
|
||||||
const ocrOpen = ref(false);
|
|
||||||
const 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"
|
||||||
@ -48,12 +35,6 @@ 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: "",
|
||||||
@ -83,41 +64,12 @@ 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 === "product_manager",
|
() => auth.user?.role === "admin" || auth.user?.role === "manager",
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -125,61 +77,16 @@ 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();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
function usePrompt(value: string) {
|
||||||
() => conversation.value?.id,
|
prompt.value = value;
|
||||||
() => refreshPrimaryReportState(),
|
void submit();
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
||||||
@ -197,23 +104,10 @@ 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: "",
|
||||||
@ -227,32 +121,9 @@ function openTool(
|
|||||||
function closeTool() {
|
function closeTool() {
|
||||||
tool.value = null;
|
tool.value = null;
|
||||||
toolError.value = "";
|
toolError.value = "";
|
||||||
directResult.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitDirectTool(kind: "supplier" | "material" | "cases" | "solution", query: string) {
|
function submitTool() {
|
||||||
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") {
|
||||||
@ -271,39 +142,21 @@ async 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();
|
||||||
void usePrompt(finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`);
|
prompt.value =
|
||||||
|
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) {
|
||||||
@ -327,47 +180,20 @@ 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;
|
||||||
const beforeTop = node?.scrollTop ?? 0;
|
void conversationStore.loadOlder().then((loaded) => {
|
||||||
void conversationStore.loadOlder().then(async (loaded) => {
|
if (loaded && node) node.scrollTop = node.scrollHeight - before;
|
||||||
if (loaded && node) {
|
|
||||||
await nextTick();
|
|
||||||
node.scrollTop = node.scrollHeight - before + beforeTop;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
if (scrollArea.value && scrollArea.value.scrollTop <= 80) loadOlder();
|
if (scrollArea.value && scrollArea.value.scrollTop < 60) 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");
|
||||||
@ -381,113 +207,13 @@ 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 = "";
|
||||||
const sentConversation = await conversationStore.streamSend(
|
await conversationStore.streamSend(
|
||||||
value,
|
value,
|
||||||
(delta) => {
|
(delta) => {
|
||||||
void delta;
|
void delta;
|
||||||
@ -495,16 +221,15 @@ async function submit(
|
|||||||
messageType,
|
messageType,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
sentConversation &&
|
conversation.value &&
|
||||||
conversation.value?.id === sentConversation.id &&
|
route.query.conversation !== String(conversation.value.id)
|
||||||
route.query.conversation !== String(sentConversation.id)
|
|
||||||
) {
|
) {
|
||||||
await router.replace({
|
await router.replace({
|
||||||
name: "assessment",
|
name: "assessment",
|
||||||
query: { conversation: String(sentConversation.id) },
|
query: { conversation: String(conversation.value.id) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (conversation.value?.id === sentConversation?.id) await scrollToBottom();
|
await scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
||||||
@ -524,18 +249,21 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
@scroll.passive="handleScroll"
|
@scroll.passive="handleScroll"
|
||||||
>
|
>
|
||||||
<div class="conversation-column">
|
<div class="conversation-column">
|
||||||
<p v-if="conversationStore.loadingOlder" class="history-loading">
|
<button
|
||||||
正在加载更早消息…
|
v-if="conversation?.hasMoreMessages"
|
||||||
</p>
|
class="load-older"
|
||||||
|
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, messageIndex) in conversation?.messages ?? []"
|
v-for="message in conversation?.messages ?? []"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
>
|
>
|
||||||
<div class="message" :class="`message--${message.role}`">
|
<div class="message" :class="`message--${message.role}`">
|
||||||
@ -549,22 +277,6 @@ 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
|
||||||
@ -584,53 +296,23 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="message.content || isMessageThinking(message.id, messageIndex)"
|
v-if="
|
||||||
class="assistant-thinking"
|
message.content &&
|
||||||
|
!message.riskResult &&
|
||||||
|
!message.responseCard
|
||||||
|
"
|
||||||
|
class="assistant-bubble"
|
||||||
>
|
>
|
||||||
<button
|
{{ message.content }}
|
||||||
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--collapsed':
|
|
||||||
!expandedReplies.has(message.id),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ 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)
|
||||||
@ -639,35 +321,10 @@ 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">
|
||||||
@ -677,11 +334,8 @@ 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"
|
||||||
@ -700,7 +354,9 @@ 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">{{ toolDescription }}</p>
|
<p class="dialog-description">
|
||||||
|
填写必要信息后,将生成一条带上下文的对话请求。
|
||||||
|
</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
|
||||||
@ -733,7 +389,6 @@ 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
|
||||||
@ -748,55 +403,14 @@ 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" :disabled="directLoading">
|
<button type="submit" class="dialog-primary">带入对话</button>
|
||||||
{{ toolSubmitLabel }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<OcrRecognitionDialog :open="ocrOpen" @close="ocrOpen = false" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -826,11 +440,15 @@ async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-loading {
|
.load-older {
|
||||||
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;
|
||||||
line-height: 1.4;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@ -884,47 +502,7 @@ 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);
|
||||||
@ -936,71 +514,6 @@ 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);
|
||||||
@ -1170,52 +683,6 @@ 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;
|
||||||
|
|||||||
@ -11,10 +11,8 @@ 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: "第三阶段(产品开发)",
|
||||||
@ -76,10 +74,6 @@ 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>
|
||||||
|
|||||||
@ -12,8 +12,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
|
|
||||||
import { useAuthStore } from "@/stores/auth.store";
|
import { useAuthStore } from "@/stores/auth.store";
|
||||||
import {
|
import {
|
||||||
@ -44,7 +43,6 @@ 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("启用");
|
||||||
@ -222,20 +220,7 @@ function formatDate(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(keyword, scheduleSearch);
|
watch(keyword, scheduleSearch);
|
||||||
watch(
|
onMounted(() => void load());
|
||||||
() => [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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,945 +0,0 @@
|
|||||||
<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<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 级占比<30%</td>
|
|
||||||
<td>常规合作,季度复核</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="tier-badge tier-B">B级·关注</span></td>
|
|
||||||
<td>异常偏高</td>
|
|
||||||
<td>60<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>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>> 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>
|
|
||||||
@ -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,10 +24,6 @@ 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;
|
||||||
|
|||||||
@ -41,26 +41,6 @@ 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",
|
||||||
|
|||||||
@ -1,31 +1,10 @@
|
|||||||
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);
|
const response = await fetchDashboard(year);
|
||||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
|
if (!response.data.data) throw new Error("Dashboard response is empty");
|
||||||
const running = pending.get(year);
|
return response.data.data;
|
||||||
if (running) return running;
|
|
||||||
const request = (async () => {
|
|
||||||
const response = await fetchDashboard(year);
|
|
||||||
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;
|
|
||||||
})();
|
|
||||||
pending.set(year, request);
|
|
||||||
try {
|
|
||||||
return await request;
|
|
||||||
} finally {
|
|
||||||
pending.delete(year);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDashboardCache() {
|
|
||||||
cache.clear();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,10 +11,6 @@ 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;
|
||||||
@ -22,27 +18,9 @@ export async function getStandards(params: {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}): Promise<StandardRuleListData> {
|
}): Promise<StandardRuleListData> {
|
||||||
const key = JSON.stringify(params);
|
const response = await fetchStandards(params);
|
||||||
const cached = cache.get(key);
|
if (!response.data.data) throw new Error("标准库响应为空");
|
||||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.data;
|
return response.data.data;
|
||||||
const running = pending.get(key);
|
|
||||||
if (running) return running;
|
|
||||||
const request = (async () => {
|
|
||||||
const response = await fetchStandards(params);
|
|
||||||
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 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> {
|
||||||
@ -56,7 +34,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,6 +43,5 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dingTalkLogin(preserveSessionOnFailure = false) {
|
async function dingTalkLogin() {
|
||||||
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) {
|
||||||
if (!preserveSessionOnFailure) clear();
|
clear();
|
||||||
error.value = reason instanceof Error ? reason.message : "钉钉免登失败";
|
error.value = reason instanceof Error ? reason.message : "钉钉免登失败";
|
||||||
throw reason;
|
throw reason;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, reactive, ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createConversation,
|
createConversation,
|
||||||
deleteConversation,
|
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
fetchConversations,
|
fetchConversations,
|
||||||
withdrawConversationMessage,
|
|
||||||
streamConversationMessage,
|
streamConversationMessage,
|
||||||
sendConversationMessage,
|
sendConversationMessage,
|
||||||
} from "@/api/conversations";
|
} from "@/api/conversations";
|
||||||
@ -18,50 +16,21 @@ 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 plainSending = ref(false);
|
const sending = ref(false);
|
||||||
const loadingOlder = ref(false);
|
const loadingOlder = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
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;
|
try {
|
||||||
listRequest = (async () => {
|
const response = await fetchConversations(50, search);
|
||||||
try {
|
items.value = response.data.data ?? [];
|
||||||
const response = await fetchConversations(50, normalizedSearch);
|
} catch {
|
||||||
items.value = response.data.data ?? [];
|
error.value = "历史对话加载失败";
|
||||||
if (!normalizedSearch) listLoadedAt = Date.now();
|
} finally {
|
||||||
} catch {
|
loading.value = false;
|
||||||
error.value = "历史对话加载失败";
|
}
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
listRequest = null;
|
|
||||||
listRequestSearch = undefined;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return listRequest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(id: number) {
|
async function open(id: number) {
|
||||||
@ -70,19 +39,7 @@ 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("对话响应为空");
|
||||||
const remote = response.data.data;
|
current.value = 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;
|
||||||
@ -100,24 +57,20 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const conversationId = current.value.id;
|
const offset = current.value.messagesOffset + current.value.messages.length;
|
||||||
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(conversationId, offset);
|
const response = await fetchConversation(current.value.id, 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;
|
||||||
const updated = {
|
current.value = {
|
||||||
...base,
|
...current.value,
|
||||||
messages: [...older.messages, ...base.messages],
|
messages: [...older.messages, ...current.value.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;
|
||||||
@ -127,52 +80,40 @@ 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("当前对话不存在");
|
||||||
const conversationId = current.value.id;
|
sending.value = true;
|
||||||
plainSending.value = true;
|
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await sendConversationMessage(
|
const response = await sendConversationMessage(
|
||||||
conversationId,
|
current.value.id,
|
||||||
content,
|
content,
|
||||||
messageType,
|
messageType,
|
||||||
);
|
);
|
||||||
if (!response.data.data) throw new Error("消息响应为空");
|
if (!response.data.data) throw new Error("消息响应为空");
|
||||||
setConversation(response.data.data);
|
current.value = response.data.data;
|
||||||
const index = items.value.findIndex(
|
const index = items.value.findIndex(
|
||||||
(item) => item.id === conversationId,
|
(item) => item.id === current.value?.id,
|
||||||
);
|
);
|
||||||
const summary = toSummary(response.data.data);
|
const summary = toSummary(current.value);
|
||||||
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 response.data.data;
|
return current.value;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : "消息发送失败";
|
error.value = reason instanceof Error ? reason.message : "消息发送失败";
|
||||||
throw reason;
|
throw reason;
|
||||||
} finally {
|
} finally {
|
||||||
plainSending.value = false;
|
sending.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,19 +124,11 @@ 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("当前对话不存在");
|
||||||
const conversationId = current.value.id;
|
sending.value = true;
|
||||||
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: state.userMessageId,
|
id: -Date.now(),
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
messageType,
|
messageType,
|
||||||
content,
|
content,
|
||||||
@ -205,7 +138,7 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
createdAt: startedAt,
|
createdAt: startedAt,
|
||||||
};
|
};
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
id: state.assistantMessageId,
|
id: userMessage.id - 1,
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
messageType: "text" as const,
|
messageType: "text" as const,
|
||||||
content: "",
|
content: "",
|
||||||
@ -214,201 +147,86 @@ export const useConversationStore = defineStore("conversation", () => {
|
|||||||
semantic: null as Record<string, unknown> | null,
|
semantic: null as Record<string, unknown> | null,
|
||||||
createdAt: startedAt,
|
createdAt: startedAt,
|
||||||
};
|
};
|
||||||
const optimistic = {
|
current.value = {
|
||||||
...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) {
|
if (event.type === "semantic" && event.data && current.value) {
|
||||||
const cached = conversationCache.get(conversationId);
|
const messages = [...current.value.messages];
|
||||||
if (!cached) return;
|
const last = messages[messages.length - 1];
|
||||||
const messages = cached.messages.map((message) =>
|
if (last?.role === "assistant") {
|
||||||
message.id === state.assistantMessageId
|
messages[messages.length - 1] = {
|
||||||
? {
|
...last,
|
||||||
...message,
|
|
||||||
semantic: event.data as Record<string, unknown>,
|
semantic: event.data as Record<string, unknown>,
|
||||||
}
|
};
|
||||||
: message,
|
current.value = { ...current.value, messages };
|
||||||
);
|
}
|
||||||
setConversation({ ...cached, messages });
|
|
||||||
}
|
}
|
||||||
if (event.type === "delta" && event.content) {
|
if (event.type === "delta" && event.content && current.value) {
|
||||||
const cached = conversationCache.get(conversationId);
|
const messages = [...current.value.messages];
|
||||||
if (!cached) return;
|
const last = messages[messages.length - 1];
|
||||||
const messages = cached.messages.map((message) =>
|
if (last?.role === "assistant") {
|
||||||
message.id === state.assistantMessageId
|
messages[messages.length - 1] = {
|
||||||
? { ...message, content: message.content + event.content }
|
...last,
|
||||||
: message,
|
content: last.content + event.content,
|
||||||
);
|
};
|
||||||
setConversation({ ...cached, messages });
|
current.value = { ...current.value, 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)
|
||||||
setConversation(event.data as Conversation);
|
current.value = event.data as Conversation;
|
||||||
},
|
},
|
||||||
messageType,
|
messageType,
|
||||||
state.controller.signal,
|
|
||||||
);
|
);
|
||||||
const completed = conversationCache.get(conversationId);
|
|
||||||
if (
|
if (
|
||||||
!completed ||
|
!current.value ||
|
||||||
!completed.messages.some(
|
!current.value.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(completed);
|
|
||||||
const index = items.value.findIndex((item) => item.id === summary.id);
|
|
||||||
if (index >= 0) items.value.splice(index, 1, summary);
|
|
||||||
else items.value.unshift(summary);
|
|
||||||
return 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);
|
const summary = toSummary(current.value);
|
||||||
|
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 current.value;
|
return current.value;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : "撤回失败";
|
const conversationId = current.value?.id;
|
||||||
|
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 {
|
||||||
loading.value = false;
|
sending.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,19 +250,14 @@ 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import axios from "axios";
|
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
@ -7,7 +6,6 @@ import {
|
|||||||
fetchPendingRiskItems,
|
fetchPendingRiskItems,
|
||||||
reviewRiskItem,
|
reviewRiskItem,
|
||||||
updateRiskItem,
|
updateRiskItem,
|
||||||
withdrawRiskItem,
|
|
||||||
} from "@/api/riskItems";
|
} from "@/api/riskItems";
|
||||||
import type {
|
import type {
|
||||||
RiskItem,
|
RiskItem,
|
||||||
@ -61,35 +59,10 @@ export const useReviewStore = defineStore("review", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function review(itemId: number, action: "通过" | "驳回", note: string) {
|
async function review(itemId: number, action: "通过" | "驳回", note: string) {
|
||||||
error.value = null;
|
const response = await reviewRiskItem(itemId, { action, note });
|
||||||
try {
|
if (!response.data.data) throw new Error("审核响应为空");
|
||||||
const response = await reviewRiskItem(itemId, { action, note });
|
items.value = items.value.filter((item) => item.id !== itemId);
|
||||||
if (!response.data.data) throw new Error("审核响应为空");
|
return response.data.data;
|
||||||
items.value = items.value.filter((item) => item.id !== itemId);
|
|
||||||
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 {
|
||||||
@ -101,6 +74,5 @@ export const useReviewStore = defineStore("review", () => {
|
|||||||
submit,
|
submit,
|
||||||
update,
|
update,
|
||||||
review,
|
review,
|
||||||
withdraw,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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" | "cases";
|
type: "supplier" | "material" | "standard" | "compliance" | "solution";
|
||||||
title: string;
|
title: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -9,29 +9,13 @@ 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;
|
||||||
@ -39,22 +23,12 @@ export interface RiskAssessmentResult {
|
|||||||
modelVersion: string;
|
modelVersion: string;
|
||||||
factors: RiskFactorResult[];
|
factors: RiskFactorResult[];
|
||||||
similarCases?: Array<{
|
similarCases?: Array<{
|
||||||
caseId?: number | null;
|
date: string;
|
||||||
date?: string | null;
|
product: string;
|
||||||
product?: string | null;
|
problem: string;
|
||||||
category?: string | null;
|
solution: string;
|
||||||
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?: {
|
||||||
@ -69,12 +43,9 @@ export interface RiskAssessmentResult {
|
|||||||
supplierName: string | null;
|
supplierName: string | null;
|
||||||
cooperationCount: number | null;
|
cooperationCount: number | null;
|
||||||
anomalyCount: number | null;
|
anomalyCount: number | null;
|
||||||
alpha: number | null;
|
alpha: number;
|
||||||
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;
|
||||||
@ -83,66 +54,13 @@ export interface RiskAssessmentResult {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
dimensionScores?: RiskDimensionScore[];
|
dimensionScores?: Array<{
|
||||||
}
|
dimension: string;
|
||||||
|
score: number;
|
||||||
export interface AiSolutionReport {
|
weight: number;
|
||||||
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;
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
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;
|
||||||
@ -12,8 +10,6 @@ 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 {
|
||||||
@ -24,22 +20,7 @@ export interface RiskItem extends RiskItemCreate {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RiskItemUpdate = Partial<
|
export type RiskItemUpdate = Partial<RiskItemCreate>;
|
||||||
Pick<
|
|
||||||
RiskItemCreate,
|
|
||||||
| "occurredOn"
|
|
||||||
| "productName"
|
|
||||||
| "productCategory"
|
|
||||||
| "materialType"
|
|
||||||
| "supplier"
|
|
||||||
| "factory"
|
|
||||||
| "stageNew"
|
|
||||||
| "description"
|
|
||||||
| "defectLevel"
|
|
||||||
| "solution"
|
|
||||||
| "photos"
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface ReviewAction {
|
export interface ReviewAction {
|
||||||
action: "通过" | "驳回";
|
action: "通过" | "驳回";
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
export const roleLabels: Record<string, string> = {
|
|
||||||
admin: "管理员",
|
|
||||||
product_manager: "产品经理",
|
|
||||||
reviewer: "审核员",
|
|
||||||
user: "普通用户",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function roleLabel(role: string | null | undefined) {
|
|
||||||
return role ? (roleLabels[role] ?? role) : "未登录";
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user