风险预警

This commit is contained in:
kx_guo 2026-09-04 10:22:37 +08:00
commit b672f025dd
66 changed files with 15221 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL=/api

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
dist/
.env
.env.*
!.env.example
.vite/
.eslintcache
coverage/

View File

@ -0,0 +1,155 @@
09:12:40 [vite] (client) [console.warn] [Vue warn]: Unhandled error during execution of mounted hook
at <RiskAssistantPage onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< {} > >
at <RouterView>
at <MainLayout onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< {} > >
at <RouterView>
at <App>
09:12:40 [vite] (client) [Unhandled rejection] AxiosError: Request failed with status code 404
> settle node_modules/axios/lib/core/settle.js:19:11
> XMLHttpRequest.onloadend node_modules/axios/lib/adapters/xhr.js:109:8
> Axios$1.request node_modules/axios/lib/core/Axios.js:48:42
> Proxy.open src/stores/conversation.store.ts:40:23
38 | error.value = null;
39 | try {
40 | const response = await fetchConversation(id);
| ^
41 | if (!response.data.data) throw new Error("对话响应为空");
42 | current.value = response.data.data;
> openFromRoute src/pages/RiskAssistantPage.vue:195:4
> src/pages/RiskAssistantPage.vue:75:2
09:12:45 [vite] (client) [console.warn] [Vue warn]: Unhandled error during execution of mounted hook
at <RiskAssistantPage onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< {} > >
at <RouterView>
at <MainLayout onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< {} > >
at <RouterView>
at <App>
09:12:45 [vite] (client) [Unhandled rejection] AxiosError: Request failed with status code 404
> settle node_modules/axios/lib/core/settle.js:19:11
> XMLHttpRequest.onloadend node_modules/axios/lib/adapters/xhr.js:109:8
> Axios$1.request node_modules/axios/lib/core/Axios.js:48:42
> Proxy.open src/stores/conversation.store.ts:40:23
38 | error.value = null;
39 | try {
40 | const response = await fetchConversation(id);
| ^
41 | if (!response.data.data) throw new Error("对话响应为空");
42 | current.value = response.data.data;
> openFromRoute src/pages/RiskAssistantPage.vue:195:4
> src/pages/RiskAssistantPage.vue:75:2
09:12:47 [vite] (client) [console.warn] [Vue warn]: Unhandled error during execution of mounted hook
at <RiskAssistantPage onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< {} > >
at <RouterView>
at <MainLayout onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< {} > >
at <RouterView>
at <App>
09:12:47 [vite] (client) [Unhandled rejection] AxiosError: Request failed with status code 404
> settle node_modules/axios/lib/core/settle.js:19:11
> XMLHttpRequest.onloadend node_modules/axios/lib/adapters/xhr.js:109:8
> Axios$1.request node_modules/axios/lib/core/Axios.js:48:42
> Proxy.open src/stores/conversation.store.ts:40:23
38 | error.value = null;
39 | try {
40 | const response = await fetchConversation(id);
| ^
41 | if (!response.data.data) throw new Error("对话响应为空");
42 | current.value = response.data.data;
> openFromRoute src/pages/RiskAssistantPage.vue:195:4
> src/pages/RiskAssistantPage.vue:75:2
09:31:14 [vite] Internal server error: Failed to resolve import "@/components/assistant/AssistantResponseCard.vue" from "src/pages/RiskAssistantPage.vue". Does the file exist?
Plugin: vite:import-analysis
File: D:/py_qoder/dieya_risk_prediction_frontend/src/pages/RiskAssistantPage.vue:9:34
6 | import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue";
7 | import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue";
8 | import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue";
| ^
9 | import { useConversationStore } from "@/stores/conversation.store";
10 | import { useAuthStore } from "@/stores/auth.store";
at TransformPluginContext._formatLog (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:31147:39)
at TransformPluginContext.error (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:31144:14)
at normalizeUrl (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:28083:18)
at async file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:28153:30
at async Promise.all (index 7)
at async TransformPluginContext.transform (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:28119:4)
at async EnvironmentPluginContainer.transform (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:30932:14)
at async loadAndTransform (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:20671:26)
at async viteTransformMiddleware (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:25205:20)
09:31:14 [vite] (client) [console.error] [vite] Failed to reload /src/pages/RiskAssistantPage.vue. This could be due to syntax errors or importing non-existent modules. (see errors above)
09:31:14 [vite] Internal server error: Failed to resolve import "@/components/assistant/AssistantResponseCard.vue" from "src/pages/RiskAssistantPage.vue". Does the file exist?
Plugin: vite:import-analysis
File: D:/py_qoder/dieya_risk_prediction_frontend/src/pages/RiskAssistantPage.vue:9:34
6 | import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue";
7 | import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue";
8 | import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue";
| ^
9 | import { useConversationStore } from "@/stores/conversation.store";
10 | import { useAuthStore } from "@/stores/auth.store";
at TransformPluginContext._formatLog (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:31147:39)
at TransformPluginContext.error (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:31144:14)
at normalizeUrl (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:28083:18)
at async file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:28153:30
at async Promise.all (index 7)
at async TransformPluginContext.transform (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:28119:4)
at async EnvironmentPluginContainer.transform (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:30932:14)
at async loadAndTransform (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:20671:26)
at async viteTransformMiddleware (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/vite/dist/node/chunks/node.js:25205:20)
09:31:14 [vite] (client) [console.error] [vite] Failed to reload /src/pages/RiskAssistantPage.vue. This could be due to syntax errors or importing non-existent modules. (see errors above)
14:02:39 [vite] (client) [console.warn] [VUE_ROUTER_R0010] Uncaught error during route navigation
╰▶ fix: Register an error handler with `router.onError()` to handle navigation errors.
14:02:39 [vite] (client) [console.error] TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:39158/src/pages/OverviewPage.vue
14:12:11 [vite] (client) [console.warn] [VUE_ROUTER_R0010] Uncaught error during route navigation
╰▶ fix: Register an error handler with `router.onError()` to handle navigation errors.
14:12:11 [vite] (client) [console.error] TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:39158/src/pages/OverviewPage.vue
14:12:11 [vite] (client) [console.warn] [VUE_ROUTER_R0010] Uncaught error during route navigation
╰▶ fix: Register an error handler with `router.onError()` to handle navigation errors.
14:12:11 [vite] (client) [console.error] TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:39158/src/pages/OverviewPage.vue
14:12:13 [vite] (client) [console.warn] [VUE_ROUTER_R0010] Uncaught error during route navigation
╰▶ fix: Register an error handler with `router.onError()` to handle navigation errors.
14:12:13 [vite] (client) [console.error] TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:39158/src/pages/OverviewPage.vue
16:59:34 [vite] Internal server error: D:/py_qoder/dieya_risk_prediction_frontend/src/pages/OverviewPage.vue:513:1: Unexpected }
Plugin: vite:vue
File: D:/py_qoder/dieya_risk_prediction_frontend/src/pages/OverviewPage.vue:954:1
952 | }
953 | }
954 | .rank-row {
| ^
955 | display: grid;
956 | grid-template-columns: 22px 92px minmax(70px, 1fr) 30px 29px;
at Input.error (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\input.js:135:16)
at Parser.unexpectedClose (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\parser.js:594:22)
at Parser.end (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\parser.js:337:12)
at Parser.parse (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\parser.js:463:16)
at parse (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\parse.js:11:12)
at new LazyResult (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\lazy-result.js:171:16)
at Processor.process (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\postcss\lib\processor.js:53:14)
at doCompileStyle (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\@vue\compiler-sfc\dist\compiler-sfc.cjs.js:20027:36)
at Object.compileStyleAsync (D:\py_qoder\dieya_risk_prediction_frontend\node_modules\@vue\compiler-sfc\dist\compiler-sfc.cjs.js:19947:10)
at transformStyle (file:///D:/py_qoder/dieya_risk_prediction_frontend/node_modules/@vitejs/plugin-vue/dist/index.mjs:1583:40)
16:59:34 [vite] (client) [console.error] [vite] Failed to reload /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css. This could be due to syntax errors or importing non-existent modules. (see errors above)
17:00:12 [vite] (client) Pre-transform error: D:/py_qoder/dieya_risk_prediction_frontend/src/pages/OverviewPage.vue:513:1: Unexpected }
Plugin: vite:vue
File: D:/py_qoder/dieya_risk_prediction_frontend/src/pages/OverviewPage.vue:954:1
952 | }
953 | }
954 | .rank-row {
| ^
955 | display: grid;
956 | grid-template-columns: 22px 92px minmax(70px, 1fr) 30px 29px;
17:00:12 [vite] (client) [console.error] [vite] Failed to reload /src/pages/OverviewPage.vue. This could be due to syntax errors or importing non-existent modules. (see errors above)
10:12:37 [vite] (client) [console.warn] [VUE_ROUTER_R0004] No match found for location with path "/%E5%86%85%E6%96%99%E6%84%9F%E5%AE%98%E4%B8%8A%E4%B8%8B%E9%99%90%E9%AA%8C%E6%94%B6%E6%A0%87%E5%87%86%EF%BC%88%E5%8F%82%E8%80%83%EF%BC%89"
├▶ fix: Add a route matching this path or check for typos in the location.
╰▶ see: https://router.vuejs.org/guide/essentials/dynamic-matching.html#Catch-all-404-Not-found-Route
15:37:06 [vite] http proxy error: /api/v1/conversations?limit=50
Error: connect ECONNREFUSED 127.0.0.1:18474
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16)
15:37:06 [vite] http proxy error: /api/v1/standards?category=all&status=%E5%90%AF%E7%94%A8&page=1&pageSize=20
Error: connect ECONNREFUSED 127.0.0.1:18474
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16)
15:37:06 [vite] http proxy error: /api/v1/health
Error: connect ECONNREFUSED 127.0.0.1:18474
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16)
15:46:46 [vite] http proxy error: /api/v1/auth/me
Error: connect ECONNREFUSED 127.0.0.1:18474
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16)

View File

@ -0,0 +1,59 @@
> dieya-risk-prediction-frontend@0.1.0 dev
> vite
VITE v8.2.2 ready in 197 ms
➜ Local: http://127.0.0.1:39158/
➜ press h + enter to show help
09:13:48 [vite] (client) hmr update /src/pages/RiskAssistantPage.vue
09:31:14 [vite] (client) hmr update /src/components/layout/AppSidebar.vue, /src/pages/RiskAssistantPage.vue
09:31:14 [vite] (client) hmr update /src/pages/RiskAssistantPage.vue
09:32:32 [vite] (client) page reload src/components/assistant/AssistantResponseCard.vue
09:33:53 [vite] (client) hmr update /src/components/assistant/AssistantResponseCard.vue
09:34:23 [vite] (client) hmr update /src/components/assistant/AssistantResponseCard.vue, /src/components/assistant/AssistantResponseCard.vue?vue&type=style&index=0&scoped=c97b3f24&lang.css
09:34:23 [vite] (client) hmr update /src/pages/RiskAssistantPage.vue
09:34:47 [vite] (client) hmr update /src/components/assistant/AssistantResponseCard.vue
14:00:31 [vite] (client) hmr update /src/pages/OverviewPage.vue, /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:04:54 [vite] (client) hmr update /src/pages/OverviewPage.vue, /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:05:13 [vite] (client) hmr update /src/pages/OverviewPage.vue, /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:24:47 [vite] (client) hmr update /src/pages/OverviewPage.vue
14:39:34 [vite] (client) hmr update /src/pages/OverviewPage.vue
14:40:32 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:40:49 [vite] (client) hmr update /src/pages/OverviewPage.vue, /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:46:58 [vite] (client) hmr update /src/pages/OverviewPage.vue
14:47:08 [vite] (client) hmr update /src/pages/OverviewPage.vue
14:47:39 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:47:54 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:48:29 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
14:58:40 [vite] (client) hmr update /src/pages/OverviewPage.vue
14:58:54 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
16:44:35 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:45:03 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:45:17 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
16:45:28 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:45:39 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
16:46:25 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:56:07 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:56:48 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:58:57 [vite] (client) hmr update /src/pages/OverviewPage.vue
16:59:06 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
16:59:19 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
16:59:34 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
17:00:12 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:01:23 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
17:03:24 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
17:03:53 [vite] (client) hmr update /src/pages/OverviewPage.vue?vue&type=style&index=0&scoped=18441908&lang.css
17:04:02 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:25:14 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:25:23 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:26:56 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:29:37 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:31:46 [vite] (client) hmr update /src/pages/OverviewPage.vue
17:32:14 [vite] (client) hmr update /src/pages/OverviewPage.vue
09:51:39 [vite] (client) page reload src/router/routes.ts
09:51:39 [vite] (client) hmr update /src/components/layout/AppSidebar.vue
09:51:39 [vite] (client) hmr update /src/components/layout/AppHeader.vue
10:09:52 [vite] (client) hmr update /src/pages/StandardsPage.vue
10:13:54 [vite] (client) hmr update /src/pages/StandardsPage.vue, /src/pages/StandardsPage.vue?vue&type=style&index=0&scoped=f4429b1d&lang.css

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# Dieya Risk Prediction Frontend
本项目参考 `dy_finance_agent_frontend` 的组织方式,使用 Vue 3、TypeScript、Vite、Pinia、Vue Router 和 Axios。
## 本地启动
```powershell
npm install
npm run dev
```
开发地址:`http://127.0.0.1:39158`
Vite 会将 `/api` 代理到 `http://127.0.0.1:18474`。架构说明见 [docs/architecture.md](docs/architecture.md)。

22
docs/architecture.md Normal file
View File

@ -0,0 +1,22 @@
# 前端架构
前端采用 Vue 3 组合式 API 和按职责分层的单页应用架构。
```text
Page / Component
-> composables 页面用例组合
-> stores Pinia 全局状态
-> services 响应解包和业务调用
-> api Axios 与后端端点
-> backend
```
- `layouts`:应用整体布局。
- `pages`:路由页面,只编排组件和状态。
- `components`:布局、通用和风险领域组件。
- `stores`:跨页面状态、加载态和错误态。
- `services`:面向业务的接口,隔离 HTTP 响应结构。
- `api`:Axios 实例和 REST 请求定义。
- `types`:API 与领域类型。
- `styles`:设计变量、重置和全局样式。

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import eslint from '@eslint/js'
import prettier from 'eslint-config-prettier'
import vue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
...vue.configs['flat/recommended'],
{ languageOptions: { globals: { ...globals.browser } } },
{
files: ['**/*.vue'],
languageOptions: {
parser: vue.parser,
parserOptions: { parser: tseslint.parser, ecmaVersion: 'latest', sourceType: 'module' },
},
},
{
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
prettier,
)

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17201d" />
<title>蝶雅风险预测</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4887
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "dieya-risk-prediction-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"test": "vitest run",
"lint": "eslint . && prettier --check \"src/**/*.{ts,vue,css}\"",
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
},
"dependencies": {
"dingtalk-jsapi": "^3.2.9",
"axios": "^1.19.0",
"@lucide/vue": "^1.34.0",
"pinia": "^4.0.3",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vite": "^8.2.1",
"vitest": "^4.1.11",
"vue-tsc": "^3.3.10"
}
}

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { RouterView } from "vue-router";
import router from "@/router";
import { useAuthStore } from "@/stores/auth.store";
const auth = useAuthStore();
function handleAuthExpired() {
auth.clear();
if (router.currentRoute.value.name !== "login") {
router.push({
name: "login",
query: { redirect: router.currentRoute.value.fullPath },
});
}
}
onMounted(() =>
window.addEventListener("risk-auth-expired", handleAuthExpired),
);
onUnmounted(() =>
window.removeEventListener("risk-auth-expired", handleAuthExpired),
);
</script>
<template>
<RouterView />
</template>

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

@ -0,0 +1,31 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type { AuthTokenData, DingTalkConfig, User } from "@/types/auth";
export function login(username: string, password: string) {
return http.post<ApiResponse<AuthTokenData>>("/v1/auth/login", {
username,
password,
});
}
export function devLogin(username: string, password: string) {
return http.post<ApiResponse<AuthTokenData>>("/v1/auth/dev-login", {
username,
password,
});
}
export function getCurrentUser() {
return http.get<ApiResponse<User>>("/v1/auth/me");
}
export function getDingTalkConfig() {
return http.get<ApiResponse<DingTalkConfig>>("/v1/auth/dingtalk/config");
}
export function loginWithDingTalk(authCode: string) {
return http.post<ApiResponse<AuthTokenData>>("/v1/auth/dingtalk/login", {
authCode,
});
}

