diff --git a/index.html b/index.html index c1a47f9..91ca25a 100644 --- a/index.html +++ b/index.html @@ -4,11 +4,10 @@ - 蝶雅风险预测 + 蝶雅风险预警助手
- diff --git a/src/App.vue b/src/App.vue index 1ba91c8..3ae44b8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ import { onMounted, onUnmounted } from "vue"; import { RouterView } from "vue-router"; +import { isDingTalkContainer } from "@/api/dingtalk"; import router from "@/router"; import { useAuthStore } from "@/stores/auth.store"; @@ -17,9 +18,28 @@ function handleAuthExpired() { } } -onMounted(() => - window.addEventListener("risk-auth-expired", handleAuthExpired), -); +async function syncDingTalkUser() { + if ( + !isDingTalkContainer() || + !auth.user?.dingtalkId || + auth.user.dingtalkId.startsWith("local-") + ) { + return; + } + try { + await auth.dingTalkLogin(true); + } catch { + // A stale token must remain usable if DingTalk is temporarily unavailable. + } +} + +onMounted(async () => { + window.addEventListener("risk-auth-expired", handleAuthExpired); + // Restore /auth/me first: on a hard refresh auth.user starts as null, so a + // synchronous check would skip the DingTalk profile resync entirely. + if (auth.token && !auth.user) await auth.restore(); + await syncDingTalkUser(); +}); onUnmounted(() => window.removeEventListener("risk-auth-expired", handleAuthExpired), ); diff --git a/src/api/conversations.ts b/src/api/conversations.ts index 211bb15..503e6f7 100644 --- a/src/api/conversations.ts +++ b/src/api/conversations.ts @@ -1,6 +1,6 @@ import { http } from "@/api/http"; -import type { ApiResponse } from "@/types/api"; import type { Conversation, ConversationSummary } from "@/types/conversation"; +import type { ApiResponse } from "@/types/api"; export interface ConversationStreamEvent { type: "start" | "semantic" | "delta" | "done" | "error"; @@ -12,6 +12,7 @@ export interface ConversationStreamEvent { export function fetchConversations(limit = 50, search?: string) { return http.get>("/v1/conversations", { params: { limit, search }, + timeout: 30_000, }); } @@ -21,12 +22,21 @@ export function createConversation(title?: string) { }); } +export function directConversationQuery(kind: "supplier" | "material" | "cases" | "solution", query: string) { + return http.post>>("/v1/conversations/direct-query", { kind, query }); +} + export function fetchConversation(id: number, offset = 0) { return http.get>(`/v1/conversations/${id}`, { params: { limit: 20, offset }, + timeout: 60_000, }); } +export function deleteConversation(id: number) { + return http.delete>(`/v1/conversations/${id}`); +} + export function sendConversationMessage( id: number, content: string, @@ -38,25 +48,47 @@ export function sendConversationMessage( ); } +export function withdrawConversationMessage( + conversationId: number, + messageId: number, +) { + return http.delete>( + `/v1/conversations/${conversationId}/messages/${messageId}`, + ); +} + export async function streamConversationMessage( id: number, content: string, onEvent: (event: ConversationStreamEvent) => void, messageType: "text" | "image" | "file" | "audio" = "text", + signal?: AbortSignal, ) { const token = localStorage.getItem("risk_prediction_token"); - const response = await fetch( - `${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}` } : {}), + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(); + if (signal?.aborted) controller.abort(); + signal?.addEventListener("abort", abortFromCaller, { once: true }); + const timeout = window.setTimeout(() => controller.abort(), 180_000); + let response: Response; + try { + response = await fetch( + `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`, + { + 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) { localStorage.removeItem("risk_prediction_token"); window.dispatchEvent(new CustomEvent("risk-auth-expired")); @@ -64,32 +96,55 @@ export async function streamConversationMessage( if (!response.ok || !response.body) throw new Error(`流式请求失败(HTTP ${response.status})`); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let completed = false; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (!line.startsWith("data:")) continue; - const raw = line.slice(5).trim(); - if (!raw) continue; - const event = JSON.parse(raw) as ConversationStreamEvent; - onEvent(event); - if (event.type === "done") completed = true; + try { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let completed = false; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const raw = line.slice(5).trim(); + if (!raw) continue; + let event: ConversationStreamEvent; + try { + 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) { - const event = JSON.parse(raw) as ConversationStreamEvent; - 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 (!completed) throw new Error("AI 流式连接中断,请重试"); } diff --git a/src/api/dingtalk.ts b/src/api/dingtalk.ts index 7ab49d8..91aeac6 100644 --- a/src/api/dingtalk.ts +++ b/src/api/dingtalk.ts @@ -3,14 +3,43 @@ import dd from "dingtalk-jsapi"; import { getDingTalkConfig, loginWithDingTalk } from "@/api/auth"; import type { AuthTokenData } from "@/types/auth"; -function isDingTalkContainer() { - return ( - typeof navigator !== "undefined" && /DingTalk/i.test(navigator.userAgent) +declare global { + interface Window { + dingtalk?: { platform?: { invokeAPI?: unknown } }; + DingTalkJSBridge?: unknown; + } +} + +export function isDingTalkContainer() { + if (typeof window === "undefined" || typeof navigator === "undefined") + return false; + const userAgent = + navigator.userAgent || + (navigator as Navigator & { swuserAgent?: string }).swuserAgent || + ""; + return Boolean( + /DingTalk|dingtalk-win|dd-web/i.test(userAgent) || + window.DingTalkJSBridge || + window.dingtalk?.platform?.invokeAPI || + // DingTalk desktop can expose its container identity through window.name + // even when the embedded browser omits DingTalk from userAgent. + /dingtalk/i.test(window.name || ""), ); } +export async function waitForDingTalkContainer(timeoutMs = 5000) { + if (isDingTalkContainer()) return true; + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => window.setTimeout(resolve, 100)); + if (isDingTalkContainer()) return true; + } + return false; +} + export async function authenticateWithDingTalk(): Promise { - if (!isDingTalkContainer()) throw new Error("请从钉钉工作台打开此应用"); + if (!(await waitForDingTalkContainer())) + throw new Error("请从钉钉工作台打开此应用"); const configResponse = await getDingTalkConfig(); const config = configResponse.data.data; if (!config) throw new Error("钉钉免登配置不完整"); diff --git a/src/api/modelConfig.ts b/src/api/modelConfig.ts new file mode 100644 index 0000000..39e955e --- /dev/null +++ b/src/api/modelConfig.ts @@ -0,0 +1,17 @@ +import { http } from "@/api/http"; +import type { ApiResponse } from "@/types/api"; +import type { + ModelWeightConfig, + ModelWeightConfigUpdate, +} from "@/types/modelConfig"; + +export function fetchModelConfig() { + return http.get>("/v1/model-config"); +} + +export function updateModelConfig(payload: ModelWeightConfigUpdate) { + return http.patch>( + "/v1/model-config", + payload, + ); +} diff --git a/src/api/monitoring.ts b/src/api/monitoring.ts new file mode 100644 index 0000000..534ec03 --- /dev/null +++ b/src/api/monitoring.ts @@ -0,0 +1,9 @@ +import { http } from "@/api/http"; + +export function addRiskMonitor(payload: { subjectId: string; score: number; level: string }) { + return http.post("/v1/monitoring/risk", { + subjectId: payload.subjectId, + score: payload.score, + level: payload.level, + }); +} diff --git a/src/api/ocr.ts b/src/api/ocr.ts new file mode 100644 index 0000000..24f0866 --- /dev/null +++ b/src/api/ocr.ts @@ -0,0 +1,31 @@ +import { http } from "@/api/http"; +import type { ApiResponse } from "@/types/api"; +import type { + OcrBatchResult, + OcrMeasurement, + OcrStandardJudgment, +} from "@/types/ocr"; + +export function recognizeOcrFiles(files: File[]) { + const body = new FormData(); + files.forEach((file) => body.append("files", file)); + return http.post>("/v1/ocr/recognize", body, { + timeout: 180_000, + }); +} + +export function judgeOcrMeasurements(measurements: OcrMeasurement[]) { + return http.post>("/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", + }); +} diff --git a/src/api/reports.ts b/src/api/reports.ts new file mode 100644 index 0000000..dc40894 --- /dev/null +++ b/src/api/reports.ts @@ -0,0 +1,16 @@ +import { http } from "@/api/http"; +import type { RiskReportResult } from "@/types/risk"; + +export function downloadReportPdf(payload: RiskReportResult) { + return http.post("/v1/reports/pdf", payload, { + responseType: "blob", + timeout: 60_000, + }); +} + +export function downloadReportWord(payload: RiskReportResult) { + return http.post("/v1/reports/word", payload, { + responseType: "blob", + timeout: 60_000, + }); +} diff --git a/src/api/riskItems.ts b/src/api/riskItems.ts index 22b68b6..288bb51 100644 --- a/src/api/riskItems.ts +++ b/src/api/riskItems.ts @@ -28,6 +28,10 @@ export function updateRiskItem(itemId: number, payload: RiskItemUpdate) { return http.patch>(`/v1/risk-items/${itemId}`, payload); } +export function withdrawRiskItem(itemId: number) { + return http.post>(`/v1/risk-items/${itemId}/withdraw`); +} + export function reviewRiskItem(itemId: number, payload: ReviewAction) { return http.post>( `/v1/risk-items/${itemId}/review`, diff --git a/src/api/supplierProfile.ts b/src/api/supplierProfile.ts new file mode 100644 index 0000000..4eeb077 --- /dev/null +++ b/src/api/supplierProfile.ts @@ -0,0 +1,39 @@ +import { http } from "@/api/http"; +import type { ApiResponse } from "@/types/api"; +import type { SupplierProfile } from "@/types/supplierProfile"; + +const CACHE_TTL_MS = 30_000; +const cache = new Map(); +const pending = new Map>(); + +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>( + "/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(); +} diff --git a/src/api/suppliers.ts b/src/api/suppliers.ts new file mode 100644 index 0000000..2c5756a --- /dev/null +++ b/src/api/suppliers.ts @@ -0,0 +1,48 @@ +import { http } from "@/api/http"; +import type { ApiResponse } from "@/types/api"; + +export interface SupplierOption { + name: string; + anomalyCount: number; + tier: string; + city?: string | null; + province?: string | null; + shortName?: string | null; + reviewStatus?: string | null; +} +export interface SupplierListData { + items: SupplierOption[]; + defaultSupplier: string | null; +} + +const CACHE_TTL_MS = 30_000; +let cached: { data: SupplierListData; at: number } | null = null; +let pending: Promise | 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>( + "/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; +} diff --git a/src/components/assistant/AssistantComposer.vue b/src/components/assistant/AssistantComposer.vue index 3244fad..386de18 100644 --- a/src/components/assistant/AssistantComposer.vue +++ b/src/components/assistant/AssistantComposer.vue @@ -11,7 +11,16 @@ import { Search, } from "@lucide/vue"; -defineProps<{ modelValue: string; loading: boolean }>(); +import { ref } from "vue"; + +import { featureFlags } from "@/config/features"; + +const props = defineProps<{ + modelValue: string; + loading: boolean; + canUpload: boolean; + canReview: boolean; +}>(); const emit = defineEmits<{ "update:modelValue": [value: string]; @@ -29,6 +38,13 @@ const emit = defineEmits<{ | "voice", ]; }>(); +const textarea = ref(null); + +function focus() { + textarea.value?.focus(); +} + +defineExpose({ focus }); const functions: Array<{ title: string; @@ -36,6 +52,7 @@ const functions: Array<{ icon: typeof Search; tone: string; prompt?: string; + isNew?: boolean; tool: "assessment" | "supplier" | "material" | "cases" | "solution" | "review"; }> = [ @@ -78,6 +95,7 @@ const functions: Array<{ tone: "purple", prompt: "帮我生成一个新品风险预防方案", tool: "solution", + isNew: true, }, { title: "审核队列", @@ -85,6 +103,7 @@ const functions: Array<{ icon: ClipboardCheck, tone: "red", tool: "review", + isNew: true, }, ]; @@ -93,6 +112,7 @@ const functions: Array<{