风险预警
This commit is contained in:
commit
b672f025dd
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.vite/
|
||||
.eslintcache
|
||||
coverage/
|
||||
|
||||
155
.runtime-logs/frontend.err.log
Normal file
155
.runtime-logs/frontend.err.log
Normal 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)
|
||||
59
.runtime-logs/frontend.out.log
Normal file
59
.runtime-logs/frontend.out.log
Normal 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
15
README.md
Normal 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
22
docs/architecture.md
Normal 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
28
eslint.config.js
Normal 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
14
index.html
Normal 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
4887
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
30
src/App.vue
Normal 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
31
src/api/auth.ts
Normal 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
95
src/api/conversations.ts
Normal 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
9
src/api/dashboard.ts
Normal 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
25
src/api/dingtalk.ts
Normal 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
6
src/api/health.ts
Normal 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
27
src/api/http.ts
Normal 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
36
src/api/riskItems.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
18
src/api/riskPredictions.ts
Normal file
18
src/api/riskPredictions.ts
Normal 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
35
src/api/standards.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
321
src/components/assistant/AssistantComposer.vue
Normal file
321
src/components/assistant/AssistantComposer.vue
Normal 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>
|
||||
551
src/components/assistant/AssistantResponseCard.vue
Normal file
551
src/components/assistant/AssistantResponseCard.vue
Normal 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>
|
||||
246
src/components/assistant/AssistantWelcome.vue
Normal file
246
src/components/assistant/AssistantWelcome.vue
Normal 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>
|
||||
725
src/components/assistant/RiskAnalysisCard.vue
Normal file
725
src/components/assistant/RiskAnalysisCard.vue
Normal 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>
|
||||
49
src/components/common/RiskBadge.vue
Normal file
49
src/components/common/RiskBadge.vue
Normal 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>
|
||||
255
src/components/layout/AppHeader.vue
Normal file
255
src/components/layout/AppHeader.vue
Normal 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>
|
||||
379
src/components/layout/AppSidebar.vue
Normal file
379
src/components/layout/AppSidebar.vue
Normal 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>
|
||||
227
src/components/risk/RiskAssessmentForm.vue
Normal file
227
src/components/risk/RiskAssessmentForm.vue
Normal 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>
|
||||
199
src/components/risk/RiskResultPanel.vue
Normal file
199
src/components/risk/RiskResultPanel.vue
Normal 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>
|
||||
22
src/composables/useRiskPrediction.ts
Normal file
22
src/composables/useRiskPrediction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
src/layouts/MainLayout.vue
Normal file
48
src/layouts/MainLayout.vue
Normal 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
10
src/main.ts
Normal 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
366
src/pages/LoginPage.vue
Normal 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
1497
src/pages/OverviewPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1163
src/pages/ReviewPage.vue
Normal file
1163
src/pages/ReviewPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
77
src/pages/RiskAssessmentPage.vue
Normal file
77
src/pages/RiskAssessmentPage.vue
Normal 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>
|
||||
705
src/pages/RiskAssistantPage.vue
Normal file
705
src/pages/RiskAssistantPage.vue
Normal 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>
|
||||
284
src/pages/RiskItemEntryPage.vue
Normal file
284
src/pages/RiskItemEntryPage.vue
Normal 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
1288
src/pages/StandardsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
32
src/router/index.ts
Normal file
32
src/router/index.ts
Normal 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
72
src/router/routes.ts
Normal 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 },
|
||||
},
|
||||
];
|
||||
10
src/services/dashboard.service.ts
Normal file
10
src/services/dashboard.service.ts
Normal 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;
|
||||
}
|
||||
8
src/services/health.service.ts
Normal file
8
src/services/health.service.ts
Normal 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;
|
||||
}
|
||||
20
src/services/riskPrediction.service.ts
Normal file
20
src/services/riskPrediction.service.ts
Normal 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;
|
||||
}
|
||||
47
src/services/standards.service.ts
Normal file
47
src/services/standards.service.ts
Normal 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
27
src/stores/app.store.ts
Normal 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
104
src/stores/auth.store.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
263
src/stores/conversation.store.ts
Normal file
263
src/stores/conversation.store.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
78
src/stores/review.store.ts
Normal file
78
src/stores/review.store.ts
Normal 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
42
src/stores/risk.store.ts
Normal 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
126
src/styles/global.css
Normal 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
36
src/styles/reset.css
Normal 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
38
src/styles/variables.css
Normal 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
11
src/types/api.ts
Normal 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
29
src/types/auth.ts
Normal 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
38
src/types/conversation.ts
Normal 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
68
src/types/dashboard.ts
Normal 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
68
src/types/risk.ts
Normal 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
40
src/types/riskItem.ts
Normal 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
44
src/types/standards.ts
Normal 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
13
src/utils/risk.test.ts
Normal 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
13
src/utils/risk.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal 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
16
vite.config.ts
Normal 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
10
vitest.config.ts
Normal 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 },
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user