95
src/api/conversations.ts Normal file
View File

@ -0,0 +1,95 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type { Conversation, ConversationSummary } from "@/types/conversation";
export interface ConversationStreamEvent {
type: "start" | "semantic" | "delta" | "done" | "error";
content?: string;
message?: string;
data?: Conversation | Record<string, unknown>;
}
export function fetchConversations(limit = 50, search?: string) {
return http.get<ApiResponse<ConversationSummary[]>>("/v1/conversations", {
params: { limit, search },
});
}
export function createConversation(title?: string) {
return http.post<ApiResponse<Conversation>>("/v1/conversations", {
title,
});
}
export function fetchConversation(id: number, offset = 0) {
return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, {
params: { limit: 20, offset },
});
}
export function sendConversationMessage(
id: number,
content: string,
messageType: "text" | "image" | "file" | "audio" = "text",
) {
return http.post<ApiResponse<Conversation>>(
`/v1/conversations/${id}/messages`,
{ content, messageType },
);
}
export async function streamConversationMessage(
id: number,
content: string,
onEvent: (event: ConversationStreamEvent) => void,
messageType: "text" | "image" | "file" | "audio" = "text",
) {
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}` } : {}),
},
body: JSON.stringify({ content, messageType }),
},
);
if (response.status === 401) {
localStorage.removeItem("risk_prediction_token");
window.dispatchEvent(new CustomEvent("risk-auth-expired"));
}
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;
}
}
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 流式连接中断,请重试");
}

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

@ -0,0 +1,9 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type { DashboardData, DashboardYear } from "@/types/dashboard";
export function fetchDashboard(year: DashboardYear) {
return http.get<ApiResponse<DashboardData>>("/v1/dashboard", {
params: { year },
});
}

25
src/api/dingtalk.ts Normal file
View File

@ -0,0 +1,25 @@
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)
);
}
export async function authenticateWithDingTalk(): Promise<AuthTokenData> {
if (!isDingTalkContainer()) throw new Error("请从钉钉工作台打开此应用");
const configResponse = await getDingTalkConfig();
const config = configResponse.data.data;
if (!config) throw new Error("钉钉免登配置不完整");
const result = await dd.runtime.permission.requestAuthCode({
corpId: config.corpId,
});
const authCode = result?.code;
if (!authCode) throw new Error("钉钉未返回免登授权码");
const response = await loginWithDingTalk(authCode);
if (!response.data.data) throw new Error("钉钉登录响应为空");
return response.data.data;
}

6
src/api/health.ts Normal file
View File

@ -0,0 +1,6 @@
import { http } from "@/api/http";
import type { ApiResponse, HealthData } from "@/types/api";
export function fetchHealth() {
return http.get<ApiResponse<HealthData>>("/v1/health");
}

27
src/api/http.ts Normal file
View File

@ -0,0 +1,27 @@
import axios from "axios";
export const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
timeout: 15_000,
headers: { Accept: "application/json" },
});
http.interceptors.request.use((config) => {
const token = localStorage.getItem("risk_prediction_token");
if (token) {
config.headers = config.headers ?? {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
http.interceptors.response.use(
(response) => response,
(error: unknown) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
localStorage.removeItem("risk_prediction_token");
window.dispatchEvent(new CustomEvent("risk-auth-expired"));
}
return Promise.reject(error);
},
);

36
src/api/riskItems.ts Normal file
View File

@ -0,0 +1,36 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
RiskItem,
RiskItemCreate,
RiskItemUpdate,
ReviewAction,
ReviewRecord,
} from "@/types/riskItem";
export function createRiskItem(payload: RiskItemCreate) {
return http.post<ApiResponse<RiskItem>>("/v1/risk-items", payload);
}
export function fetchPendingRiskItems(params?: {
sourceType?: string;
status?: string;
}) {
return http.get<ApiResponse<RiskItem[]>>("/v1/risk-items/pending", {
params: {
source_type: params?.sourceType,
status: params?.status,
},
});
}
export function updateRiskItem(itemId: number, payload: RiskItemUpdate) {
return http.patch<ApiResponse<RiskItem>>(`/v1/risk-items/${itemId}`, payload);
}
export function reviewRiskItem(itemId: number, payload: ReviewAction) {
return http.post<ApiResponse<ReviewRecord>>(
`/v1/risk-items/${itemId}/review`,
payload,
);
}

View File

@ -0,0 +1,18 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
ModelInfo,
RiskAssessmentRequest,
RiskAssessmentResult,
} from "@/types/risk";
export function fetchModelInfo() {
return http.get<ApiResponse<ModelInfo>>("/v1/risk-predictions/model-info");
}
export function previewRisk(payload: RiskAssessmentRequest) {
return http.post<ApiResponse<RiskAssessmentResult>>(
"/v1/risk-predictions/preview",
payload,
);
}

35
src/api/standards.ts Normal file
View File

@ -0,0 +1,35 @@
import { http } from "@/api/http";
import type { ApiResponse } from "@/types/api";
import type {
StandardRuleCreate,
StandardRuleDetail,
StandardRuleListData,
StandardRuleUpdate,
} from "@/types/standards";
export function fetchStandards(params: {
keyword?: string;
category?: string;
status?: string;
page?: number;
pageSize?: number;
}) {
return http.get<ApiResponse<StandardRuleListData>>("/v1/standards", {
params,
});
}
export function fetchStandard(ruleId: number) {
return http.get<ApiResponse<StandardRuleDetail>>(`/v1/standards/${ruleId}`);
}
export function createStandard(payload: StandardRuleCreate) {
return http.post<ApiResponse<StandardRuleDetail>>("/v1/standards", payload);
}
export function updateStandard(ruleId: number, payload: StandardRuleUpdate) {
return http.patch<ApiResponse<StandardRuleDetail>>(
`/v1/standards/${ruleId}`,
payload,
);
}

View File

@ -0,0 +1,321 @@
<script setup lang="ts">
import {
ArrowUp,
Bot,
Box,
Building2,
Camera,
ClipboardCheck,
FileSearch,
Mic,
Search,
} from "@lucide/vue";
defineProps<{ modelValue: string; loading: boolean }>();
const emit = defineEmits<{
"update:modelValue": [value: string];
submit: [];
select: [value: string];
tool: [
value:
| "assessment"
| "supplier"
| "material"
| "cases"
| "solution"
| "review"
| "ocr"
| "voice",
];
}>();
const functions: Array<{
title: string;
description: string;
icon: typeof Search;
tone: string;
prompt?: string;
tool:
"assessment" | "supplier" | "material" | "cases" | "solution" | "review";
}> = [
{
title: "评估新品",
description: "含品类+频次",
icon: Search,
tone: "blue",
prompt: "帮我评估一款新品的综合风险",
tool: "assessment",
},
{
title: "查供应商",
description: "深度画像+分层",
icon: Building2,
tone: "green",
prompt: "帮我查询供应商风险画像",
tool: "supplier",
},
{
title: "查物料",
description: "品类常见问题",
icon: Box,
tone: "amber",
prompt: "帮我查询物料常见风险",
tool: "material",
},
{
title: "搜案例",
description: "语义检索",
icon: FileSearch,
tone: "red",
prompt: "搜索相似质量异常案例",
tool: "cases",
},
{
title: "AI方案",
description: "推理生成",
icon: Bot,
tone: "purple",
prompt: "帮我生成一个新品风险预防方案",
tool: "solution",
},
{
title: "审核队列",
description: "查看待审核风险项",
icon: ClipboardCheck,
tone: "red",
tool: "review",
},
];
</script>
<template>
<div class="composer-wrap">
<form class="composer" @submit.prevent="emit('submit')">
<button
class="composer-icon"
type="button"
title="语音录入"
aria-label="语音录入"
@click="emit('tool', 'voice')"
>
<Mic :size="16" />
</button>
<button
class="composer-icon"
type="button"
title="拍照识别"
aria-label="拍照识别"
@click="emit('tool', 'ocr')"
>
<Camera :size="16" />
</button>
<textarea
:value="modelValue"
rows="1"
placeholder="输入产品参数或问题…"
aria-label="输入产品参数或问题"
maxlength="2000"
@input="
emit(
'update:modelValue',
($event.target as HTMLTextAreaElement).value,
)
"
@keydown.enter.exact.prevent="emit('submit')"
></textarea>
<button
class="send-button"
type="submit"
:disabled="loading || !modelValue.trim()"
title="发送"
aria-label="发送"
>
<ArrowUp :size="17" />
</button>
</form>
<div class="function-grid">
<button
v-for="item in functions"
:key="item.title"
class="function-card"
type="button"
@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
>
</button>
</div>
</div>
</template>
<style scoped>
.composer-wrap {
padding: 12px 24px 14px;
background: linear-gradient(
to bottom,
rgb(250 250 250 / 88%),
var(--color-bg) 18%
);
}
.composer {
display: flex;
max-width: var(--content-width);
min-height: 52px;
align-items: flex-end;
gap: 7px;
border: 1px solid var(--color-border);
border-radius: 20px;
margin: 0 auto;
padding: 8px 10px;
background: var(--color-surface);
box-shadow: var(--shadow-md);
}
.composer:focus-within {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-lg);
}
.composer textarea {
min-width: 0;
min-height: 32px;
max-height: 92px;
flex: 1;
resize: none;
border: 0;
padding: 6px 3px;
background: transparent;
color: var(--color-text);
font: inherit;
line-height: 1.45;
outline: 0;
}
.composer textarea::placeholder {
color: var(--color-text-faint);
}
.composer-icon,
.send-button {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 34px;
place-items: center;
border: 0;
border-radius: 50%;
}
.composer-icon {
background: var(--color-bg-warm);
color: var(--color-text-muted);
}
.composer-icon:hover {
background: var(--color-border);
color: var(--color-text);
}
.send-button {
background: var(--color-text);
color: white;
}
.send-button:disabled {
opacity: 0.35;
}
.function-grid {
display: grid;
max-width: var(--content-width);
margin: 8px auto 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.function-card {
display: flex;
min-width: 0;
min-height: 44px;
align-items: center;
gap: 6px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 9px;
padding: 7px 8px;
background: var(--color-surface);
color: var(--color-text-secondary);
text-align: left;
}
.function-card:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm);
}
.function-card > span:last-child {
display: grid;
min-width: 0;
}
.function-card strong,
.function-card small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.function-card strong {
color: var(--color-text);
font-size: 10px;
}
.function-card small {
color: var(--color-text-faint);
font-size: 8px;
}
.function-dot {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
}
.function-dot--blue {
background: var(--color-accent);
}
.function-dot--green {
background: var(--color-success);
}
.function-dot--amber {
background: var(--color-warning);
}
.function-dot--red {
background: var(--color-danger);
}
.function-dot--purple {
background: var(--color-purple);
}
@media (max-width: 760px) {
.composer-wrap {
padding-inline: 10px;
}
.function-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.composer-icon:nth-child(2) {
display: none;
}
}
</style>

View File

@ -0,0 +1,551 @@
<script setup lang="ts">
import {
AlertTriangle,
BarChart3,
BookOpenCheck,
Bot,
Building2,
CheckCircle2,
ClipboardCheck,
PackageSearch,
Sparkles,
} from "@lucide/vue";
import { computed, ref } from "vue";
import type { AssistantResponseCard } from "@/types/conversation";
const props = defineProps<{ card: AssistantResponseCard }>();
const feedback = ref("");
interface CardData extends AssistantResponseCard {
name?: string;
partyType?: string;
material?: string;
anomalyCount?: number;
cooperationCount?: number | null;
frequencyCoefficient?: number;
tier?: string;
topProblems?: Array<{ name: string; count: number }>;
frequentProblems?: Array<{ name: string; count: number }>;
suggestions?: string[];
note?: string;
detectedValues?: Array<{ name: string; value: string }>;
matchedCount?: number;
standards?: Array<{
category: string;
riskPoint: string;
rule: string;
source?: string | null;
status: string;
}>;
confidence?: string;
analysisBase?: string;
risks?: Array<{ level: string; title: string; detail: string }>;
warning?: string;
}
const data = computed(() => props.card as CardData);
function notify(label: string) {
feedback.value = `${label}功能将在对应模块接入后执行`;
window.setTimeout(() => (feedback.value = ""), 2400);
}
function formatCount(value: unknown) {
return typeof value === "number" ? value.toLocaleString("zh-CN") : "暂无数据";
}
</script>
<template>
<article class="response-card" :class="`response-card--${card.type}`">
<header class="response-card__header">
<span class="response-card__icon">
<Building2 v-if="card.type === 'supplier'" :size="16" />
<PackageSearch v-else-if="card.type === 'material'" :size="16" />
<BookOpenCheck v-else-if="card.type === 'standard'" :size="16" />
<ClipboardCheck v-else-if="card.type === 'compliance'" :size="16" />
<Bot v-else :size="16" />
</span>
<div>
<h3>{{ card.title }}</h3>
<small v-if="card.type === 'supplier'"
>{{ data.name }} · {{ data.partyType }}</small
>
<small v-else-if="card.type === 'material'"
>{{ data.material }} · 历史异常分析</small
>
<small v-else-if="card.type === 'standard'"
>{{ data.category }} · 规则引擎</small
>
<small v-else-if="card.type === 'solution'"
>AI 推理生成 · 置信度:{{ data.confidence }}</small
>
<small v-else>标准库规则匹配 · 待品控确认</small>
</div>
</header>
<template v-if="card.type === 'supplier'">
<div class="metric-grid">
<div>
<b>{{ formatCount(data.anomalyCount) }}</b
><span>历史异常</span>
</div>
<div>
<b>{{ formatCount(data.cooperationCount) }}</b
><span>合作次数</span>
</div>
<div>
<b>×{{ data.frequencyCoefficient }}</b
><span>频次系数</span>
</div>
</div>
<div class="profile-line">
<span>供应商层级</span><strong>{{ data.tier }}</strong>
</div>
<section class="result-section">
<h4><BarChart3 :size="14" /> 高频异常</h4>
<div v-if="data.topProblems?.length" class="problem-list">
<div v-for="problem in data.topProblems" :key="problem.name">
<span>{{ problem.name }}</span
><b>{{ problem.count }} 次</b>
</div>
</div>
<p v-else class="empty">暂无匹配的异常类型</p>
</section>
<ul class="suggestion-list">
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
<p class="card-note">{{ data.note }}</p>
</template>
<template v-else-if="card.type === 'material'">
<div class="material-summary">
<b>{{ formatCount(data.anomalyCount) }}</b
><span>条历史异常涉及该物料</span>
</div>
<section class="result-section">
<h4><BarChart3 :size="14" /> 常见问题</h4>
<div v-if="data.frequentProblems?.length" class="problem-list">
<div v-for="problem in data.frequentProblems" :key="problem.name">
<span>{{ problem.name }}</span
><b>{{ problem.count }} 次</b>
</div>
</div>
<p v-else class="empty">暂无匹配历史异常</p>
</section>
<section v-if="data.standards?.length" class="result-section">
<h4><BookOpenCheck :size="14" /> 关联标准</h4>
<div
v-for="standard in data.standards"
:key="standard.riskPoint"
class="standard-line"
>
<span>{{ standard.category }}</span>
<p>
{{ standard.riskPoint }}<small>{{ standard.source }}</small>
</p>
</div>
</section>
<ul class="suggestion-list">
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
</template>
<template
v-else-if="card.type === 'standard' || card.type === 'compliance'"
>
<div
v-if="card.type === 'standard' && data.detectedValues?.length"
class="detected-values"
>
<span
v-for="item in data.detectedValues"
:key="`${item.name}-${item.value}`"
>{{ item.name }} <b>{{ item.value }}</b></span
>
</div>
<div class="standard-count">
<b>{{ data.matchedCount }}</b> 条规则已匹配
</div>
<section class="standard-results">
<template v-if="data.standards?.length">
<div
v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`"
class="standard-row"
>
<span>{{ standard.category }}</span>
<div>
<b>{{ standard.riskPoint }}</b
><small>{{ standard.rule }} · {{ standard.source }}</small>
</div>
<strong>{{ standard.status }}</strong>
</div>
</template>
<p v-else class="empty">暂无直接匹配规则</p>
</section>
<ul v-if="data.suggestions" class="suggestion-list">
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
<p v-if="data.note" class="card-note">{{ data.note }}</p>
</template>
<template v-else>
<div class="solution-base"><b>分析基础:</b>{{ data.analysisBase }}</div>
<section v-if="data.standards?.length" class="solution-section">
<h4><BookOpenCheck :size="14" /> 标准兜底校验</h4>
<div
v-for="standard in data.standards"
:key="`${standard.category}-${standard.riskPoint}`"
class="standard-row"
>
<span>{{ standard.category }}</span>
<div>
<b>{{ standard.riskPoint }}</b
><small>{{ standard.rule }} · {{ standard.source }}</small>
</div>
<strong>{{ standard.status }}</strong>
</div>
</section>
<section v-if="data.risks?.length" class="solution-section risk-list">
<div v-for="risk in data.risks" :key="risk.title" class="solution-risk">
<span>{{ risk.level }}</span>
<p>
<b>{{ risk.title }}:</b>{{ risk.detail }}
</p>
</div>
</section>
<section class="solution-suggest">
<h4><Sparkles :size="14" /> 建议方案(AI 生成,需品控确认)</h4>
<ol>
<li v-for="suggestion in data.suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ol>
</section>
<div class="solution-warning">
<AlertTriangle :size="14" />{{ data.warning }}
</div>
</template>
<div class="response-actions">
<button
type="button"
class="response-actions__primary"
@click="notify('生成报告')"
>
<CheckCircle2 :size="14" />生成报告
</button>
<button type="button" @click="notify('查看详情')">查看详情</button>
<button type="button" @click="notify('追问详情')">追问详情</button>
</div>
<p v-if="feedback" class="response-feedback">{{ feedback }}</p>
</article>
</template>
<style scoped>
.response-card {
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 18px;
background: var(--color-surface);
box-shadow: var(--shadow-md);
}
.response-card--supplier {
border-left: 3px solid var(--color-success);
}
.response-card--material {
border-left: 3px solid var(--color-warning);
}
.response-card--standard,
.response-card--compliance {
border-left: 3px solid var(--color-accent);
}
.response-card--solution {
border: 1px solid #bfdbfe;
border-left: 3px solid var(--color-purple);
}
.response-card__header {
display: flex;
align-items: center;
gap: 9px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 12px;
}
.response-card__icon {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 6px;
background: var(--color-bg-warm);
color: var(--color-accent);
}
.response-card--supplier .response-card__icon {
color: var(--color-success);
}
.response-card--material .response-card__icon {
color: var(--color-warning);
}
.response-card--solution .response-card__icon {
color: var(--color-purple);
}
.response-card__header h3 {
margin: 0;
font-size: 14px;
}
.response-card__header small {
color: var(--color-text-muted);
font-size: 10px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 7px;
margin: 14px 0 10px;
}
.metric-grid > div {
border-radius: 6px;
padding: 10px;
background: var(--color-bg-warm);
}
.metric-grid b,
.metric-grid span {
display: block;
}
.metric-grid b {
color: var(--color-text);
font-size: 17px;
}
.metric-grid span {
margin-top: 3px;
color: var(--color-text-faint);
font-size: 10px;
}
.profile-line,
.material-summary,
.standard-count {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 9px 10px;
background: var(--color-success-soft);
color: var(--color-text-secondary);
font-size: 11px;
}
.profile-line strong {
color: var(--color-success);
}
.material-summary {
justify-content: flex-start;
margin-top: 14px;
background: var(--color-warning-soft);
}
.material-summary b {
color: var(--color-warning);
font-size: 17px;
}
.result-section,
.solution-section {
margin-top: 14px;
}
.result-section h4,
.solution-section h4,
.solution-suggest h4 {
display: flex;
align-items: center;
gap: 5px;
margin: 0 0 7px;
color: var(--color-text);
font-size: 12px;
}
.problem-list > div,
.standard-line,
.standard-row {
display: flex;
align-items: flex-start;
gap: 8px;
border-top: 1px dashed var(--color-border-light);
padding: 8px 0;
font-size: 11px;
}
.problem-list > div {
justify-content: space-between;
}
.problem-list b {
color: var(--color-danger);
}
.empty {
margin: 7px 0;
color: var(--color-text-faint);
font-size: 11px;
}
.standard-line > span,
.standard-row > span {
flex: 0 0 auto;
border-radius: 999px;
padding: 2px 6px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 9px;
}
.standard-line p,
.standard-row div {
min-width: 0;
flex: 1;
margin: 0;
color: var(--color-text-secondary);
}
.standard-line small,
.standard-row small {
display: block;
margin-top: 3px;
color: var(--color-text-faint);
font-size: 9px;
}
.standard-row > strong {
flex: 0 0 auto;
color: var(--color-warning);
font-size: 10px;
}
.suggestion-list {
margin: 12px 0 0;
padding-left: 18px;
color: var(--color-text-secondary);
}
.suggestion-list li {
margin: 3px 0;
font-size: 11px;
line-height: 1.55;
}
.card-note {
margin: 10px 0 0;
color: var(--color-text-faint);
font-size: 10px;
line-height: 1.55;
}
.detected-values {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 14px 0 10px;
}
.detected-values span {
border: 1px solid var(--color-accent-soft);
border-radius: 5px;
padding: 5px 8px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 11px;
}
.standard-count {
justify-content: flex-start;
margin-top: 14px;
background: var(--color-accent-subtle);
}
.standard-count b {
color: var(--color-accent);
font-size: 17px;
}
.standard-results {
margin-top: 5px;
}
.solution-base {
margin-top: 14px;
border-radius: 6px;
padding: 10px;
background: #f3f7fb;
color: var(--color-text-secondary);
font-size: 11px;
line-height: 1.6;
}
.solution-suggest {
margin-top: 14px;
border-radius: 6px;
padding: 11px;
background: #eff6ff;
}
.solution-suggest h4 {
color: var(--color-accent);
}
.solution-suggest ol {
margin: 0;
padding-left: 18px;
color: var(--color-text-secondary);
}
.solution-suggest li {
margin: 4px 0;
font-size: 11px;
line-height: 1.55;
}
.solution-risk {
display: flex;
gap: 7px;
border-top: 1px solid var(--color-border-light);
padding: 8px 0;
}
.solution-risk > span {
flex: 0 0 auto;
border-radius: 4px;
padding: 3px 5px;
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: 9px;
}
.solution-risk p {
margin: 0;
color: var(--color-text-secondary);
font-size: 11px;
line-height: 1.5;
}
.solution-warning {
display: flex;
gap: 6px;
margin-top: 10px;
border-radius: 5px;
padding: 8px 10px;
background: var(--color-warning-soft);
color: #92400e;
font-size: 10px;
line-height: 1.5;
}
.response-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
border-top: 1px solid var(--color-border-light);
padding-top: 12px;
}
.response-actions button {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 30px;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0 10px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 10px;
}
.response-actions__primary {
border-color: var(--color-text) !important;
background: var(--color-text) !important;
color: white !important;
}
.response-feedback {
margin: 8px 0 0;
color: var(--color-accent);
font-size: 10px;
}
@media (max-width: 600px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@ -0,0 +1,246 @@
<script setup lang="ts">
import {
BookOpenCheck,
Bot,
Box,
Building2,
Camera,
Mic,
Scale,
Search,
} from "@lucide/vue";
const emit = defineEmits<{ select: [prompt: string] }>();
const actions = [
{
title: "评估新品风险",
description: "含品类预警 + 标准判定",
prompt: "帮我评估一款500ml PET瓶洗发水,泵头用祥印的,加工厂博元",
icon: Search,
tone: "blue",
},
{
title: "查供应商",
description: "含合作频次 + 分层",
prompt: "佳亿的风险怎么样?",
icon: Building2,
tone: "green",
},
{
title: "查物料风险",
description: "品类常见问题",
prompt: "软管类物料有什么常见问题?",
icon: Box,
tone: "amber",
},
{
title: "AI方案生成",
description: "无历史时按标准兜底",
prompt: "我要做一款次氯酸消毒喷雾,50ml,铝瓶加喷头",
icon: Bot,
tone: "purple",
isNew: true,
},
{
title: "标准判定",
description: "检测数值对标标准库",
prompt: "洗发水 pH 值检测报告 4.8,请判定",
icon: BookOpenCheck,
tone: "sky",
isNew: true,
},
{
title: "法规合规",
description: "法规标准快速核验",
prompt: "新产品宣称能祛痘,帮我查下合规风险",
icon: Scale,
tone: "rose",
isNew: true,
},
{
title: "OCR识别录入",
description: "拍照自动填表 + 数值判定",
prompt: "请帮我识别检测报告并进行风险判定",
icon: Camera,
tone: "sky",
},
{
title: "语音录入异常",
description: "口述信息自动结构化",
prompt: "记录一条新的质量异常",
icon: Mic,
tone: "purple",
},
];
</script>
<template>
<section class="welcome">
<div class="welcome__mark">蝶</div>
<h1>你好,我是风险预估助手 v2.3</h1>
<p>
双引擎风险预估:历史异常经验引擎 +
判定标准规则引擎,帮助你在立项、开发和量产阶段提前识别风险。
</p>
<div class="welcome-grid">
<button
v-for="action in actions"
:key="action.title"
class="welcome-action"
type="button"
@click="emit('select', action.prompt)"
>
<span class="welcome-action__icon" :class="`tone-${action.tone}`">
<component :is="action.icon" :size="16" />
</span>
<span class="welcome-action__copy">
<strong>{{ action.title }}</strong>
<small>{{ action.description }}</small>
</span>
<span v-if="action.isNew" class="welcome-action__new">新</span>
</button>
</div>
</section>
</template>
<style scoped>
.welcome {
padding: 42px 20px 26px;
text-align: center;
}
.welcome__mark {
display: inline-grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 14px;
margin-bottom: 18px;
background: var(--color-text);
color: white;
font-size: 20px;
font-weight: 650;
}
h1 {
margin: 0 0 6px;
font-size: 20px;
font-weight: 650;
}
p {
max-width: 440px;
margin: 0 auto 26px;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.75;
}
.welcome-grid {
display: grid;
max-width: 520px;
margin: 0 auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.welcome-action {
display: flex;
min-width: 0;
min-height: 62px;
align-items: center;
gap: 10px;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px 13px;
background: var(--color-surface);
color: var(--color-text);
text-align: left;
transition:
border-color 0.15s,
box-shadow 0.15s,
transform 0.15s;
}
.welcome-action:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.welcome-action__icon {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
border-radius: 6px;
}
.tone-blue,
.tone-sky {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.tone-green {
background: var(--color-success-soft);
color: var(--color-success);
}
.tone-amber {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.tone-purple {
background: var(--color-purple-soft);
color: var(--color-purple);
}
.tone-rose {
background: #fce7f3;
color: #be185d;
}
.welcome-action__copy {
display: grid;
min-width: 0;
gap: 1px;
}
.welcome-action strong {
font-size: 13px;
font-weight: 600;
}
.welcome-action small {
overflow: hidden;
color: var(--color-text-faint);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.welcome-action__new {
margin-left: auto;
border-radius: 3px;
padding: 0 4px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 8px;
font-weight: 700;
}
@media (max-width: 640px) {
.welcome {
padding-inline: 10px;
}
.welcome-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,725 @@
<script setup lang="ts">
import {
AlertTriangle,
BookOpenCheck,
Copy,
ChevronDown,
ChevronUp,
Factory,
FileDown,
History,
Sparkles,
} from "@lucide/vue";
import { computed, ref } from "vue";
import type { RiskAssessmentResult } from "@/types/risk";
import { formatRiskLevel } from "@/utils/risk";
const props = defineProps<{
result: RiskAssessmentResult;
canMonitor?: boolean;
}>();
const expanded = ref(true);
const feedback = ref("");
function notify(label: string) {
feedback.value = `${label}功能将在对应模块接入后执行`;
window.setTimeout(() => (feedback.value = ""), 2600);
}
function copyScore() {
void navigator.clipboard?.writeText(
`综合安全分:${props.result.score.toFixed(1)},${formatRiskLevel(props.result.level)}`,
);
notify("评分已复制");
}
const dimensions = computed(() =>
(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,
}))
).map((item) => ({
name: item.dimension,
score: Math.round(item.score),
weight: `${item.weight}%`,
tone: item.score < 40 ? "danger" : item.score < 60 ? "warning" : "success",
})),
);
const suggestions = computed(() =>
props.result.aiSuggestions?.length
? props.result.aiSuggestions
: ["高风险贡献指标优先安排专项验证,并明确责任人与完成节点。"],
);
const stars = computed(() => {
const filled = Math.max(1, Math.min(5, Math.round(props.result.score / 20)));
return "★".repeat(filled) + "☆".repeat(5 - filled);
});
</script>
<template>
<article class="risk-card" :class="{ 'risk-card--collapsed': !expanded }">
<header class="risk-summary">
<span
class="risk-summary__signal"
:class="`signal-${result.level}`"
></span>
<div>
<strong :class="`risk-summary__title--${result.level}`">
{{ stars }}
{{ formatRiskLevel(result.level) }}
</strong>
<small
>综合评分 <b>{{ Math.round(result.score) }}</b> / 100</small
>
</div>
<span class="risk-action-badge">{{ result.riskBadge || "需关注" }}</span>
<button
class="collapse-button"
type="button"
@click="expanded = !expanded"
>
<ChevronUp v-if="expanded" :size="13" />
<ChevronDown v-else :size="13" />
{{ expanded ? "收起" : "展开" }}
</button>
<button
class="collapse-button"
type="button"
title="复制评分"
aria-label="复制评分"
@click="copyScore"
>
<Copy :size="13" />
</button>
</header>
<template v-if="expanded">
<section class="alert-strip">
<AlertTriangle :size="16" />
<p>
{{
result.alertText ||
"当前暂无足够历史数据,建议按标准库完成基础验证。"
}}
</p>
</section>
<section class="category-warning">
<h3><History :size="14" /> 品类维度风险预警</h3>
<div v-if="result.categoryWarning">
<span>{{ result.categoryWarning.category }}</span>
历史异常 {{ result.categoryWarning.anomalyCount }} 条,
{{ result.categoryWarning.riskLevel }},案例用于当前风险参考
</div>
<div v-if="result.categoryWarning?.suggestion">
<span>建议</span>{{ result.categoryWarning.suggestion }}
</div>
</section>
<section class="supplier-analysis">
<h3><Factory :size="14" /> 供应商合作频次分析</h3>
<div v-if="result.supplierFreq?.supplierName">
<span>{{ result.supplierFreq.supplierName }}</span>
<strong>异常 {{ result.supplierFreq.anomalyCount ?? 0 }} 次</strong>
</div>
<div v-if="result.supplierFreq">
<span>频次风险系数</span
><strong>×{{ result.supplierFreq.alpha.toFixed(2) }}</strong>
</div>
<div v-if="result.supplierFreq?.tier">
<span>供应商层级</span><strong>{{ result.supplierFreq.tier }}</strong>
</div>
<div>
<span>合作次数</span
><strong>{{
result.supplierFreq?.cooperationCount ?? "暂无数据"
}}</strong>
</div>
<div v-if="result.supplierFreq?.suggestion">
<span>建议</span>{{ result.supplierFreq.suggestion }}
</div>
</section>
<section class="standards-card">
<h3><BookOpenCheck :size="14" /> 标准判定 · 双引擎</h3>
<p
v-if="!result.standardJudgment?.length"
class="standards-card__empty"
>
暂无相关标准
</p>
<div
v-for="standard in result.standardJudgment"
:key="`${standard.category}-${standard.riskPoint}`"
class="standard-row"
>
<span
class="standard-tag"
:class="{ 'standard-tag--pack': standard.category === '包材' }"
>{{ standard.category }}</span
>
<div>
{{ standard.riskPoint
}}<small
>{{ standard.rule }} ·
{{ standard.source || "标准文件.xlsx" }}</small
>
</div>
<strong :class="{ 'status-action': standard.status !== '待检测' }">{{
standard.status
}}</strong>
</div>
<footer>
双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。
</footer>
</section>
<section class="analysis-section">
<h3>七维风险分析</h3>
<div class="dimension-grid">
<div
v-for="item in dimensions"
:key="item.name"
class="dimension-cell"
>
<span>{{ item.name }}</span>
<div class="dimension-meter">
<i
:class="`tone-${item.tone}`"
:style="{ width: `${item.score}%` }"
></i>
</div>
<small
><b>{{ item.score }}</b
>分 · {{ item.weight }}</small
>
</div>
</div>
</section>
<section class="analysis-section">
<h3>
{{
result.similarCases?.length
? `匹配到 ${result.similarCases.length} 条相似案例`
: "相似案例"
}}
</h3>
<div v-if="!result.similarCases?.length" class="case-empty">
暂无相关案例,RAG 相似案例检索尚未接入。
</div>
<button
v-for="(item, index) in result.similarCases"
:key="`${item.date}-${index}`"
class="case-row"
type="button"
>
<span>TOP {{ index + 1 }}</span>
<small
>{{ item.date }} · {{ item.product }} · {{ item.problem }} →
{{ item.solution }}</small
>
</button>
</section>
<section class="tips-card">
<h3><Sparkles :size="14" /> AI 预防建议</h3>
<ol>
<li v-for="suggestion in suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ol>
</section>
<div class="risk-actions">
<button
class="primary-action"
type="button"
@click="notify('生成报告')"
>
<FileDown :size="14" /> 生成报告
</button>
<button type="button" @click="notify('案例详情')">案例详情</button>
<button type="button" @click="notify('供应商对比')">供应商对比</button>
<button v-if="canMonitor" type="button" @click="notify('加入监控')">
加入监控
</button>
</div>
</template>
<p v-if="feedback" class="risk-feedback">{{ feedback }}</p>
</article>
</template>
<style scoped>
.risk-card {
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 20px;
background: var(--color-surface);
box-shadow: var(--shadow-md);
}
.risk-summary {
display: flex;
align-items: center;
gap: 11px;
border-bottom: 1px solid var(--color-border-light);
margin-bottom: 14px;
padding-bottom: 14px;
}
.risk-summary__signal {
width: 13px;
height: 13px;
border-radius: 50%;
box-shadow: 0 0 0 5px var(--color-warning-soft);
}
.signal-low {
background: var(--color-success);
box-shadow: 0 0 0 5px var(--color-success-soft);
}
.signal-moderately_low {
background: var(--color-accent);
box-shadow: 0 0 0 5px var(--color-accent-subtle);
}
.signal-medium {
background: var(--color-warning);
}
.signal-high {
background: var(--color-danger);
box-shadow: 0 0 0 5px var(--color-danger-soft);
}
.signal-very_high {
background: #991b1b;
box-shadow: 0 0 0 5px #fee2e2;
}
.risk-summary > div {
display: grid;
flex: 1;
gap: 1px;
}
.risk-summary strong {
font-size: 15px;
}
.risk-summary__title--low,
.risk-summary__title--moderately_low,
.risk-summary__title--medium,
.risk-summary__title--high,
.risk-summary__title--very_high {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.risk-action-badge {
flex: 0 0 auto;
border: 1px solid var(--color-warning-border);
border-radius: 6px;
padding: 4px 9px;
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: 11px;
font-weight: 650;
line-height: 1.2;
}
.collapse-button {
display: inline-flex;
min-height: 30px;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid var(--color-border-strong);
border-radius: 6px;
padding: 0 9px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 550;
cursor: pointer;
}
.collapse-button:hover {
border-color: var(--color-text-muted);
background: var(--color-surface);
color: var(--color-text);
}
.collapse-button svg {
color: var(--color-text-muted);
}
.collapse-button[title="复制评分"] {
width: 30px;
padding: 0;
background: var(--color-surface);
}
.collapse-button[title="复制评分"]:hover {
border-color: var(--color-accent-soft);
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.collapse-button[title="复制评分"]:hover svg {
color: var(--color-accent);
}
.risk-summary__title--low,
.risk-summary__title--moderately_low {
color: var(--color-success);
}
.risk-summary__title--medium {
color: var(--color-warning);
}
.risk-summary__title--high,
.risk-summary__title--very_high {
color: var(--color-danger);
}
.risk-summary small {
color: var(--color-text-muted);
font-size: 11px;
}
.risk-summary small b {
color: var(--color-warning);
font-size: 14px;
}
.alert-strip,
.category-warning,
.supplier-analysis,
.standards-card,
.tips-card {
border-radius: 10px;
margin-bottom: 14px;
padding: 12px 13px;
}
.alert-strip {
display: flex;
align-items: flex-start;
gap: 9px;
border: 1px solid var(--color-danger-border);
background: var(--color-danger-soft);
color: #991b1b;
}
.alert-strip svg {
flex: 0 0 auto;
margin-top: 2px;
}
.alert-strip p {
margin: 0;
font-size: 12px;
line-height: 1.65;
}
.category-warning {
border: 1px solid var(--color-warning-border);
background: var(--color-warning-soft);
color: #92400e;
}
.supplier-analysis {
border: 1px solid #ddd6fe;
background: var(--color-purple-soft);
color: #5b21b6;
}
h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 7px;
color: inherit;
font-size: 12px;
font-weight: 650;
}
.category-warning > div,
.supplier-analysis > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 3px 0;
font-size: 12px;
}
.category-warning > div {
justify-content: flex-start;
}
.category-warning > div span {
flex: 0 0 auto;
border-radius: 999px;
padding: 1px 7px;
background: var(--color-warning);
color: white;
font-size: 9px;
font-weight: 650;
}
.supplier-analysis strong {
overflow-wrap: anywhere;
color: #4c1d95;
text-align: right;
}
.standards-card {
border: 1px solid #bfdbfe;
}
.standards-card h3 {
color: var(--color-accent-hover);
}
.standards-card__intro {
margin: 0 0 8px;
color: var(--color-text-faint);
font-size: 10px;
}
.standard-row {
display: flex;
align-items: flex-start;
gap: 8px;
border-top: 1px dashed var(--color-border-light);
padding: 7px 0;
color: var(--color-text-secondary);
font-size: 12px;
}
.standard-tag {
flex: 0 0 auto;
border: 1px solid var(--color-accent-soft);
border-radius: 999px;
padding: 1px 7px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 9px;
font-weight: 650;
}
.standard-tag--pack {
border-color: var(--color-success-border);
background: var(--color-success-soft);
color: var(--color-success);
}
.standard-row > div {
min-width: 0;
flex: 1;
}
.standard-row small {
display: block;
color: var(--color-text-faint);
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,
.case-empty {
margin: 8px 0;
color: var(--color-text-faint);
font-size: 11px;
}
.standards-card footer {
border-radius: 6px;
margin-top: 7px;
padding: 7px 9px;
background: var(--color-bg-warm);
color: var(--color-text-muted);
font-size: 10px;
}
.analysis-section {
margin-bottom: 14px;
}
.analysis-section > h3 {
color: var(--color-text);
font-size: 13px;
}
.dimension-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.dimension-cell {
display: grid;
gap: 5px;
border-radius: 9px;
padding: 10px;
background: var(--color-bg-warm);
text-align: center;
}
.dimension-cell > span {
color: var(--color-text-muted);
font-size: 9px;
font-weight: 550;
}
.dimension-cell small {
color: var(--color-text-faint);
font-size: 9px;
}
.dimension-cell small b {
color: var(--color-text-secondary);
font-size: 11px;
}
.dimension-meter {
height: 5px;
overflow: hidden;
border-radius: 3px;
background: var(--color-border);
}
.dimension-meter i {
display: block;
height: 100%;
border-radius: inherit;
}
.tone-warning {
background: var(--color-warning);
}
.tone-danger {
background: var(--color-danger);
}
.tone-success {
background: var(--color-success);
}
.case-row {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 8px;
border: 0;
border-radius: 6px;
margin-bottom: 4px;
padding: 7px 9px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
text-align: left;
}
.case-row span {
min-width: 0;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-row small {
color: var(--color-text-faint);
font-size: 9px;
}
.case-row strong {
color: var(--color-accent);
font-size: 11px;
}
.tips-card {
border: 1px solid var(--color-success-border);
background: var(--color-success-soft);
color: #166534;
}
.tips-card ol {
margin: 0;
padding-left: 18px;
}
.tips-card li {
margin: 2px 0;
font-size: 12px;
line-height: 1.6;
}
.risk-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
border-top: 1px solid var(--color-border-light);
padding-top: 12px;
}
.risk-actions button {
display: inline-flex;
min-height: 32px;
align-items: center;
gap: 5px;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0 12px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 550;
}
.risk-actions .primary-action {
border-color: var(--color-text);
background: var(--color-text);
color: white;
}
.risk-feedback {
margin: 8px 0 0;
color: var(--color-accent);
font-size: 11px;
}
@media (max-width: 600px) {
.risk-card {
padding: 14px;
}
.dimension-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.case-row {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.case-row small {
display: none;
}
}
</style>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import type { RiskLevel } from "@/types/risk";
import { formatRiskLevel } from "@/utils/risk";
defineProps<{ level: RiskLevel }>();
</script>
<template>
<span class="risk-badge" :class="`risk-badge--${level}`">{{
formatRiskLevel(level)
}}</span>
</template>
<style scoped>
.risk-badge {
display: inline-flex;
min-height: 26px;
align-items: center;
border-radius: 999px;
padding: 0 10px;
font-size: 12px;
font-weight: 700;
}
.risk-badge--low {
background: var(--color-success-soft);
color: var(--color-success);
}
.risk-badge--moderately_low {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.risk-badge--medium {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.risk-badge--high {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.risk-badge--very_high {
background: #fee2e2;
color: #991b1b;
}
</style>

View File

@ -0,0 +1,255 @@
<script setup lang="ts">
import { Bell, ChevronDown } from "@lucide/vue";
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/stores/app.store";
import { useAuthStore } from "@/stores/auth.store";
const appStore = useAppStore();
const { isOnline } = storeToRefs(appStore);
const auth = useAuthStore();
const { user } = storeToRefs(auth);
const router = useRouter();
const route = useRoute();
const navigation = ["对话", "看板", "供应商", "标准库", "报告", "管理"];
const visibleNavigation = computed(() =>
navigation.filter(
(item) => item !== "管理" || auth.hasPermission("risk_item.review"),
),
);
</script>
<template>
<header class="app-header">
<RouterLink class="brand" :to="{ name: 'assessment' }">
<span class="brand__mark">蝶</span>
<span class="brand__name">风险预估助手</span>
<span class="brand__version">v2.3</span>
<span class="engine-badge">双引擎</span>
</RouterLink>
<nav class="top-navigation" aria-label="顶部导航">
<RouterLink
v-for="item in visibleNavigation"
:key="item"
:to="{
name:
item === '看板'
? 'overview'
: item === '标准库'
? 'standards'
: item === '管理'
? 'review'
: 'assessment',
}"
:class="{
active:
(item === '看板' && route.name === 'overview') ||
(item === '标准库' && route.name === 'standards') ||
(item === '管理' && route.name === 'review') ||
(item === '对话' && route.name === 'assessment'),
}"
>
{{ item }}
</RouterLink>
</nav>
<div class="header-actions">
<span
class="service-dot"
:class="{ 'service-dot--online': isOnline }"
:title="isOnline ? '服务正常' : '服务离线'"
></span>
<button class="header-icon" type="button" title="通知" aria-label="通知">
<Bell :size="16" />
</button>
<button
class="user-pill"
type="button"
title="退出登录"
@click="
auth.logout();
router.push({ name: 'login' });
"
>
<span class="user-avatar">{{ user?.name?.slice(0, 1) ?? "访" }}</span>
<span>{{ user?.name ?? "访客" }}</span>
<ChevronDown :size="13" />
</button>
</div>
</header>
</template>
<style scoped>
.app-header {
z-index: 10;
display: flex;
height: var(--header-height);
flex: 0 0 var(--header-height);
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--color-border);
padding: 0 20px;
background: var(--color-surface);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text);
text-decoration: none;
white-space: nowrap;
}
.brand__mark {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 6px;
background: var(--color-text);
color: white;
font-size: 13px;
font-weight: 700;
}
.brand__name {
font-size: 14px;
font-weight: 650;
}
.brand__version {
color: var(--color-text-faint);
font-size: 10px;
}
.engine-badge {
border: 1px solid var(--color-accent-soft);
border-radius: 999px;
padding: 1px 6px;
background: var(--color-accent-subtle);
color: var(--color-accent);
font-size: 9px;
font-weight: 700;
}
nav {
display: flex;
min-width: 0;
flex: 1;
gap: 2px;
margin-left: 16px;
}
nav a {
border-radius: 6px;
padding: 6px 12px;
color: var(--color-text-muted);
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
nav a:hover,
nav a.active {
background: var(--color-bg-warm);
color: var(--color-text);
}
.header-actions,
.user-pill {
display: flex;
align-items: center;
}
.header-actions {
gap: 8px;
}
.service-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-danger);
}
.service-dot--online {
background: var(--color-success);
}
.header-icon {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--color-text-muted);
}
.header-icon:hover {
background: var(--color-bg-warm);
color: var(--color-text);
}
.user-pill {
min-height: 34px;
gap: 7px;
border: 0;
border-radius: 999px;
padding: 4px 10px 4px 4px;
background: var(--color-bg-warm);
color: var(--color-text-secondary);
font-size: 12px;
}
.user-avatar {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 50%;
background: var(--color-text);
color: white;
font-size: 11px;
font-weight: 600;
}
@media (max-width: 960px) {
nav a:nth-child(n + 4) {
display: none;
}
}
@media (max-width: 680px) {
.app-header {
padding-inline: 12px;
}
.brand__version,
.engine-badge,
.top-navigation,
.header-icon,
.user-pill > span:not(.user-avatar),
.user-pill svg {
display: none;
}
.header-actions {
margin-left: auto;
}
.brand__name {
font-size: 13px;
}
.user-pill {
padding: 4px;
}
}
</style>

View File

@ -0,0 +1,379 @@
<script setup lang="ts">
import {
BarChart3,
BookOpenCheck,
Building2,
ClipboardCheck,
FileText,
PenLine,
Search,
Smartphone,
} from "@lucide/vue";
import { storeToRefs } from "pinia";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth.store";
import { useConversationStore } from "@/stores/conversation.store";
const auth = useAuthStore();
const { user } = storeToRefs(auth);
const conversationStore = useConversationStore();
const { items: conversations, loading: conversationsLoading } =
storeToRefs(conversationStore);
const router = useRouter();
const search = ref("");
const shortcuts = [
{ label: "数据看板", icon: BarChart3, route: "overview" },
{
label: "录入风险项",
icon: ClipboardCheck,
route: "risk-item-entry",
permission: "risk_item.submit",
},
{ label: "供应商画像", icon: Building2, route: "assessment" },
{
label: "判定标准库",
icon: BookOpenCheck,
route: "standards",
isNew: true,
},
{ label: "最新报告", icon: FileText, route: "assessment" },
{ label: "钉钉移动端", icon: Smartphone, route: "assessment" },
];
function canReview() {
return auth.hasPermission("risk_item.review");
}
onMounted(() => conversationStore.loadList());
function startNewChat() {
conversationStore.reset();
router.push({ name: "assessment", query: { new: String(Date.now()) } });
}
async function openConversation(id: number) {
try {
await conversationStore.open(id);
await router.push({
name: "assessment",
query: { conversation: String(id) },
});
} catch {
// The store exposes the request error and the retry action remains available.
}
}
function searchConversations() {
void conversationStore.loadList(search.value.trim() || undefined);
}
</script>
<template>
<aside class="app-sidebar">
<div class="sidebar-top">
<button class="new-chat" type="button" @click="startNewChat">
<PenLine :size="15" />
<span>新建对话</span>
</button>
</div>
<section>
<h2>历史对话</h2>
<form class="conversation-search" @submit.prevent="searchConversations">
<Search :size="13" />
<input
v-model="search"
maxlength="120"
placeholder="搜索标题"
aria-label="搜索历史对话"
/>
</form>
<p v-if="conversationsLoading" class="sidebar-state">加载中…</p>
<p
v-else-if="conversationStore.error"
class="sidebar-state sidebar-state--error"
>
{{ conversationStore.error }}
<button type="button" @click="searchConversations">重试</button>
</p>
<p v-else-if="!conversations.length" class="sidebar-state">
暂无历史对话
</p>
<button
v-for="item in conversations"
:key="item.id"
class="sidebar-item"
:class="{
'sidebar-item--active': conversationStore.current?.id === item.id,
}"
type="button"
@click="openConversation(item.id)"
>
<span
v-if="item.lastRiskLevel"
class="risk-dot"
:class="`risk-dot--${item.lastRiskLevel}`"
></span>
<span v-else class="risk-dot risk-dot--empty"></span>
<span class="sidebar-item__label">{{ item.title }}</span>
</button>
</section>
<section>
<h2>快捷入口</h2>
<RouterLink
v-for="item in shortcuts.filter(
(item) => !item.permission || auth.hasPermission(item.permission),
)"
:key="item.label"
class="sidebar-item"
:to="{ name: item.route }"
>
<component :is="item.icon" :size="14" />
<span class="sidebar-item__label">{{ item.label }}</span>
<span v-if="item.isNew" class="new-label">新</span>
</RouterLink>
<RouterLink
v-if="canReview()"
class="sidebar-item"
:to="{ name: 'review' }"
>
<ClipboardCheck :size="14" />
<span class="sidebar-item__label">审核队列</span>
</RouterLink>
</section>
<footer>
<div class="knowledge-progress"><i></i></div>
<p>{{ user?.role ?? "未登录" }} · 知识库 2,448 / 3,000 条</p>
<p>标准库 219 条</p>
</footer>
</aside>
</template>
<style scoped>
.app-sidebar {
display: flex;
width: var(--sidebar-width);
min-height: 0;
flex: 0 0 var(--sidebar-width);
flex-direction: column;
overflow-y: auto;
border-right: 1px solid var(--color-border);
background: var(--color-surface);
}
.sidebar-top {
padding: 12px 14px;
}
.new-chat {
display: flex;
width: 100%;
align-items: center;
gap: 8px;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 10px 14px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
line-height: 1.6;
}
.new-chat:hover {
background: var(--color-bg-warm);
}
section h2 {
margin: 0;
padding: 14px 16px 4px;
color: var(--color-text-faint);
font-size: 10px;
font-weight: 650;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sidebar-item {
display: flex;
width: 100%;
min-height: 36px;
align-items: center;
gap: 8px;
border: 0;
border-left: 2px solid transparent;
padding: 0 16px 0 14px;
background: transparent;
color: var(--color-text-secondary);
font-size: 13px;
text-align: left;
text-decoration: none;
}
.sidebar-item:hover,
.sidebar-item--active {
background: var(--color-bg-warm);
color: var(--color-text);
}
.sidebar-item--active {
border-left-color: var(--color-text);
font-weight: 550;
}
.sidebar-item__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.risk-dot {
width: 5px;
height: 5px;
flex: 0 0 5px;
border-radius: 50%;
}
.risk-dot--high {
background: var(--color-danger);
}
.risk-dot--medium {
background: var(--color-warning);
}
.risk-dot--low {
background: var(--color-success);
}
.risk-dot--moderately_low {
background: var(--color-accent);
}
.risk-dot--very_high {
background: #991b1b;
}
.risk-dot--empty {
border: 1px solid var(--color-border-strong);
}
.sidebar-state {
margin: 8px 16px;
color: var(--color-text-faint);
font-size: 11px;
}
.sidebar-state--error button {
display: block;
border: 0;
padding: 0;
background: transparent;
color: var(--color-accent);
font: inherit;
cursor: pointer;
}
.conversation-search {
display: flex;
min-height: 30px;
align-items: center;
gap: 6px;
margin: 5px 14px 4px;
border: 1px solid var(--color-border);
border-radius: 5px;
padding: 0 8px;
color: var(--color-text-faint);
}
.conversation-search input {
width: 100%;
min-height: 28px;
border: 0;
padding: 0;
outline: 0;
background: transparent;
color: var(--color-text);
font: inherit;
font-size: 11px;
}
.new-label {
margin-left: auto;
color: var(--color-accent);
font-size: 8px;
font-weight: 700;
}
footer {
margin-top: auto;
border-top: 1px solid var(--color-border-light);
padding: 12px 16px;
}
.knowledge-progress {
height: 2px;
overflow: hidden;
border-radius: 1px;
margin-bottom: 7px;
background: var(--color-border-light);
}
.knowledge-progress i {
display: block;
width: 72%;
height: 100%;
background: var(--color-text-faint);
}
footer p {
margin: 1px 0;
color: var(--color-text-faint);
font-size: 9px;
text-align: center;
}
@media (max-width: 760px) {
.app-sidebar {
width: 58px;
flex-basis: 58px;
}
.sidebar-top {
padding-inline: 8px;
}
.new-chat {
justify-content: center;
padding: 0;
}
.new-chat span,
section h2,
.conversation-search,
.sidebar-item__label,
.new-label,
footer {
display: none;
}
.sidebar-item {
justify-content: center;
padding: 0;
}
.risk-dot {
width: 7px;
height: 7px;
flex-basis: 7px;
}
}
</style>

View File

@ -0,0 +1,227 @@
<script setup lang="ts">
import { Plus, ShieldCheck, Trash2 } from "@lucide/vue";
import { reactive } from "vue";
import type { RiskAssessmentRequest, RiskIndicator } from "@/types/risk";
defineProps<{ loading: boolean }>();
const emit = defineEmits<{
submit: [payload: RiskAssessmentRequest];
}>();
const form = reactive<RiskAssessmentRequest>({
subjectId: "customer-001",
indicators: [
{ name: "historicalDefault", value: 28, weight: 4 },
{ name: "debtBurden", value: 52, weight: 3 },
{ name: "cashflowVolatility", value: 36, weight: 3 },
],
});
function addIndicator() {
form.indicators.push({
name: `indicator${form.indicators.length + 1}`,
value: 0,
weight: 1,
});
}
function removeIndicator(index: number) {
if (form.indicators.length > 1) form.indicators.splice(index, 1);
}
function submit() {
const indicators: RiskIndicator[] = form.indicators.map((item) => ({
...item,
}));
emit("submit", { subjectId: form.subjectId, indicators });
}
</script>
<template>
<form class="assessment-form" @submit.prevent="submit">
<div class="field-group">
<label for="subject-id">评估对象</label>
<input
id="subject-id"
v-model.trim="form.subjectId"
required
maxlength="128"
/>
</div>
<div class="indicator-heading">
<div>
<strong>风险指标</strong>
<span>指标值范围 0-100</span>
</div>
<button
class="button button--secondary"
type="button"
@click="addIndicator"
>
<Plus :size="16" />
添加指标
</button>
</div>
<div class="indicator-table">
<div class="indicator-table__header" aria-hidden="true">
<span>指标名称</span>
<span>指标值</span>
<span>权重</span>
<span></span>
</div>
<div
v-for="(indicator, index) in form.indicators"
:key="index"
class="indicator-row"
>
<input
v-model.trim="indicator.name"
required
maxlength="64"
aria-label="指标名称"
/>
<input
v-model.number="indicator.value"
required
type="number"
min="0"
max="100"
step="0.1"
aria-label="指标值"
/>
<input
v-model.number="indicator.weight"
required
type="number"
min="0.1"
max="100"
step="0.1"
aria-label="权重"
/>
<button
class="icon-button"
type="button"
title="删除指标"
aria-label="删除指标"
:disabled="form.indicators.length === 1"
@click="removeIndicator(index)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
<button
class="button button--primary submit-button"
type="submit"
:disabled="loading"
>
<ShieldCheck :size="17" />
{{ loading ? "评估中" : "运行风险评估" }}
</button>
</form>
</template>
<style scoped>
.assessment-form {
display: grid;
gap: 22px;
}
.field-group,
.indicator-heading div {
display: grid;
gap: 7px;
}
label,
.indicator-heading strong {
color: var(--color-text);
font-size: 13px;
font-weight: 700;
}
.field-group input {
width: min(420px, 100%);
}
.indicator-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.indicator-heading span {
color: var(--color-text-muted);
font-size: 12px;
}
.indicator-table {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 6px;
}
.indicator-table__header,
.indicator-row {
display: grid;
grid-template-columns:
minmax(180px, 1.5fr) minmax(100px, 0.7fr) minmax(90px, 0.6fr)
42px;
gap: 10px;
align-items: center;
padding: 10px 12px;
}
.indicator-table__header {
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.indicator-row + .indicator-row {
border-top: 1px solid var(--color-border);
}
.indicator-row input {
width: 100%;
}
.submit-button {
justify-self: start;
}
@media (max-width: 620px) {
.indicator-heading {
align-items: stretch;
flex-direction: column;
}
.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>

View File

@ -0,0 +1,199 @@
<script setup lang="ts">
import { BarChart3 } from "@lucide/vue";
import RiskBadge from "@/components/common/RiskBadge.vue";
import type { RiskAssessmentResult } from "@/types/risk";
defineProps<{ result: RiskAssessmentResult | null; error: string | null }>();
</script>
<template>
<section class="result-panel" aria-live="polite">
<div class="result-panel__heading">
<BarChart3 :size="18" />
<h2>评估结果</h2>
</div>
<p v-if="error" class="result-error">{{ error }}</p>
<div v-else-if="result" class="result-content">
<div class="score-line">
<div>
<span>综合安全分</span>
<strong>{{ result.score.toFixed(1) }}</strong>
</div>
<RiskBadge :level="result.level" />
</div>
<dl class="result-meta">
<div>
<dt>对象</dt>
<dd>{{ result.subjectId }}</dd>
</div>
<div>
<dt>模型</dt>
<dd>{{ result.modelVersion }}</dd>
</div>
</dl>
<div class="factor-list">
<div
v-for="factor in result.factors"
:key="factor.name"
class="factor-row"
>
<span>{{ factor.name }}</span>
<div class="factor-bar">
<i :style="{ width: `${factor.value}%` }"></i>
</div>
<strong>{{ factor.contribution.toFixed(1) }}</strong>
</div>
</div>
</div>
<div v-else class="result-empty">
<span>--</span>
<p>暂无评估结果</p>
</div>
</section>
</template>
<style scoped>
.result-panel {
min-height: 420px;
border-left: 1px solid var(--color-border);
padding-left: 28px;
}
.result-panel__heading {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-brand);
}
h2 {
margin: 0;
color: var(--color-text);
font-size: 15px;
}
.result-content {
display: grid;
gap: 24px;
margin-top: 24px;
}
.score-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border-bottom: 1px solid var(--color-border);
padding-bottom: 22px;
}
.score-line div {
display: grid;
gap: 4px;
}
.score-line span,
dt {
color: var(--color-text-muted);
font-size: 12px;
}
.score-line strong {
font-size: 36px;
line-height: 1;
}
.result-meta {
display: grid;
margin: 0;
gap: 12px;
}
.result-meta div {
display: flex;
justify-content: space-between;
gap: 16px;
}
dd {
margin: 0;
overflow-wrap: anywhere;
font-weight: 600;
text-align: right;
}
.factor-list {
display: grid;
gap: 12px;
}
.factor-row {
display: grid;
grid-template-columns: minmax(100px, 1fr) minmax(80px, 1.3fr) 42px;
align-items: center;
gap: 10px;
font-size: 12px;
}
.factor-row > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.factor-row > strong {
text-align: right;
}
.factor-bar {
height: 6px;
overflow: hidden;
border-radius: 3px;
background: var(--color-border);
}
.factor-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--color-brand);
}
.result-empty {
display: grid;
min-height: 320px;
place-content: center;
color: var(--color-text-muted);
text-align: center;
}
.result-empty span {
color: var(--color-border-strong);
font-size: 46px;
}
.result-empty p,
.result-error {
margin: 8px 0 0;
}
.result-error {
border: 1px solid #f3c4c0;
border-radius: 6px;
padding: 12px;
background: var(--color-danger-soft);
color: var(--color-danger);
}
@media (max-width: 980px) {
.result-panel {
min-height: auto;
border-top: 1px solid var(--color-border);
border-left: 0;
padding-top: 24px;
padding-left: 0;
}
}
</style>

View File

@ -0,0 +1,22 @@
import { storeToRefs } from "pinia";
import { useRiskStore } from "@/stores/risk.store";
import type { RiskAssessmentRequest } from "@/types/risk";
export function useRiskPrediction() {
const store = useRiskStore();
const { modelInfo, result, loading, error } = storeToRefs(store);
async function run(payload: RiskAssessmentRequest) {
await store.predict(payload);
}
return {
modelInfo,
result,
loading,
error,
loadModelInfo: store.loadModelInfo,
run,
};
}

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { RouterView } from "vue-router";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import { useAppStore } from "@/stores/app.store";
const appStore = useAppStore();
onMounted(() => appStore.refreshHealth());
</script>
<template>
<div class="app-shell">
<AppHeader />
<div class="app-shell__body">
<AppSidebar />
<main class="app-content">
<RouterView />
</main>
</div>
</div>
</template>
<style scoped>
.app-shell {
display: flex;
height: 100vh;
flex-direction: column;
background: var(--color-bg);
overflow: hidden;
}
.app-shell__body {
display: flex;
min-height: 0;
flex: 1;
}
.app-content {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
overflow: hidden;
}
</style>

10
src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "@/App.vue";
import router from "@/router";
import "@/styles/variables.css";
import "@/styles/reset.css";
import "@/styles/global.css";
createApp(App).use(createPinia()).use(router).mount("#app");

366
src/pages/LoginPage.vue Normal file
View File

@ -0,0 +1,366 @@
<script setup lang="ts">
import {
Building2,
ChevronRight,
KeyRound,
ShieldCheck,
Smartphone,
} from "@lucide/vue";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth.store";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const mode = ref<"password" | "dingtalk">("password");
const useDevelopmentLogin = ref(true);
const username = ref("local-admin");
const password = ref("risk-admin-2026");
const redirectPath = computed(() => {
const redirect = route.query.redirect;
return typeof redirect === "string" && redirect.startsWith("/")
? redirect
: "/assessment";
});
async function submitPassword() {
if (!username.value.trim() || password.value.length < 8 || auth.loading)
return;
try {
await auth.authenticate(
username.value.trim(),
password.value,
useDevelopmentLogin.value,
);
await router.replace(redirectPath.value);
} catch {
// The store exposes the server message below the form.
}
}
async function submitDingTalk() {
if (auth.loading) return;
try {
await auth.dingTalkLogin();
await router.replace(redirectPath.value);
} catch {
// The store exposes the server or container message below the form.
}
}
</script>
<template>
<main class="login-page">
<section class="login-card">
<div class="login-card__brand">
<span class="login-card__mark">蝶</span>
<div>
<strong>风险预估助手</strong>
<small>Dieya Risk Prediction Console</small>
</div>
</div>
<div class="login-card__intro">
<span class="eyebrow">SECURE ACCESS</span>
<h1>登录系统</h1>
<p>使用企业账号或钉钉工作台进入风险预估平台。</p>
</div>
<div class="login-tabs" role="tablist" aria-label="登录方式">
<button
type="button"
:class="{ active: mode === 'password' }"
role="tab"
:aria-selected="mode === 'password'"
@click="mode = 'password'"
>
<KeyRound :size="15" />账号密码
</button>
<button
type="button"
:class="{ active: mode === 'dingtalk' }"
role="tab"
:aria-selected="mode === 'dingtalk'"
@click="mode = 'dingtalk'"
>
<Smartphone :size="15" />钉钉免登
</button>
</div>
<form
v-if="mode === 'password'"
class="login-form"
@submit.prevent="submitPassword"
>
<label for="username">工号 / 企业邮箱</label>
<input
id="username"
v-model.trim="username"
autocomplete="username"
placeholder="例如 admin001"
required
/>
<label for="password">密码</label>
<input
id="password"
v-model="password"
autocomplete="current-password"
type="password"
minlength="8"
required
/>
<label class="dev-toggle">
<input v-model="useDevelopmentLogin" type="checkbox" />
<span>本地开发联调模式</span>
</label>
<button class="login-submit" type="submit" :disabled="auth.loading">
{{
auth.loading
? "正在登录…"
: useDevelopmentLogin
? "开发登录"
: "登录"
}}
<ChevronRight :size="16" />
</button>
</form>
<div v-else class="dingtalk-panel">
<div class="dingtalk-panel__icon"><Smartphone :size="22" /></div>
<strong>从钉钉工作台快速进入</strong>
<p>在钉钉容器中点击授权,系统会读取组织身份并自动匹配角色权限。</p>
<button
class="login-submit"
type="button"
:disabled="auth.loading"
@click="submitDingTalk"
>
{{ auth.loading ? "正在验证…" : "钉钉免登" }}
<ChevronRight :size="16" />
</button>
</div>
<p v-if="auth.error" class="login-error" role="alert">{{ auth.error }}</p>
<footer class="login-card__footer">
<span><ShieldCheck :size="14" />JWT + RBAC 权限控制</span>
<span><Building2 :size="14" />企业内部账号</span>
</footer>
</section>
</main>
</template>
<style scoped>
.login-page {
display: grid;
min-height: 100vh;
place-items: center;
padding: 32px 18px;
background: var(--color-bg-warm);
}
.login-card {
width: min(440px, 100%);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 30px;
background: var(--color-surface);
box-shadow: var(--shadow-lg);
}
.login-card__brand,
.login-card__footer span,
.login-tabs button,
.login-submit {
display: flex;
align-items: center;
}
.login-card__brand {
gap: 10px;
margin-bottom: 38px;
}
.login-card__mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 8px;
background: var(--color-text);
color: white;
font-size: 17px;
font-weight: 700;
}
.login-card__brand div {
display: grid;
gap: 1px;
}
.login-card__brand strong {
font-size: 15px;
}
.login-card__brand small,
.login-card__intro p,
.dingtalk-panel p {
color: var(--color-text-muted);
font-size: 11px;
}
.login-card__intro h1 {
margin: 5px 0 3px;
font-size: 25px;
}
.login-card__intro p {
margin: 0;
}
.eyebrow {
margin: 0;
color: var(--color-accent);
font-size: 10px;
font-weight: 800;
}
.login-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
border-bottom: 1px solid var(--color-border);
margin-top: 28px;
}
.login-tabs button {
justify-content: center;
gap: 6px;
border: 0;
border-bottom: 2px solid transparent;
padding: 11px 8px;
background: transparent;
color: var(--color-text-muted);
font-size: 12px;
font-weight: 650;
}
.login-tabs button.active {
border-bottom-color: var(--color-text);
color: var(--color-text);
}
.login-form,
.dingtalk-panel {
display: grid;
gap: 9px;
margin-top: 24px;
}
.login-form label:not(.dev-toggle) {
margin-top: 5px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
.login-form input:not([type="checkbox"]) {
width: 100%;
}
.dev-toggle {
display: flex;
align-items: center;
gap: 7px;
margin-top: 5px;
color: var(--color-text-muted);
font-size: 11px;
}
.dev-toggle input {
accent-color: var(--color-accent);
}
.login-submit {
justify-content: center;
gap: 8px;
min-height: 42px;
border: 1px solid var(--color-text);
border-radius: 6px;
margin-top: 11px;
background: var(--color-text);
color: white;
font-size: 13px;
font-weight: 700;
}
.login-submit:hover:not(:disabled) {
background: #333;
}
.login-submit:disabled {
cursor: wait;
opacity: 0.55;
}
.dingtalk-panel {
justify-items: center;
padding: 24px 8px 4px;
text-align: center;
}
.dingtalk-panel__icon {
display: grid;
width: 52px;
height: 52px;
place-items: center;
border-radius: 14px;
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.dingtalk-panel p {
max-width: 280px;
margin: -1px 0 4px;
line-height: 1.7;
}
.login-error {
border: 1px solid var(--color-danger-border);
border-radius: 6px;
margin: 16px 0 0;
padding: 10px 12px;
background: var(--color-danger-soft);
color: var(--color-danger);
font-size: 11px;
}
.login-card__footer {
display: flex;
justify-content: space-between;
gap: 10px;
border-top: 1px solid var(--color-border-light);
margin-top: 24px;
padding-top: 16px;
}
.login-card__footer span {
gap: 5px;
color: var(--color-text-faint);
font-size: 10px;
}
@media (max-width: 460px) {
.login-card {
padding: 24px 20px;
}
.login-card__footer {
align-items: flex-start;
flex-direction: column;
}
}
</style>

1497
src/pages/OverviewPage.vue Normal file

File diff suppressed because it is too large Load Diff

1163
src/pages/ReviewPage.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { onMounted } from "vue";
import RiskAssessmentForm from "@/components/risk/RiskAssessmentForm.vue";
import RiskResultPanel from "@/components/risk/RiskResultPanel.vue";
import { useRiskPrediction } from "@/composables/useRiskPrediction";
import type { RiskAssessmentRequest } from "@/types/risk";
const { modelInfo, result, loading, error, loadModelInfo, run } =
useRiskPrediction();
onMounted(loadModelInfo);
function submit(payload: RiskAssessmentRequest) {
run(payload);
}
</script>
<template>
<div class="page">
<header class="page-header">
<div>
<p class="eyebrow">RISK ASSESSMENT</p>
<h1>风险评估</h1>
</div>
<span class="model-version">{{
modelInfo?.modelVersion ?? "模型待连接"
}}</span>
</header>
<section class="assessment-workspace">
<RiskAssessmentForm :loading="loading" @submit="submit" />
<RiskResultPanel :result="result" :error="error" />
</section>
</div>
</template>
<style scoped>
.model-version {
color: var(--color-text-muted);
font-size: 12px;
font-weight: 600;
}
.assessment-workspace {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.8fr);
gap: 28px;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 28px;
background: var(--color-surface);
}
@media (max-width: 980px) {
.assessment-workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.page-header {
align-items: start;
flex-direction: column;
gap: 8px;
}
.model-version {
max-width: 100%;
overflow-wrap: anywhere;
}
.assessment-workspace {
padding: 18px 14px;
}
}
</style>

View File

@ -0,0 +1,705 @@
<script setup lang="ts">
import axios from "axios";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import AssistantComposer from "@/components/assistant/AssistantComposer.vue";
import AssistantWelcome from "@/components/assistant/AssistantWelcome.vue";
import RiskAnalysisCard from "@/components/assistant/RiskAnalysisCard.vue";
import AssistantResponseCard from "@/components/assistant/AssistantResponseCard.vue";
import { useConversationStore } from "@/stores/conversation.store";
import { useAuthStore } from "@/stores/auth.store";
import { storeToRefs } from "pinia";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const conversationStore = useConversationStore();
const {
current: conversation,
sending: loading,
error,
} = storeToRefs(conversationStore);
const prompt = ref("");
const scrollArea = ref<HTMLElement | null>(null);
const tool = ref<
| "assessment"
| "supplier"
| "material"
| "cases"
| "solution"
| "ocr"
| "voice"
| null
>(null);
const toolInput = ref("");
const toolError = ref("");
const toolFile = ref<File | null>(null);
const assessmentFields = ref({
category: "",
specification: "",
packaging: "",
supplier: "",
factory: "",
stage: "产品开发",
});
const toolTitle = computed(() => {
const titles = {
assessment: "评估新品",
supplier: "查供应商",
material: "查物料",
cases: "搜索相似案例",
solution: "生成 AI 方案",
ocr: "OCR 识别录入",
voice: "语音录入异常",
} as const;
return tool.value ? titles[tool.value] : "";
});
const toolLabel = computed(() => {
if (tool.value === "ocr") return "检测报告图片或 PDF(当前先提交描述)";
if (tool.value === "voice") return "录音内容(当前先提交转写文本)";
if (tool.value === "assessment") return "产品品类、规格、包装和供应商";
if (tool.value === "supplier") return "供应商名称";
if (tool.value === "material") return "物料名称";
if (tool.value === "cases") return "案例关键词";
return "方案主题";
});
const loadingText = computed(() =>
loading.value ? "正在调用风险评分引擎…" : "",
);
const canMonitor = computed(
() => auth.user?.role === "admin" || auth.user?.role === "manager",
);
onMounted(async () => {
await conversationStore.loadList();
await openFromRoute();
});
watch(
() => route.query.conversation,
async () => {
await openFromRoute();
},
);
function usePrompt(value: string) {
prompt.value = value;
void submit();
}
function openTool(
value:
| "assessment"
| "supplier"
| "material"
| "cases"
| "solution"
| "review"
| "ocr"
| "voice",
) {
if (value === "review") {
router.push({ name: "review" });
return;
}
tool.value = value;
toolInput.value = "";
toolFile.value = null;
toolError.value = "";
assessmentFields.value = {
category: "",
specification: "",
packaging: "",
supplier: "",
factory: "",
stage: "产品开发",
};
}
function closeTool() {
tool.value = null;
toolError.value = "";
}
function submitTool() {
const selectedTool = tool.value;
const selectedTitle = toolTitle.value;
if (selectedTool === "assessment") {
const fields = assessmentFields.value;
if (!fields.category.trim() || !fields.packaging.trim()) {
toolError.value = "产品品类和包装材质为必填项";
return;
}
toolInput.value = [
`评估新品:${fields.category}`,
fields.specification && `规格:${fields.specification}`,
`包装材质:${fields.packaging}`,
fields.supplier && `供应商:${fields.supplier}`,
fields.factory && `加工厂:${fields.factory}`,
`开发阶段:${fields.stage}`,
]
.filter(Boolean)
.join(",");
}
const finalValue = toolInput.value.trim();
if (!finalValue && !toolFile.value) {
toolError.value = "请先填写必填内容";
return;
}
closeTool();
prompt.value =
finalValue || `${selectedTitle}:${toolFile.value?.name ?? "附件"}`;
const messageType = toolFile.value
? selectedTool === "ocr"
? "image"
: "audio"
: "text";
void submit(messageType);
}
function selectFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0] ?? null;
if (file && file.size > 10 * 1024 * 1024) {
toolFile.value = null;
toolError.value = "文件不能超过 10MB";
return;
}
toolError.value = "";
toolFile.value = file;
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function loadOlder() {
const node = scrollArea.value;
const before = node?.scrollHeight ?? 0;
void conversationStore.loadOlder().then((loaded) => {
if (loaded && node) node.scrollTop = node.scrollHeight - before;
});
}
function handleScroll() {
if (scrollArea.value && scrollArea.value.scrollTop < 60) loadOlder();
}
async function openFromRoute() {
const rawId = route.query.conversation;
if (typeof rawId !== "string") return;
const id = Number(rawId);
if (Number.isSafeInteger(id) && id > 0 && conversation.value?.id !== id) {
try {
await conversationStore.open(id);
await scrollToBottom("auto");
} catch (reason) {
if (axios.isAxiosError(reason) && reason.response?.status === 404) {
// A conversation can be deleted from another session while its URL remains open.
conversationStore.reset();
await router.replace({ name: "assessment", query: {} });
}
}
}
}
async function submit(
messageType: "text" | "image" | "file" | "audio" = "text",
) {
const value = prompt.value.trim();
if (!value || loading.value) return;
prompt.value = "";
await conversationStore.streamSend(
value,
(delta) => {
void delta;
},
messageType,
);
if (
conversation.value &&
route.query.conversation !== String(conversation.value.id)
) {
await router.replace({
name: "assessment",
query: { conversation: String(conversation.value.id) },
});
}
await scrollToBottom();
}
async function scrollToBottom(behavior: "smooth" | "auto" = "smooth") {
await nextTick();
scrollArea.value?.scrollTo({
top: scrollArea.value.scrollHeight,
behavior,
});
}
</script>
<template>
<div class="assistant-page">
<div
ref="scrollArea"
class="conversation-scroll"
@scroll.passive="handleScroll"
>
<div class="conversation-column">
<button
v-if="conversation?.hasMoreMessages"
class="load-older"
type="button"
@click="loadOlder"
>
{{ conversationStore.loadingOlder ? "加载中…" : "加载更早消息" }}
</button>
<AssistantWelcome
v-if="!conversation?.messages.length"
@select="usePrompt"
/>
<template
v-for="message in conversation?.messages ?? []"
:key="message.id"
>
<div class="message" :class="`message--${message.role}`">
<div class="message-avatar">
{{
message.role === "user"
? (auth.user?.name?.slice(0, 1) ?? "我")
: "蝶"
}}
</div>
<div v-if="message.role === 'user'" class="user-bubble">
{{ message.content }}
<time>{{ formatMessageTime(message.createdAt) }}</time>
</div>
<div v-else class="message-body">
<div
v-if="
message.semantic &&
!message.riskResult &&
!message.responseCard
"
class="semantic-strip"
>
已识别:{{ message.semantic.intent ?? "通用咨询" }}
<span v-if="message.semantic.category"
>· {{ message.semantic.category }}</span
>
<span v-if="message.semantic.packaging"
>· {{ message.semantic.packaging }}</span
>
</div>
<div
v-if="
message.content &&
!message.riskResult &&
!message.responseCard
"
class="assistant-bubble"
>
{{ message.content }}
</div>
<RiskAnalysisCard
v-if="message.riskResult"
:result="message.riskResult"
:can-monitor="canMonitor"
/>
<AssistantResponseCard
v-if="message.responseCard"
:card="message.responseCard"
/>
<time class="message-time">{{
formatMessageTime(message.createdAt)
}}</time>
</div>
</div>
</template>
<div v-if="loading" class="message message--assistant">
<div class="message-avatar">蝶</div>
<div class="message-body">
<p class="state-message">{{ loadingText }}</p>
</div>
</div>
<p v-if="error" class="state-message state-message--error">
{{ error }}
</p>
</div>
</div>
<AssistantComposer
v-model="prompt"
:loading="loading"
@submit="submit"
@select="usePrompt"
@tool="openTool"
/>
<div v-if="tool" class="tool-backdrop" @click.self="closeTool">
<form class="tool-dialog" @submit.prevent="submitTool">
<button
class="dialog-close"
type="button"
aria-label="关闭"
title="关闭"
@click="closeTool"
>
×
</button>
<p class="eyebrow">CHAT TOOL</p>
<h2>{{ toolTitle }}</h2>
<p class="dialog-description">
填写必要信息后,将生成一条带上下文的对话请求。
</p>
<div v-if="tool === 'assessment'" class="assessment-fields">
<label
>产品品类<input v-model="assessmentFields.category" autofocus
/></label>
<label
>规格容量<input
v-model="assessmentFields.specification"
placeholder="如 500ml / 100g"
/></label>
<label
>包装材质<input
v-model="assessmentFields.packaging"
placeholder="如 瓶子 / 软管 / 泵头"
/></label>
<label>供应商<input v-model="assessmentFields.supplier" /></label>
<label>加工厂<input v-model="assessmentFields.factory" /></label>
<label
>开发阶段<select v-model="assessmentFields.stage">
<option>需求发现</option>
<option>产品立项</option>
<option>产品开发</option>
<option>量产上市</option>
<option>产品生命周期</option>
</select></label
>
</div>
<label v-else>
{{ toolLabel }}
<input
v-if="tool !== 'ocr' && tool !== 'voice'"
v-model="toolInput"
:maxlength="tool === 'cases' ? 50 : 2000"
autofocus
/>
<input
v-else
type="file"
:accept="
tool === 'ocr'
? 'image/jpeg,image/png,application/pdf'
: 'audio/*'
"
@change="selectFile"
/>
</label>
<p v-if="toolError" class="dialog-error">{{ toolError }}</p>
<div class="dialog-actions">
<button type="button" class="dialog-secondary" @click="closeTool">
取消
</button>
<button type="submit" class="dialog-primary">带入对话</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.assistant-page {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
background: var(--color-bg);
}
.conversation-scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
}
.conversation-column {
display: flex;
width: min(var(--content-width), 100%);
margin: 0 auto;
padding: 0 24px 28px;
flex-direction: column;
gap: 18px;
}
.load-older {
align-self: center;
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 6px 12px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: 11px;
cursor: pointer;
}
.message {
display: flex;
gap: 10px;
}
.message--user {
flex-direction: row-reverse;
}
.message-avatar {
display: grid;
width: 30px;
height: 30px;
flex: 0 0 30px;
place-items: center;
border-radius: 6px;
background: var(--color-text);
color: white;
font-size: 11px;
font-weight: 650;
}
.message--user .message-avatar {
border: 1px solid var(--color-border);
background: var(--color-bg-warm);
color: var(--color-text-muted);
}
.user-bubble {
max-width: 82%;
border-radius: 14px 4px 14px 14px;
padding: 11px 15px;
background: var(--color-text);
color: white;
font-size: 13px;
line-height: 1.65;
}
.user-bubble time,
.message-time {
display: block;
margin-top: 4px;
color: var(--color-text-faint);
font-size: 10px;
}
.user-bubble time {
color: rgb(255 255 255 / 70%);
text-align: right;
}
.assistant-bubble {
width: fit-content;
max-width: 82%;
border: 1px solid var(--color-border);
border-radius: 4px 14px 14px 14px;
padding: 11px 15px;
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.65;
}
.semantic-strip {
margin-bottom: 8px;
color: var(--color-text-muted);
font-size: 11px;
}
.message-body {
width: min(100%, 680px);
min-width: 0;
}
.assistant-placeholder {
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 20px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.placeholder-heading {
display: flex;
align-items: center;
gap: 11px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 13px;
}
.placeholder-signal {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-border-strong);
box-shadow: 0 0 0 5px var(--color-bg-warm);
}
.placeholder-heading > div {
display: grid;
}
.placeholder-heading strong {
font-size: 14px;
}
.placeholder-heading small,
.assistant-placeholder p {
color: var(--color-text-faint);
font-size: 10px;
}
.assistant-placeholder p {
margin: 13px 0 0;
}
.state-message {
margin: 8px 0 0;
color: var(--color-text-muted);
font-size: 11px;
}
.state-message--error {
color: var(--color-danger);
}
.tool-backdrop {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgb(17 17 17 / 28%);
}
.tool-dialog {
position: relative;
width: min(560px, 100%);
max-height: 80vh;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 24px;
background: var(--color-surface);
box-shadow: var(--shadow-lg);
}
.dialog-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
border: 0;
background: transparent;
color: var(--color-text-muted);
font-size: 22px;
}
.tool-dialog h2 {
margin: 5px 0;
font-size: 20px;
}
.dialog-description {
margin: 0 0 20px;
color: var(--color-text-muted);
font-size: 12px;
}
.tool-dialog label {
display: grid;
gap: 7px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
.tool-dialog input {
width: 100%;
box-sizing: border-box;
}
.assessment-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.assessment-fields select {
min-height: 38px;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 0 10px;
background: white;
color: var(--color-text);
font: inherit;
}
.dialog-error {
margin: 8px 0 0;
color: var(--color-danger);
font-size: 12px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 22px;
}
.dialog-actions button {
min-height: 36px;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 0 14px;
font-size: 12px;
}
.dialog-secondary {
background: var(--color-surface);
color: var(--color-text-secondary);
}
.dialog-primary {
border-color: var(--color-text) !important;
background: var(--color-text);
color: white;
}
@media (max-width: 760px) {
.conversation-column {
padding-inline: 12px;
}
.message-avatar {
display: none;
}
.message-body,
.user-bubble {
width: 100%;
max-width: 100%;
}
.assessment-fields {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,284 @@
<script setup lang="ts">
import { ClipboardCheck, FileText, Send } from "@lucide/vue";
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { useReviewStore } from "@/stores/review.store";
import type { RiskItemCreate } from "@/types/riskItem";
const router = useRouter();
const reviewStore = useReviewStore();
const submitted = ref(false);
const form = reactive<RiskItemCreate>({
sourceType: "表单",
productName: "",
productCategory: "",
supplier: "",
factory: "",
stageNew: "第三阶段(产品开发)",
description: "",
defectLevel: "",
solution: "",
photos: [],
});
async function submit() {
if (!form.productName.trim() || form.description.trim().length < 5) return;
await reviewStore.submit({
...form,
productCategory: form.productCategory || null,
supplier: form.supplier || null,
factory: form.factory || null,
defectLevel: form.defectLevel || null,
solution: form.solution || null,
});
submitted.value = true;
form.productName = "";
form.description = "";
form.solution = "";
}
</script>
<template>
<div class="page entry-page">
<header class="page-header">
<div>
<p class="eyebrow">RISK ITEM INTAKE</p>
<h1>录入风险项</h1>
</div>
<button
class="button button--secondary"
type="button"
@click="router.push({ name: 'assessment' })"
>
返回对话
</button>
</header>
<section class="entry-layout">
<form class="entry-form" @submit.prevent="submit">
<div class="section-heading">
<ClipboardCheck :size="18" />
<div>
<h2>风险项信息</h2>
<p>提交后先自动预审,再进入审核队列。</p>
</div>
</div>
<div class="form-grid">
<label
>产品名称 *<input
v-model.trim="form.productName"
required
maxlength="200"
/></label>
<label
>产品品类<input v-model.trim="form.productCategory" maxlength="100"
/></label>
<label
>供应商<input v-model.trim="form.supplier" maxlength="300"
/></label>
<label
>加工厂<input v-model.trim="form.factory" maxlength="300"
/></label>
<label
>流程阶段<select v-model="form.stageNew">
<option>第一阶段(需求发现)</option>
<option>第二阶段(产品立项)</option>
<option>第三阶段(产品开发)</option>
<option>第四阶段(量产上市)</option>
<option>第五阶段(产品生命管理)</option>
</select></label
>
<label
>缺陷等级<select v-model="form.defectLevel">
<option value="">未确定</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select></label
>
</div>
<label
>异常描述 *<textarea
v-model.trim="form.description"
required
minlength="5"
maxlength="10000"
rows="5"
placeholder="描述异常现象、影响范围和发现方式"
></textarea>
</label>
<label
>解决方案<textarea
v-model.trim="form.solution"
maxlength="10000"
rows="4"
placeholder="填写已有处理方案或建议"
></textarea>
</label>
<p v-if="reviewStore.error" class="form-error" role="alert">
{{ reviewStore.error }}
</p>
<button
class="button button--primary submit-button"
type="submit"
:disabled="reviewStore.submitting"
>
<Send :size="16" />{{
reviewStore.submitting ? "提交中…" : "提交审核"
}}
</button>
</form>
<aside class="entry-aside">
<div class="aside-icon"><FileText :size="19" /></div>
<strong>审核流说明</strong>
<ol>
<li>系统校验必填字段和描述长度</li>
<li>通过预审后进入待审核队列</li>
<li>审核通过后写入异常案例库</li>
</ol>
<div v-if="submitted" class="success-note">
风险项已提交,等待审核员处理。
</div>
</aside>
</section>
</div>
</template>
<style scoped>
.entry-page {
overflow-y: auto;
}
.entry-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 26px;
}
.entry-form,
.entry-aside {
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface);
}
.entry-form {
display: grid;
gap: 16px;
padding: 24px;
}
.section-heading {
display: flex;
align-items: flex-start;
gap: 10px;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 16px;
color: var(--color-accent);
}
.section-heading h2 {
margin: 0;
color: var(--color-text);
font-size: 15px;
}
.section-heading p {
margin: 3px 0 0;
color: var(--color-text-muted);
font-size: 11px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
label {
display: grid;
gap: 6px;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 650;
}
textarea,
select {
width: 100%;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 9px 10px;
background: white;
color: var(--color-text);
font: inherit;
font-size: 13px;
resize: vertical;
}
.submit-button {
justify-self: start;
}
.form-error {
margin: 0;
color: var(--color-danger);
font-size: 11px;
}
.entry-aside {
align-self: start;
padding: 22px;
}
.aside-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 8px;
margin-bottom: 13px;
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.entry-aside > strong {
font-size: 14px;
}
.entry-aside ol {
margin: 14px 0 0;
padding-left: 18px;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.8;
}
.success-note {
border-radius: 6px;
margin-top: 18px;
padding: 10px;
background: var(--color-success-soft);
color: var(--color-success);
font-size: 11px;
line-height: 1.5;
}
@media (max-width: 820px) {
.entry-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.entry-form {
padding: 16px;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

1288
src/pages/StandardsPage.vue Normal file

File diff suppressed because it is too large Load Diff

32
src/router/index.ts Normal file
View File

@ -0,0 +1,32 @@
import { createRouter, createWebHistory } from "vue-router";
import { routes } from "@/router/routes";
import { useAuthStore } from "@/stores/auth.store";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
router.afterEach((to) => {
document.title = `${String(to.meta.title ?? "风险预测")} | 蝶雅风险预测`;
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!auth.isAuthenticated && auth.token) await auth.restore();
if (to.meta.public && auth.isAuthenticated) return { name: "assessment" };
if (
to.meta.requiresAuth !== false &&
!to.meta.public &&
!auth.isAuthenticated
) {
return { name: "login", query: { redirect: to.fullPath } };
}
const permission = to.meta.permission;
if (typeof permission === "string" && !auth.hasPermission(permission))
return { name: "assessment" };
return true;
});
export default router;

72
src/router/routes.ts Normal file
View File

@ -0,0 +1,72 @@
import type { RouteRecordRaw } from "vue-router";
import MainLayout from "@/layouts/MainLayout.vue";
export const routes: RouteRecordRaw[] = [
{
path: "/",
component: MainLayout,
children: [
{
path: "",
redirect: { name: "assessment" },
},
{
path: "review",
name: "review",
component: () => import("@/pages/ReviewPage.vue"),
meta: {
title: "审核队列",
requiresAuth: true,
permission: "risk_item.review",
},
},
{
path: "risk-items/new",
name: "risk-item-entry",
component: () => import("@/pages/RiskItemEntryPage.vue"),
meta: {
title: "录入风险项",
requiresAuth: true,
permission: "risk_item.submit",
},
},
{
path: "assessment",
name: "assessment",
component: () => import("@/pages/RiskAssistantPage.vue"),
meta: {
title: "智能预估",
requiresAuth: true,
permission: "risk.assess",
},
},
{
path: "overview",
name: "overview",
component: () => import("@/pages/OverviewPage.vue"),
meta: {
title: "数据看板",
requiresAuth: true,
permission: "risk.assess",
},
},
{
path: "standards",
name: "standards",
component: () => import("@/pages/StandardsPage.vue"),
meta: {
title: "判定标准库",
requiresAuth: true,
permission: "standard.read",
},
},
],
},
{
path: "/login",
name: "login",
component: () => import("@/pages/LoginPage.vue"),
meta: { title: "登录", public: true },
},
];

View File

@ -0,0 +1,10 @@
import { fetchDashboard } from "@/api/dashboard";
import type { DashboardData, DashboardYear } from "@/types/dashboard";
export async function getDashboard(
year: DashboardYear,
): Promise<DashboardData> {
const response = await fetchDashboard(year);
if (!response.data.data) throw new Error("Dashboard response is empty");
return response.data.data;
}

View File

@ -0,0 +1,8 @@
import { fetchHealth } from "@/api/health";
import type { HealthData } from "@/types/api";
export async function getHealth(): Promise<HealthData> {
const response = await fetchHealth();
if (!response.data.data) throw new Error("Health response is empty");
return response.data.data;
}

View File

@ -0,0 +1,20 @@
import { fetchModelInfo, previewRisk } from "@/api/riskPredictions";
import type {
ModelInfo,
RiskAssessmentRequest,
RiskAssessmentResult,
} from "@/types/risk";
export async function getModelInfo(): Promise<ModelInfo> {
const response = await fetchModelInfo();
if (!response.data.data) throw new Error("Model info response is empty");
return response.data.data;
}
export async function runRiskPreview(
payload: RiskAssessmentRequest,
): Promise<RiskAssessmentResult> {
const response = await previewRisk(payload);
if (!response.data.data) throw new Error("Risk preview response is empty");
return response.data.data;
}

View File

@ -0,0 +1,47 @@
import {
createStandard,
fetchStandard,
fetchStandards,
updateStandard,
} from "@/api/standards";
import type {
StandardRuleCreate,
StandardRuleDetail,
StandardRuleListData,
StandardRuleUpdate,
} from "@/types/standards";
export async function getStandards(params: {
keyword?: string;
category?: string;
status?: string;
page?: number;
pageSize?: number;
}): Promise<StandardRuleListData> {
const response = await fetchStandards(params);
if (!response.data.data) throw new Error("标准库响应为空");
return response.data.data;
}
export async function getStandard(ruleId: number): Promise<StandardRuleDetail> {
const response = await fetchStandard(ruleId);
if (!response.data.data) throw new Error("标准详情响应为空");
return response.data.data;
}
export async function addStandard(
payload: StandardRuleCreate,
): Promise<StandardRuleDetail> {
const response = await createStandard(payload);
if (!response.data.data) throw new Error("新增标准响应为空");
return response.data.data;
}
export async function editStandard(
ruleId: number,
payload: StandardRuleUpdate,
): Promise<StandardRuleDetail> {
const response = await updateStandard(ruleId, payload);
if (!response.data.data) throw new Error("修改标准响应为空");
return response.data.data;
}

27
src/stores/app.store.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { getHealth } from "@/services/health.service";
import type { HealthData } from "@/types/api";
export const useAppStore = defineStore("app", () => {
const health = ref<HealthData | null>(null);
const checkingHealth = ref(false);
const healthError = ref<string | null>(null);
const isOnline = computed(() => health.value?.status === "ok");
async function refreshHealth() {
checkingHealth.value = true;
healthError.value = null;
try {
health.value = await getHealth();
} catch {
health.value = null;
healthError.value = "后端服务不可用";
} finally {
checkingHealth.value = false;
}
}
return { health, checkingHealth, healthError, isOnline, refreshHealth };
});

104
src/stores/auth.store.ts Normal file
View File

@ -0,0 +1,104 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { devLogin, getCurrentUser, login } from "@/api/auth";
import { authenticateWithDingTalk } from "@/api/dingtalk";
import type { User } from "@/types/auth";
const TOKEN_KEY = "risk_prediction_token";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const isAuthenticated = computed(() => Boolean(token.value && user.value));
function saveToken(value: string) {
token.value = value;
localStorage.setItem(TOKEN_KEY, value);
}
function clear() {
token.value = null;
user.value = null;
localStorage.removeItem(TOKEN_KEY);
}
async function authenticate(
username: string,
password: string,
development = false,
) {
loading.value = true;
error.value = null;
try {
const response = development
? await devLogin(username, password)
: await login(username, password);
if (!response.data.data) throw new Error("登录响应为空");
saveToken(response.data.data.accessToken);
user.value = response.data.data.user;
} catch (reason) {
clear();
error.value =
reason instanceof Error ? reason.message : "登录失败,请检查账号或密码";
throw reason;
} finally {
loading.value = false;
}
}
async function restore() {
if (!token.value) return false;
try {
const response = await getCurrentUser();
user.value = response.data.data;
return Boolean(user.value);
} catch {
clear();
return false;
}
}
async function dingTalkLogin() {
loading.value = true;
error.value = null;
try {
const response = await authenticateWithDingTalk();
saveToken(response.accessToken);
user.value = response.user;
} catch (reason) {
clear();
error.value = reason instanceof Error ? reason.message : "钉钉免登失败";
throw reason;
} finally {
loading.value = false;
}
}
function logout() {
clear();
}
function hasPermission(permission: string) {
return (
user.value?.role === "admin" ||
Boolean(user.value?.permissions.includes(permission))
);
}
return {
token,
user,
loading,
error,
isAuthenticated,
authenticate,
dingTalkLogin,
restore,
clear,
logout,
hasPermission,
};
});

View File

@ -0,0 +1,263 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import {
createConversation,
fetchConversation,
fetchConversations,
streamConversationMessage,
sendConversationMessage,
} from "@/api/conversations";
import type { Conversation, ConversationSummary } from "@/types/conversation";
type ConversationMessageType = "text" | "image" | "file" | "audio";
export const useConversationStore = defineStore("conversation", () => {
const items = ref<ConversationSummary[]>([]);
const current = ref<Conversation | null>(null);
const loading = ref(false);
const sending = ref(false);
const loadingOlder = ref(false);
const error = ref<string | null>(null);
async function loadList(search?: string) {
loading.value = true;
error.value = null;
try {
const response = await fetchConversations(50, search);
items.value = response.data.data ?? [];
} catch {
error.value = "历史对话加载失败";
} finally {
loading.value = false;
}
}
async function open(id: number) {
loading.value = true;
error.value = null;
try {
const response = await fetchConversation(id);
if (!response.data.data) throw new Error("对话响应为空");
current.value = response.data.data;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "对话加载失败";
throw reason;
} finally {
loading.value = false;
}
}
async function loadOlder() {
if (
loadingOlder.value ||
!current.value ||
!current.value.messages.length ||
!current.value.messagesTotal
) {
return false;
}
const offset = current.value.messagesOffset + current.value.messages.length;
if (!current.value.hasMoreMessages) return false;
loadingOlder.value = true;
try {
const response = await fetchConversation(current.value.id, offset);
if (!response.data.data) throw new Error("历史消息响应为空");
const older = response.data.data;
current.value = {
...current.value,
messages: [...older.messages, ...current.value.messages],
messagesOffset: 0,
hasMoreMessages: older.hasMoreMessages,
messagesTotal: older.messagesTotal,
};
return true;
} finally {
loadingOlder.value = false;
}
}
async function create(title?: string) {
const response = await createConversation(title);
if (!response.data.data) throw new Error("新建对话响应为空");
current.value = response.data.data;
items.value = [toSummary(response.data.data), ...items.value];
return current.value;
}
async function send(
content: string,
messageType: ConversationMessageType = "text",
) {
if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在");
sending.value = true;
error.value = null;
try {
const response = await sendConversationMessage(
current.value.id,
content,
messageType,
);
if (!response.data.data) throw new Error("消息响应为空");
current.value = response.data.data;
const index = items.value.findIndex(
(item) => item.id === current.value?.id,
);
const summary = toSummary(current.value);
if (index >= 0) items.value.splice(index, 1, summary);
else items.value.unshift(summary);
items.value.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return current.value;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "消息发送失败";
throw reason;
} finally {
sending.value = false;
}
}
async function streamSend(
content: string,
onDelta: (delta: string) => void,
messageType: ConversationMessageType = "text",
) {
if (!current.value) await create();
if (!current.value) throw new Error("当前对话不存在");
sending.value = true;
error.value = null;
const startedAt = new Date().toISOString();
const userMessage = {
id: -Date.now(),
role: "user" as const,
messageType,
content,
riskResult: null,
responseCard: null,
semantic: null,
createdAt: startedAt,
};
const assistantMessage = {
id: userMessage.id - 1,
role: "assistant" as const,
messageType: "text" as const,
content: "",
riskResult: null,
responseCard: null,
semantic: null as Record<string, unknown> | null,
createdAt: startedAt,
};
current.value = {
...current.value,
messages: [...current.value.messages, userMessage, assistantMessage],
messageCount: current.value.messageCount + 2,
messagesTotal: current.value.messagesTotal + 2,
};
try {
const conversationId = current.value.id;
await streamConversationMessage(
conversationId,
content,
(event) => {
if (event.type === "delta" && event.content) onDelta(event.content);
if (event.type === "semantic" && event.data && current.value) {
const messages = [...current.value.messages];
const last = messages[messages.length - 1];
if (last?.role === "assistant") {
messages[messages.length - 1] = {
...last,
semantic: event.data as Record<string, unknown>,
};
current.value = { ...current.value, messages };
}
}
if (event.type === "delta" && event.content && current.value) {
const messages = [...current.value.messages];
const last = messages[messages.length - 1];
if (last?.role === "assistant") {
messages[messages.length - 1] = {
...last,
content: last.content + event.content,
};
current.value = { ...current.value, messages };
}
}
if (event.type === "error")
throw new Error(event.message || "AI 回复失败");
if (event.type === "done" && event.data)
current.value = event.data as Conversation;
},
messageType,
);
if (
!current.value ||
!current.value.messages.some(
(message) => message.role === "assistant" && message.content.trim(),
)
) {
throw new Error("AI 回复为空");
}
const summary = toSummary(current.value);
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 current.value;
} catch (reason) {
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;
} finally {
sending.value = false;
}
}
function reset() {
current.value = null;
error.value = null;
}
function toSummary(conversation: Conversation): ConversationSummary {
return {
id: conversation.id,
title: conversation.title,
messageCount: conversation.messageCount,
lastRiskLevel: conversation.lastRiskLevel,
updatedAt: conversation.updatedAt,
};
}
return {
items,
current,
loading,
sending,
loadingOlder,
error,
loadList,
open,
loadOlder,
create,
send,
streamSend,
reset,
};
});

View File

@ -0,0 +1,78 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import {
createRiskItem,
fetchPendingRiskItems,
reviewRiskItem,
updateRiskItem,
} from "@/api/riskItems";
import type {
RiskItem,
RiskItemCreate,
RiskItemUpdate,
} from "@/types/riskItem";
export const useReviewStore = defineStore("review", () => {
const items = ref<RiskItem[]>([]);
const loading = ref(false);
const submitting = ref(false);
const error = ref<string | null>(null);
async function loadPending(params?: {
sourceType?: string;
status?: string;
}) {
loading.value = true;
error.value = null;
try {
const response = await fetchPendingRiskItems(params);
items.value = response.data.data ?? [];
} catch {
error.value = "审核队列加载失败";
} finally {
loading.value = false;
}
}
async function update(itemId: number, payload: RiskItemUpdate) {
const response = await updateRiskItem(itemId, payload);
if (!response.data.data) throw new Error("风险项更新响应为空");
const index = items.value.findIndex((item) => item.id === itemId);
if (index >= 0) items.value[index] = response.data.data;
return response.data.data;
}
async function submit(payload: RiskItemCreate) {
submitting.value = true;
error.value = null;
try {
const response = await createRiskItem(payload);
if (!response.data.data) throw new Error("风险项提交响应为空");
return response.data.data;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : "风险项提交失败";
throw reason;
} finally {
submitting.value = false;
}
}
async function review(itemId: number, action: "通过" | "驳回", note: string) {
const response = await reviewRiskItem(itemId, { action, note });
if (!response.data.data) throw new Error("审核响应为空");
items.value = items.value.filter((item) => item.id !== itemId);
return response.data.data;
}
return {
items,
loading,
submitting,
error,
loadPending,
submit,
update,
review,
};
});

42
src/stores/risk.store.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import {
getModelInfo,
runRiskPreview,
} from "@/services/riskPrediction.service";
import type {
ModelInfo,
RiskAssessmentRequest,
RiskAssessmentResult,
} from "@/types/risk";
export const useRiskStore = defineStore("risk", () => {
const modelInfo = ref<ModelInfo | null>(null);
const result = ref<RiskAssessmentResult | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
async function loadModelInfo() {
try {
modelInfo.value = await getModelInfo();
} catch {
modelInfo.value = null;
}
}
async function predict(payload: RiskAssessmentRequest) {
loading.value = true;
error.value = null;
try {
result.value = await runRiskPreview(payload);
} catch {
result.value = null;
error.value = "评估请求失败,请确认后端服务已启动。";
} finally {
loading.value = false;
}
}
return { modelInfo, result, loading, error, loadModelInfo, predict };
});

126
src/styles/global.css Normal file
View File

@ -0,0 +1,126 @@
html,
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 14px;
}
body {
min-width: 320px;
line-height: 1.6;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
input {
min-height: 38px;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
padding: 0 10px;
background: white;
color: var(--color-text);
}
input:hover {
border-color: var(--color-border-strong);
}
.page {
width: min(1320px, 100%);
margin: 0 auto;
padding: 34px 36px 48px;
}
.page-header {
display: flex;
min-height: 58px;
align-items: start;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
}
.page-header h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 750;
letter-spacing: 0;
}
.eyebrow {
margin: 0;
color: var(--color-brand);
font-size: 10px;
font-weight: 800;
letter-spacing: 0;
}
.button,
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 5px;
}
.button {
min-height: 38px;
gap: 7px;
padding: 0 14px;
font-size: 13px;
font-weight: 700;
}
.button--primary {
border-color: var(--color-brand);
background: var(--color-brand);
color: white;
}
.button--primary:hover:not(:disabled) {
background: var(--color-brand-hover);
}
.button--primary:disabled {
opacity: 0.6;
}
.button--secondary {
border-color: var(--color-border-strong);
background: white;
color: var(--color-text);
}
.button--secondary:hover {
border-color: var(--color-brand);
color: var(--color-brand);
}
.icon-button {
width: 34px;
height: 34px;
padding: 0;
background: transparent;
color: var(--color-text-muted);
}
.icon-button:hover:not(:disabled) {
background: var(--color-danger-soft);
color: var(--color-danger);
}
.icon-button:disabled {
opacity: 0.35;
}
@media (max-width: 760px) {
.page {
padding: 24px 18px 36px;
}
.page-header h1 {
font-size: 21px;
}
}

36
src/styles/reset.css Normal file
View File

@ -0,0 +1,36 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
min-height: 100%;
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
appearance: none;
}
button:disabled {
cursor: not-allowed;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}

38
src/styles/variables.css Normal file
View File

@ -0,0 +1,38 @@
:root {
--color-text: #111111;
--color-text-secondary: #3d3d3d;
--color-text-muted: #6b6b6b;
--color-text-faint: #9a9a9a;
--color-surface: #ffffff;
--color-bg: #fafafa;
--color-bg-warm: #f7f7f5;
--color-border: #e8e8e6;
--color-border-light: #f0f0ee;
--color-border-strong: #d4d4d0;
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
--color-accent-subtle: #eff6ff;
--color-accent-soft: #dbeafe;
--color-success: #16a34a;
--color-success-soft: #f0fdf4;
--color-success-border: #bbf7d0;
--color-warning: #d97706;
--color-warning-soft: #fffbeb;
--color-warning-border: #fde68a;
--color-danger: #dc2626;
--color-danger-soft: #fef2f2;
--color-danger-border: #fecaca;
--color-purple: #7c3aed;
--color-purple-soft: #f5f3ff;
--color-brand: #111111;
--color-brand-hover: #333333;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
--shadow-md: 0 2px 8px rgb(0 0 0 / 4%);
--shadow-lg: 0 4px 16px rgb(0 0 0 / 6%);
--header-height: 52px;
--sidebar-width: 240px;
--content-width: 760px;
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "PingFang SC",
"Noto Sans SC", sans-serif;
}

11
src/types/api.ts Normal file
View File

@ -0,0 +1,11 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T | null;
}
export interface HealthData {
status: string;
service: string;
version: string;
}

29
src/types/auth.ts Normal file
View File

@ -0,0 +1,29 @@
export interface User {
id: number;
empNo: string;
email: string | null;
name: string;
dept: string | null;
position: string | null;
role: string;
status: string;
dingtalkId: string | null;
dingtalkJobNumber: string | null;
dingtalkTitle: string | null;
dingtalkDeptId: number | null;
dingtalkIsDeptManager: boolean;
permissions: string[];
}
export interface AuthTokenData {
accessToken: string;
tokenType: string;
expiresIn: number;
user: User;
}
export interface DingTalkConfig {
corpId: string;
appKey: string;
agentId: string;
}

38
src/types/conversation.ts Normal file
View File

@ -0,0 +1,38 @@
import type { RiskAssessmentResult, RiskLevel } from "@/types/risk";
export interface AssistantResponseCard {
type: "supplier" | "material" | "standard" | "compliance" | "solution";
title: string;
[key: string]: unknown;
}
export interface ConversationMessage {
id: number;
role: "user" | "assistant";
messageType: "text" | "image" | "file" | "audio";
content: string;
riskResult: RiskAssessmentResult | null;
responseCard: AssistantResponseCard | null;
semantic: Record<string, unknown> | null;
createdAt: string;
}
export interface ConversationSummary {
id: number;
title: string;
messageCount: number;
lastRiskLevel: RiskLevel | null;
updatedAt: string;
}
export interface Conversation {
id: number;
title: string;
messageCount: number;
lastRiskLevel: RiskLevel | null;
updatedAt: string;
messages: ConversationMessage[];
messagesTotal: number;
messagesOffset: number;
hasMoreMessages: boolean;
}

68
src/types/dashboard.ts Normal file
View File

@ -0,0 +1,68 @@
export type DashboardYear = "all" | "2024" | "2025" | "2026";
export interface DashboardMetric {
value: number | null;
label: string;
changePercent: number | null;
}
export interface DashboardStage {
name: string;
count: number;
share: number;
riskLevel: string;
}
export interface DashboardCategory {
name: string;
count: number;
share: number;
riskLevel: string;
topMaterial: string | null;
}
export interface DashboardMatrixRow {
name: string;
partyType: string;
categories: Record<string, number>;
cooperationTotal: number;
anomalyCount: number;
anomalyRate: number;
tier: string;
}
export interface DashboardDefect {
level: string;
label: string;
count: number;
share: number;
}
export interface DashboardSupplierRank {
name: string;
partyType: string;
count: number;
tier: string;
topProblem: string | null;
}
export interface DashboardTrendPoint {
month: string;
label: string;
count: number;
}
export interface DashboardData {
year: DashboardYear;
totalCases: number;
metrics: DashboardMetric[];
stages: DashboardStage[];
categories: DashboardCategory[];
matrixCategories: string[];
matrix: DashboardMatrixRow[];
defects: DashboardDefect[];
supplierRanking: DashboardSupplierRank[];
trend: DashboardTrendPoint[];
insight: string;
warnings: string[];
}

68
src/types/risk.ts Normal file
View File

@ -0,0 +1,68 @@
export type RiskLevel =
"low" | "moderately_low" | "medium" | "high" | "very_high";
export interface RiskIndicator {
name: string;
value: number;
weight: number;
}
export interface RiskAssessmentRequest {
subjectId: string;
indicators: RiskIndicator[];
}
export interface RiskFactorResult extends RiskIndicator {
contribution: number;
}
export interface RiskAssessmentResult {
subjectId: string;
score: number;
level: RiskLevel;
modelVersion: string;
factors: RiskFactorResult[];
similarCases?: Array<{
date: string;
product: string;
problem: string;
solution: string;
}>;
aiSuggestions?: string[];
riskBadge?: string | null;
alertText?: string | null;
categoryWarning?: {
category: string;
anomalyCount: number;
riskLevel: string;
similarCategory: string | null;
similarCount: number | null;
suggestion: string | null;
} | null;
supplierFreq?: {
supplierName: string | null;
cooperationCount: number | null;
anomalyCount: number | null;
alpha: number;
tier: string | null;
suggestion: string | null;
} | null;
standardJudgment?: Array<{
category: string;
riskPoint: string;
rule: string;
source: string | null;
status: string;
}>;
dimensionScores?: Array<{
dimension: string;
score: number;
weight: number;
}>;
}
export interface ModelInfo {
modelVersion: string;
scoreRange: string;
levels: RiskLevel[];
}

40
src/types/riskItem.ts Normal file
View File

@ -0,0 +1,40 @@
export interface RiskItemCreate {
sourceType: string;
sourceFileId?: string | null;
productName: string;
productCategory?: string | null;
supplier?: string | null;
factory?: string | null;
stageNew?: string | null;
description: string;
defectLevel?: string | null;
solution?: string | null;
photos?: string[];
}
export interface RiskItem extends RiskItemCreate {
id: number;
submittedBy: string | null;
submittedAt: string | null;
status: string;
createdAt: string;
}
export type RiskItemUpdate = Partial<RiskItemCreate>;
export interface ReviewAction {
action: "通过" | "驳回";
note: string;
}
export interface ReviewRecord {
id: number;
riskItemId: number;
preReviewResult: string | null;
preReviewNote: string | null;
reviewer: string | null;
reviewAction: string | null;
reviewNote: string | null;
reviewedAt: string | null;
caseId: number | null;
}

44
src/types/standards.ts Normal file
View File

@ -0,0 +1,44 @@
export type StandardCategory = "内料" | "包材" | "包装" | "法规";
export interface StandardCategoryCount {
category: StandardCategory;
count: number;
}
export interface StandardRuleSummary {
id: number;
categoryLv1: StandardCategory;
categoryLv2: string | null;
categoryLv3: string | null;
riskPoint: string;
judgeStandard: string;
regulation: string | null;
status: "启用" | "停用";
updatedAt: string;
}
export interface StandardRuleDetail extends StandardRuleSummary {
attachment: string | null;
}
export interface StandardRuleCreate {
categoryLv1: StandardCategory;
categoryLv2?: string | null;
categoryLv3?: string | null;
riskPoint: string;
judgeStandard: string;
regulation?: string | null;
attachment?: string | null;
status?: "启用" | "停用";
}
export type StandardRuleUpdate = Partial<StandardRuleCreate>;
export interface StandardRuleListData {
items: StandardRuleSummary[];
total: number;
page: number;
pageSize: number;
totalPages: number;
categoryCounts: StandardCategoryCount[];
}

13
src/utils/risk.test.ts Normal file
View File

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { formatRiskLevel } from "@/utils/risk";
describe("formatRiskLevel", () => {
it("formats all API risk levels", () => {
expect(formatRiskLevel("low")).toBe("低风险");
expect(formatRiskLevel("moderately_low")).toBe("较低风险");
expect(formatRiskLevel("medium")).toBe("中等风险");
expect(formatRiskLevel("high")).toBe("高风险");
expect(formatRiskLevel("very_high")).toBe("极高风险");
});
});

13
src/utils/risk.ts Normal file
View File

@ -0,0 +1,13 @@
import type { RiskLevel } from "@/types/risk";
const labels: Record<RiskLevel, string> = {
low: "低风险",
moderately_low: "较低风险",
medium: "中等风险",
high: "高风险",
very_high: "极高风险",
};
export function formatRiskLevel(level: RiskLevel): string {
return labels[level];
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"ignoreDeprecations": "6.0",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "vitest.config.ts"]
}

16
vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
server: {
host: '127.0.0.1',
port: 39158,
strictPort: true,
proxy: { '/api': { target: 'http://127.0.0.1:18474', changeOrigin: true } },
},
preview: { host: '127.0.0.1', port: 39158, strictPort: true },
})

10
vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
test: { environment: 'jsdom', globals: true },
})