feat: HTTP 层重构 + API 抽离 + 状态管理优化 + 路由修复
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 10s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 10s
- HTTP 层:安装 axios,创建 lib/api-client.ts 统一拦截器 - API 层:创建 33 个 api/*.ts 文件,所有页面迁移完成 - DataPages.tsx 拆分为 10 个独立文件 - message 导入统一为 App.useApp() - 新增 StaticAntdProvider 全局消息/通知 - 全局 HTTP 错误/成功提示(notification.error/success) - learning 路由 /learning → /learning/dashboard 修复选中 bug - 学习会话页面优化(移除 ID 列、加批量删除、后端排序) - 文档导入页面重构(知识库筛选、批量重新解析) - 删除旧 service 文件 10 个(admin-api、billing-api 等) - antd App 包裹根组件 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7c802bdf47
commit
8663ceba66
388
package-lock.json
generated
388
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@ant-design/pro-components": "^2.8.10",
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"antd": "^5.29.3",
|
"antd": "^5.29.3",
|
||||||
|
"axios": "^1.18.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
@ -754,7 +755,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -1012,7 +1012,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@ -1063,10 +1062,33 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@ -1755,6 +1777,40 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||||
@ -2173,7 +2229,6 @@
|
|||||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@ -2189,7 +2244,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.15.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.15.tgz",
|
||||||
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@ -2265,7 +2319,6 @@
|
|||||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.4",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.4",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
@ -2517,7 +2570,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -2544,6 +2596,18 @@
|
|||||||
"object-assign": "4.x"
|
"object-assign": "4.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz",
|
||||||
@ -2566,7 +2630,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz",
|
"resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz",
|
||||||
"integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==",
|
"integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.2.1",
|
"@ant-design/colors": "^7.2.1",
|
||||||
"@ant-design/cssinjs": "^1.23.0",
|
"@ant-design/cssinjs": "^1.23.0",
|
||||||
@ -2668,6 +2731,24 @@
|
|||||||
"react-dom": ">=16.0.0"
|
"react-dom": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bail": {
|
"node_modules/bail": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz",
|
||||||
@ -2734,7 +2815,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@ -2749,6 +2829,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001793",
|
"version": "1.0.30001793",
|
||||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||||
@ -2835,6 +2928,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/comma-separated-tokens": {
|
"node_modules/comma-separated-tokens": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||||
@ -2944,6 +3049,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dequal": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz",
|
||||||
@ -2976,6 +3090,20 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/echarts": {
|
"node_modules/echarts": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
|
||||||
@ -3027,6 +3155,51 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
||||||
@ -3056,7 +3229,6 @@
|
|||||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@ -3351,6 +3523,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/format": {
|
"node_modules/format": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/format/-/format-0.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/format/-/format-0.2.2.tgz",
|
||||||
@ -3374,6 +3582,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@ -3384,6 +3601,43 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@ -3410,6 +3664,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@ -3417,6 +3683,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-parse-selector": {
|
"node_modules/hast-util-parse-selector": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||||
@ -3529,6 +3834,19 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -4098,6 +4416,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mdast-util-find-and-replace": {
|
"node_modules/mdast-util-find-and-replace": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||||
@ -4943,6 +5270,27 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
@ -5127,7 +5475,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -5210,6 +5557,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
|
||||||
@ -5323,7 +5679,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.1.tgz",
|
"resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.1.tgz",
|
||||||
"integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==",
|
"integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.0",
|
"@babel/runtime": "^7.18.0",
|
||||||
"@rc-component/async-validator": "^5.0.3",
|
"@rc-component/async-validator": "^5.0.3",
|
||||||
@ -5832,7 +6187,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz",
|
||||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -5842,7 +6196,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz",
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz",
|
||||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -6347,7 +6700,6 @@
|
|||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -6558,7 +6910,6 @@
|
|||||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@ -6692,7 +7043,6 @@
|
|||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"@ant-design/pro-components": "^2.8.10",
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"antd": "^5.29.3",
|
"antd": "^5.29.3",
|
||||||
|
"axios": "^1.18.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
|
|||||||
26
src/App.tsx
26
src/App.tsx
@ -1,9 +1,10 @@
|
|||||||
import { Suspense, lazy } from 'react'
|
import { Suspense, lazy } from 'react'
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ConfigProvider } from 'antd'
|
import { ConfigProvider, App as AntApp } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import { Empty } from 'antd'
|
import { Empty } from 'antd'
|
||||||
|
import { StaticAntdProvider } from './lib/antd-global'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import AuthGuard from './components/AuthGuard'
|
import AuthGuard from './components/AuthGuard'
|
||||||
import PermissionGuard from './components/PermissionGuard'
|
import PermissionGuard from './components/PermissionGuard'
|
||||||
@ -45,15 +46,15 @@ const NotificationAdmin = lazy(() => import('./pages/NotificationAdmin'))
|
|||||||
const CacheAdmin = lazy(() => import('./pages/CacheAdmin'))
|
const CacheAdmin = lazy(() => import('./pages/CacheAdmin'))
|
||||||
const LearningData = lazy(() => import('./pages/LearningData'))
|
const LearningData = lazy(() => import('./pages/LearningData'))
|
||||||
const LearningDashboard = lazy(() => import('./pages/learning/Dashboard'))
|
const LearningDashboard = lazy(() => import('./pages/learning/Dashboard'))
|
||||||
const ReadingEventPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.ReadingEventPage })))
|
const ReadingEventPage = lazy(() => import('./pages/learning/ReadingEventPage'))
|
||||||
const SessionPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.SessionPage })))
|
const SessionPage = lazy(() => import('./pages/learning/SessionPage'))
|
||||||
const ProgressPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.ProgressPage })))
|
const ProgressPage = lazy(() => import('./pages/learning/ProgressPage'))
|
||||||
const DailyActivityPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.DailyActivityPage })))
|
const DailyActivityPage = lazy(() => import('./pages/learning/DailyActivityPage'))
|
||||||
const RecordPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.RecordPage })))
|
const RecordPage = lazy(() => import('./pages/learning/RecordPage'))
|
||||||
const AnomalyPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.AnomalyPage })))
|
const AnomalyPage = lazy(() => import('./pages/learning/AnomalyPage'))
|
||||||
const UserTimelinePage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.UserTimelinePage })))
|
const UserTimelinePage = lazy(() => import('./pages/learning/UserTimelinePage'))
|
||||||
const UserDiagnosePage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.UserDiagnosePage })))
|
const UserDiagnosePage = lazy(() => import('./pages/learning/UserDiagnosePage'))
|
||||||
const MaterialDiagnosePage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.MaterialDiagnosePage })))
|
const MaterialDiagnosePage = lazy(() => import('./pages/learning/MaterialDiagnosePage'))
|
||||||
const LearningReplay = lazy(() => import('./pages/learning/ReplayPage'))
|
const LearningReplay = lazy(() => import('./pages/learning/ReplayPage'))
|
||||||
const BackupAdmin = lazy(() => import('./pages/BackupAdmin'))
|
const BackupAdmin = lazy(() => import('./pages/BackupAdmin'))
|
||||||
const KnowledgeSourcesPage = lazy(() => import('./pages/KnowledgeSources'))
|
const KnowledgeSourcesPage = lazy(() => import('./pages/KnowledgeSources'))
|
||||||
@ -65,6 +66,8 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfigProvider locale={zhCN} renderEmpty={() => <Empty description="暂无数据" />}>
|
<ConfigProvider locale={zhCN} renderEmpty={() => <Empty description="暂无数据" />}>
|
||||||
|
<AntApp>
|
||||||
|
<StaticAntdProvider />
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
@ -194,7 +197,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="reviews" element={<Suspense fallback={<PageLoading />}><ReviewAdmin /></Suspense>} />
|
<Route path="reviews" element={<Suspense fallback={<PageLoading />}><ReviewAdmin /></Suspense>} />
|
||||||
<Route path="learning-data" element={<Suspense fallback={<PageLoading />}><LearningData /></Suspense>} />
|
<Route path="learning-data" element={<Suspense fallback={<PageLoading />}><LearningData /></Suspense>} />
|
||||||
<Route path="learning" element={<Suspense fallback={<PageLoading />}><LearningDashboard /></Suspense>} />
|
<Route path="learning/dashboard" element={<Suspense fallback={<PageLoading />}><LearningDashboard /></Suspense>} />
|
||||||
<Route path="learning/events" element={<Suspense fallback={<PageLoading />}><ReadingEventPage /></Suspense>} />
|
<Route path="learning/events" element={<Suspense fallback={<PageLoading />}><ReadingEventPage /></Suspense>} />
|
||||||
<Route path="learning/sessions" element={<Suspense fallback={<PageLoading />}><SessionPage /></Suspense>} />
|
<Route path="learning/sessions" element={<Suspense fallback={<PageLoading />}><SessionPage /></Suspense>} />
|
||||||
<Route path="learning/progress" element={<Suspense fallback={<PageLoading />}><ProgressPage /></Suspense>} />
|
<Route path="learning/progress" element={<Suspense fallback={<PageLoading />}><ProgressPage /></Suspense>} />
|
||||||
@ -216,6 +219,7 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</AntApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
11
src/api/ai-gateway.ts
Normal file
11
src/api/ai-gateway.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const aiGatewayAPI = {
|
||||||
|
status() { return api.get('/admin-api/ai-gateway/status') },
|
||||||
|
routes() { return api.get('/admin-api/ai-gateway/routes') },
|
||||||
|
providers() { return api.get('/admin-api/ai-gateway/providers') },
|
||||||
|
fallbackEvents() { return api.get('/admin-api/ai-gateway/fallback-events') },
|
||||||
|
createRoute(data: any) { return api.post('/admin-api/ai-gateway/routes', data) },
|
||||||
|
deleteRoute(id: string) { return api.delete(`/admin-api/ai-gateway/routes/${id}`) },
|
||||||
|
toggleProvider(name: string, enabled: boolean) { return api.put(`/admin-api/ai-gateway/providers/${name}`, { enabled }) },
|
||||||
|
}
|
||||||
15
src/api/ai.ts
Normal file
15
src/api/ai.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const aiAPI = {
|
||||||
|
sendMessage(messages: { role: string; content: string }[], conversationId?: string) {
|
||||||
|
const body: Record<string, unknown> = { messages }
|
||||||
|
if (conversationId) body.conversationId = conversationId
|
||||||
|
return api.post('/admin-api/ai/chat', body)
|
||||||
|
},
|
||||||
|
stopChat(runId: string) {
|
||||||
|
return api.post('/admin-api/ai/chat/stop', { runId })
|
||||||
|
},
|
||||||
|
resolveApproval(runId: string, choice: string) {
|
||||||
|
return api.post('/admin-api/ai/chat/resolve', { runId, choice })
|
||||||
|
},
|
||||||
|
}
|
||||||
22
src/api/audit.ts
Normal file
22
src/api/audit.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { AuditLog } from '@/types/admin'
|
||||||
|
import type { PaginatedResult, PaginationParams } from '@/types/api'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { MOCK_AUDIT_LOGS } from '@/services/mock-data'
|
||||||
|
|
||||||
|
export interface AuditLogsQuery extends PaginationParams {
|
||||||
|
adminUserId?: string; action?: string; startDate?: string; endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditAPI = {
|
||||||
|
list(params: AuditLogsQuery) {
|
||||||
|
try {
|
||||||
|
return api.get<PaginatedResult<AuditLog>>(
|
||||||
|
`/admin-api/audit-logs?${new URLSearchParams(params as Record<string, string>).toString()}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) return { items: MOCK_AUDIT_LOGS, total: MOCK_AUDIT_LOGS.length, page: params.page ?? 1, limit: params.limit ?? 20, totalPages: 1 }
|
||||||
|
throw new Error('获取审计日志失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getById(id: string) { return api.get<AuditLog>(`/admin-api/audit-logs/${id}`) },
|
||||||
|
}
|
||||||
19
src/api/auth.ts
Normal file
19
src/api/auth.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
interface LoginResponse { accessToken: string; refreshToken: string; adminUser: AdminUser }
|
||||||
|
|
||||||
|
export const authAPI = {
|
||||||
|
login(email: string, password: string) {
|
||||||
|
return api.post<LoginResponse>('/admin-api/auth/login', { email, password })
|
||||||
|
},
|
||||||
|
refresh(refreshToken: string) {
|
||||||
|
return api.post<{ accessToken: string; refreshToken: string; adminUser: AdminUser }>('/admin-api/auth/refresh', { refreshToken })
|
||||||
|
},
|
||||||
|
logout(refreshToken: string) {
|
||||||
|
return api.post<void>('/admin-api/auth/logout', { refreshToken })
|
||||||
|
},
|
||||||
|
me() {
|
||||||
|
return api.get<AdminUser>('/admin-api/auth/me')
|
||||||
|
},
|
||||||
|
}
|
||||||
8
src/api/backup.ts
Normal file
8
src/api/backup.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const backupAPI = {
|
||||||
|
jobs: () => api.get('/admin-api/backup/jobs'),
|
||||||
|
cleanups: () => api.get('/admin-api/backup/cleanup'),
|
||||||
|
triggerBackup: (type: string) => api.post(`/admin-api/backup/trigger/${type}`),
|
||||||
|
triggerCleanup: (type: string) => api.post(`/admin-api/backup/cleanup/${type}`),
|
||||||
|
}
|
||||||
10
src/api/billing.ts
Normal file
10
src/api/billing.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface BillingInfo {
|
||||||
|
name: string; model: string; balance: string; currency: string
|
||||||
|
status: 'ok' | 'unknown'; consoleUrl: string; note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const billingAPI = {
|
||||||
|
get() { return api.get<{ providers: BillingInfo[] }>('/admin-api/billing') },
|
||||||
|
}
|
||||||
8
src/api/cache.ts
Normal file
8
src/api/cache.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const cacheAPI = {
|
||||||
|
stats: () => api.get('/admin-api/cache/stats'),
|
||||||
|
flushModule: (module: string) => api.post(`/admin-api/cache/flush/${module}`),
|
||||||
|
flushAll: () => api.post('/admin-api/cache/flush-all'),
|
||||||
|
resetStats: () => api.post('/admin-api/cache/reset-stats'),
|
||||||
|
}
|
||||||
6
src/api/chat-logs.ts
Normal file
6
src/api/chat-logs.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const chatLogsAPI = {
|
||||||
|
sessions: () => api.get('/admin-api/rag-chat/sessions'),
|
||||||
|
sessionMessages: (id: string) => api.get(`/admin-api/rag-chat/sessions/${id}/messages`),
|
||||||
|
}
|
||||||
16
src/api/compliance.ts
Normal file
16
src/api/compliance.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const complianceAPI = {
|
||||||
|
privacyPolicies: () => api.get('/admin-api/compliance/privacy-policies'),
|
||||||
|
createPrivacy: (data: any) => api.post('/admin-api/compliance/privacy-policies', data),
|
||||||
|
updatePrivacy: (id: string, data: any) => api.patch(`/admin-api/compliance/privacy-policies/${id}`, data),
|
||||||
|
userAgreements: () => api.get('/admin-api/compliance/user-agreements'),
|
||||||
|
createAgreement: (data: any) => api.post('/admin-api/compliance/user-agreements', data),
|
||||||
|
updateAgreement: (id: string, data: any) => api.patch(`/admin-api/compliance/user-agreements/${id}`, data),
|
||||||
|
filings: () => api.get('/admin-api/compliance/filings'),
|
||||||
|
createFiling: (data: any) => api.post('/admin-api/compliance/filings', data),
|
||||||
|
deletionRequests: () => api.get('/admin-api/compliance/deletion-requests'),
|
||||||
|
approveDeletion: (id: string) => api.post(`/admin-api/compliance/deletion-requests/${id}/approve`),
|
||||||
|
exportRequests: () => api.get('/admin-api/compliance/export-requests'),
|
||||||
|
securityEvents: () => api.get('/admin-api/compliance/security-events'),
|
||||||
|
}
|
||||||
11
src/api/config.ts
Normal file
11
src/api/config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const configAPI = {
|
||||||
|
get() { return api.get<{ configs: any[]; flags: any[] }>('/admin-api/config') },
|
||||||
|
set(key: string, value: string) { return api.post('/admin-api/config', { key, value }) },
|
||||||
|
update(key: string, value: string) { return api.patch(`/admin-api/config/${key}`, { value }) },
|
||||||
|
remove(key: string) { return api.delete(`/admin-api/config/${key}`) },
|
||||||
|
toggleFlag(name: string, enabled: boolean) { return api.post(`/admin-api/config/flags/${name}`, { enabled }) },
|
||||||
|
setFlagWhitelist(name: string, whitelist: string) { return api.post(`/admin-api/config/flags/${name}`, { whitelist }) },
|
||||||
|
changeLog() { return api.get<any[]>('/admin-api/config/changelog') },
|
||||||
|
}
|
||||||
12
src/api/content-safety.ts
Normal file
12
src/api/content-safety.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const contentSafetyAPI = {
|
||||||
|
words() { return api.get('/admin-api/content-safety/words') },
|
||||||
|
addWord(data: any) { return api.post('/admin-api/content-safety/words', data) },
|
||||||
|
deleteWord(id: string) { return api.delete(`/admin-api/content-safety/words/${id}`) },
|
||||||
|
checks(params?: any) { return api.get('/admin-api/content-safety/checks', params) },
|
||||||
|
reports(params?: any) { return api.get('/admin-api/content-safety/reports', params) },
|
||||||
|
violations(params?: any) { return api.get('/admin-api/content-safety/violations', params) },
|
||||||
|
handleReport(id: string, data: any) { return api.post(`/admin-api/content-safety/reports/${id}/handle`, data) },
|
||||||
|
penaltyViolation(id: string, data: any) { return api.post(`/admin-api/content-safety/violations/${id}/penalty`, data) },
|
||||||
|
}
|
||||||
12
src/api/conversations.ts
Normal file
12
src/api/conversations.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface Conversation { id: string; title: string; [key: string]: any }
|
||||||
|
export interface MessageRecord { id: string; content: string; role: string; [key: string]: any }
|
||||||
|
|
||||||
|
export const conversationsAPI = {
|
||||||
|
list() { return api.get<Conversation[]>('/admin-api/conversations') },
|
||||||
|
messages(conversationId: string) { return api.get<MessageRecord[]>(`/admin-api/conversations/${conversationId}/messages`) },
|
||||||
|
create(title?: string) { return api.post<Conversation>('/admin-api/conversations', { title }) },
|
||||||
|
update(id: string, title: string) { return api.patch<void>(`/admin-api/conversations/${id}`, { title }) },
|
||||||
|
remove(id: string) { return api.delete<void>(`/admin-api/conversations/${id}`) },
|
||||||
|
}
|
||||||
14
src/api/costs.ts
Normal file
14
src/api/costs.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface CostItem { id: string; currency: string; amount: number; [key: string]: any }
|
||||||
|
|
||||||
|
export const costsAPI = {
|
||||||
|
summary() { return api.get<{ totalMonthly: number; byProvider: Record<string, number> }>('/admin-api/costs/summary') },
|
||||||
|
list() { return api.get<CostItem[]>('/admin-api/costs') },
|
||||||
|
create(data: Omit<CostItem, 'id' | 'currency'> & { currency?: string }) { return api.post<CostItem>('/admin-api/costs', data) },
|
||||||
|
update(id: string, data: Partial<CostItem>) { return api.patch<CostItem>(`/admin-api/costs/${id}`, data) },
|
||||||
|
remove(id: string) { return api.delete<void>(`/admin-api/costs/${id}`) },
|
||||||
|
report() { return api.get('/admin-api/costs/report?days=30') },
|
||||||
|
topUsers() { return api.get('/admin-api/costs/top-users?days=30&limit=10') },
|
||||||
|
aggregate() { return api.post('/admin-api/costs/aggregate') },
|
||||||
|
}
|
||||||
10
src/api/dashboard.ts
Normal file
10
src/api/dashboard.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { DashboardStats } from '@/types/admin'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { MOCK_DASHBOARD_STATS } from '@/services/mock-data'
|
||||||
|
|
||||||
|
export const dashboardAPI = {
|
||||||
|
getStats() {
|
||||||
|
if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS
|
||||||
|
return api.get<DashboardStats>('/admin-api/dashboard/stats')
|
||||||
|
},
|
||||||
|
}
|
||||||
15
src/api/events-queue.ts
Normal file
15
src/api/events-queue.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface QueueInfo { name: string; waiting: number; active: number; failed: number; [key: string]: any }
|
||||||
|
export interface FailedJob { id: string; name: string; failedReason: string; [key: string]: any }
|
||||||
|
export interface JobDetail { id: string; data: any; [key: string]: any }
|
||||||
|
|
||||||
|
export const eventsQueueAPI = {
|
||||||
|
overview() { return api.get<{ queues: QueueInfo[] }>('/admin-api/events') },
|
||||||
|
stats() { return api.get('/admin-api/events/stats') },
|
||||||
|
workers() { return api.get('/admin-api/events/workers') },
|
||||||
|
failedJobs(queue: string) { return api.get<{ jobs: FailedJob[] }>(`/admin-api/events/${queue}/failed`) },
|
||||||
|
jobDetail(queue: string, jobId: string) { return api.get<JobDetail>(`/admin-api/events/${queue}/jobs/${jobId}`) },
|
||||||
|
retry(queue: string, jobId: string) { return api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`) },
|
||||||
|
batchRetry(queue: string) { return api.post(`/admin-api/events/${queue}/jobs/batch-retry`, { count: 50 }) },
|
||||||
|
}
|
||||||
6
src/api/files.ts
Normal file
6
src/api/files.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const filesAdminAPI = {
|
||||||
|
list: (page: number) => api.get(`/admin-api/files?page=${page}&limit=20`),
|
||||||
|
delete: (id: string) => api.delete(`/admin-api/files/${id}`),
|
||||||
|
}
|
||||||
8
src/api/hermes.ts
Normal file
8
src/api/hermes.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const hermesAPI = {
|
||||||
|
tasks: () => api.get('/admin-api/hermes/tasks'),
|
||||||
|
artifacts: () => api.get('/admin-api/hermes/artifacts'),
|
||||||
|
approve: (id: string) => api.post(`/admin-api/hermes/tasks/${id}/approve`),
|
||||||
|
reject: (id: string) => api.post(`/admin-api/hermes/tasks/${id}/reject`),
|
||||||
|
}
|
||||||
8
src/api/imports.ts
Normal file
8
src/api/imports.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const importsAdminAPI = {
|
||||||
|
list: (params: string) => api.get(`/admin-api/imports?${params}`),
|
||||||
|
detail: (id: string) => api.get(`/admin-api/imports/${id}`),
|
||||||
|
retry: (id: string) => api.post(`/admin-api/imports/${id}/retry`),
|
||||||
|
batchRetry: (ids: string[]) => api.post('/admin-api/imports/batch-retry', { ids }),
|
||||||
|
}
|
||||||
34
src/api/index.ts
Normal file
34
src/api/index.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export { authAPI } from './auth'
|
||||||
|
export { usersAPI } from './users'
|
||||||
|
export { dashboardAPI } from './dashboard'
|
||||||
|
export { auditAPI } from './audit'
|
||||||
|
export { billingAPI, type BillingInfo } from './billing'
|
||||||
|
export { costsAPI } from './costs'
|
||||||
|
export { configAPI } from './config'
|
||||||
|
export { conversationsAPI } from './conversations'
|
||||||
|
export { eventsQueueAPI } from './events-queue'
|
||||||
|
export { knowledgeAPI } from './knowledge'
|
||||||
|
export { serversAPI } from './servers'
|
||||||
|
export { learningAPI } from './learning'
|
||||||
|
export { aiAPI } from './ai'
|
||||||
|
|
||||||
|
// ── 新增 ──
|
||||||
|
export { aiGatewayAPI } from './ai-gateway'
|
||||||
|
export { cacheAPI } from './cache'
|
||||||
|
export { chatLogsAPI } from './chat-logs'
|
||||||
|
export { complianceAPI } from './compliance'
|
||||||
|
export { contentSafetyAPI } from './content-safety'
|
||||||
|
export { metricsAPI } from './metrics'
|
||||||
|
export { secretsAPI } from './secrets'
|
||||||
|
export { membersAPI } from './members'
|
||||||
|
export { quotaAPI } from './quota'
|
||||||
|
export { notificationsAPI } from './notifications'
|
||||||
|
export { throttleAPI } from './throttle'
|
||||||
|
export { vectorAPI } from './vector'
|
||||||
|
export { filesAdminAPI } from './files'
|
||||||
|
export { backupAPI } from './backup'
|
||||||
|
export { importsAdminAPI } from './imports'
|
||||||
|
export { hermesAPI } from './hermes'
|
||||||
|
export { releaseAPI } from './release'
|
||||||
|
export { reviewsAPI } from './reviews'
|
||||||
|
export { reportingAPI } from './reporting'
|
||||||
15
src/api/knowledge.ts
Normal file
15
src/api/knowledge.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const knowledgeAPI = {
|
||||||
|
list(page = 1, limit = 20) {
|
||||||
|
return api.get<{ items: any[]; total: number; page: number; limit: number; totalPages: number }>(`/admin-api/knowledge-bases?page=${page}&limit=${limit}`)
|
||||||
|
},
|
||||||
|
getById(id: string) { return api.get<any>(`/admin-api/knowledge-bases/${id}`) },
|
||||||
|
remove(id: string) { return api.delete<void>(`/admin-api/knowledge-bases/${id}`) },
|
||||||
|
candidates() { return api.get('/admin-api/knowledge-bases/candidates?limit=50') },
|
||||||
|
chunks(sourceId: string) { return api.get(`/admin-api/knowledge-bases/chunks?sourceId=${sourceId}&limit=50`) },
|
||||||
|
sources(kbId: string) { return api.get(`/admin-api/knowledge-bases/${kbId}/sources`) },
|
||||||
|
sourceReferences(sourceId: string) { return api.get(`/admin-api/knowledge-bases/sources/${sourceId}/references`) },
|
||||||
|
allSources(params: string) { return api.get(`/admin-api/knowledge-bases/all-sources?${params}`) },
|
||||||
|
search(params: string) { return api.get(`/admin-api/knowledge-bases?${params}`) },
|
||||||
|
}
|
||||||
50
src/api/learning.ts
Normal file
50
src/api/learning.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
const BASE = '/admin-api/learning'
|
||||||
|
|
||||||
|
function qs(params?: Record<string, any>): string {
|
||||||
|
if (!params) return ''
|
||||||
|
const p = Object.entries(params).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||||
|
return p ? `?${p}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> { items: T[]; total: number }
|
||||||
|
|
||||||
|
export const learningAPI = {
|
||||||
|
// dashboard
|
||||||
|
getDashboard: (params?: Record<string, any>) => api.get<any>(`${BASE}/dashboard${qs(params)}`),
|
||||||
|
getKnowledgeBases: () => api.get<Array<{ id: string; name: string }>>(`${BASE}/knowledge-bases`),
|
||||||
|
|
||||||
|
// reading events
|
||||||
|
getReadingEvents: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/reading-events${qs(params)}`),
|
||||||
|
getReadingEventDetail: (id: string) => api.get<any>(`${BASE}/reading-events/${id}`),
|
||||||
|
getFailedEvents: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/reading-events/failed${qs(params)}`),
|
||||||
|
|
||||||
|
// sessions
|
||||||
|
getSessions: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/sessions${qs(params)}`),
|
||||||
|
getSessionDetail: (id: string) => api.get<any>(`${BASE}/sessions/${id}`),
|
||||||
|
deleteSessions: (ids: string[]) => api.post<{ deleted: number; message: string }>(`${BASE}/sessions/batch-delete`, { ids }),
|
||||||
|
|
||||||
|
// progress & activities
|
||||||
|
getProgress: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/progress${qs(params)}`),
|
||||||
|
getDailyActivities: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/daily-activities${qs(params)}`),
|
||||||
|
getRecords: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/records${qs(params)}`),
|
||||||
|
|
||||||
|
// user/material
|
||||||
|
getUserTimeline: (userId: string) => api.get(`${BASE}/user-timeline?userId=${encodeURIComponent(userId)}`),
|
||||||
|
getUserDiagnose: (userId: string) => api.get(`${BASE}/user-diagnose?userId=${encodeURIComponent(userId)}`),
|
||||||
|
getMaterialDiagnose: (materialId: string) => api.get(`${BASE}/material-diagnose?materialId=${encodeURIComponent(materialId)}`),
|
||||||
|
|
||||||
|
// misc
|
||||||
|
getAnomalies: () => api.get<any>(`${BASE}/anomalies`),
|
||||||
|
getTemporaryMaterials: (params?: Record<string, any>) => api.get<PaginatedResponse<unknown>>(`${BASE}/temporary-materials${qs(params)}`),
|
||||||
|
|
||||||
|
// actions
|
||||||
|
recalculate: () => api.post(`${BASE}/recalculate`),
|
||||||
|
replayEvent: (eventId: string) => api.post<{ success: boolean; message: string }>(`${BASE}/reading-events/${eventId}/replay`),
|
||||||
|
replayEventsBatch: (params: Record<string, any>) => api.post<{ replayed: number; failed: number }>(`${BASE}/reading-events/replay-batch`, params),
|
||||||
|
exportData: (type: string) => api.get(`${BASE}/export?type=${encodeURIComponent(type)}`),
|
||||||
|
getSessionsRaw: (qs: string) => api.get(`${BASE}/sessions?${qs}`),
|
||||||
|
getAnalysis: (qs: string) => api.get(`${BASE}/analysis?${qs}`),
|
||||||
|
getAiUsage: (qs: string) => api.get(`${BASE}/ai-usage?${qs}`),
|
||||||
|
}
|
||||||
9
src/api/members.ts
Normal file
9
src/api/members.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const membersAPI = {
|
||||||
|
list: () => api.get('/admin-api/users'),
|
||||||
|
deletionRequests: () => api.get('/admin-api/users/deletion-requests'),
|
||||||
|
processDeletion: (id: string, action: string) => api.post(`/admin-api/users/deletion-requests/${id}/${action}`),
|
||||||
|
memberships: () => api.get('/admin-api/users/memberships'),
|
||||||
|
updateMembership: (data: any) => api.post('/admin-api/users/memberships', data),
|
||||||
|
}
|
||||||
9
src/api/metrics.ts
Normal file
9
src/api/metrics.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const metricsAPI = {
|
||||||
|
overview: () => api.get('/admin-api/metrics/overview'),
|
||||||
|
recent: () => api.get('/admin-api/metrics/recent?limit=30'),
|
||||||
|
top: () => api.get('/admin-api/metrics/top?limit=15'),
|
||||||
|
ai: () => api.get('/admin-api/metrics/ai?days=7'),
|
||||||
|
worker: () => api.get('/admin-api/metrics/worker?days=7'),
|
||||||
|
}
|
||||||
12
src/api/notifications.ts
Normal file
12
src/api/notifications.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const notificationsAPI = {
|
||||||
|
templates: () => api.get('/admin-api/notifications/templates'),
|
||||||
|
createTemplate: (data: any) => api.post('/admin-api/notifications/templates', data),
|
||||||
|
updateTemplate: (id: string, data: any) => api.patch(`/admin-api/notifications/templates/${id}`, data),
|
||||||
|
deleteTemplate: (id: string) => api.delete(`/admin-api/notifications/templates/${id}`),
|
||||||
|
sendLog: () => api.get('/admin-api/notifications/send-log?limit=50'),
|
||||||
|
adminNotifications: () => api.get('/admin-api/admin-notifications'),
|
||||||
|
send: (data: any) => api.post('/admin-api/admin-notifications/send', data),
|
||||||
|
markRead: (id: string) => api.post(`/admin-api/admin-notifications/${id}/read`),
|
||||||
|
}
|
||||||
8
src/api/quota.ts
Normal file
8
src/api/quota.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const quotaAPI = {
|
||||||
|
plans: () => api.get('/admin-api/quota/plans'),
|
||||||
|
createPlan: (data: any) => api.post('/admin-api/quota/plans', data),
|
||||||
|
memberships: () => api.get('/admin-api/quota/memberships'),
|
||||||
|
costs: () => api.get('/admin-api/quota/costs'),
|
||||||
|
}
|
||||||
11
src/api/release.ts
Normal file
11
src/api/release.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const releaseAPI = {
|
||||||
|
changelogs: () => api.get('/admin-api/release/changelogs'),
|
||||||
|
decisions: () => api.get('/admin-api/release/decisions'),
|
||||||
|
checklists: (ver: string) => api.get(`/admin-api/release/checklists/${ver}`),
|
||||||
|
create: (type: string, data: any) => api.post(`/admin-api/release/${type}`, data),
|
||||||
|
update: (type: string, id: string, data: any) => api.patch(`/admin-api/release/${type}/${id}`, data),
|
||||||
|
delete: (type: string, id: string) => api.delete(`/admin-api/release/${type}/${id}`),
|
||||||
|
toggleChecklist: (id: string, checked: boolean) => api.patch(`/admin-api/release/checklists/${id}`, { checked }),
|
||||||
|
}
|
||||||
6
src/api/reporting.ts
Normal file
6
src/api/reporting.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const reportingAPI = {
|
||||||
|
jobs: () => api.get('/admin-api/reporting/jobs'),
|
||||||
|
exportCSV: (type: string, days: string) => api.raw.get(`/admin-api/reporting/export/${type}?days=${days}`),
|
||||||
|
}
|
||||||
5
src/api/reviews.ts
Normal file
5
src/api/reviews.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const reviewsAPI = {
|
||||||
|
list: (params: string) => api.get(`/admin-api/reviews?${params}`),
|
||||||
|
}
|
||||||
13
src/api/secrets.ts
Normal file
13
src/api/secrets.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const secretsAPI = {
|
||||||
|
list() { return api.get('/admin-api/secrets') },
|
||||||
|
logs() { return api.get('/admin-api/secrets/logs') },
|
||||||
|
create(data: any) { return api.post('/admin-api/secrets', data) },
|
||||||
|
delete(id: string) { return api.delete(`/admin-api/secrets/${id}`) },
|
||||||
|
vendorBills() { return api.get('/admin-api/vendor/bills') },
|
||||||
|
createVendorBill(data: any) { return api.post('/admin-api/vendor/bills', data) },
|
||||||
|
deleteVendorBill(id: string) { return api.delete(`/admin-api/vendor/bills/${id}`) },
|
||||||
|
rotateVendorSecret(id: string) { return api.post(`/admin-api/vendor/secrets/${id}/rotate`) },
|
||||||
|
revokeVendorSecret(id: string) { return api.post(`/admin-api/vendor/secrets/${id}/revoke`) },
|
||||||
|
}
|
||||||
11
src/api/servers.ts
Normal file
11
src/api/servers.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface ServerInfo { hostname: string; cpu: number; memory: number; [key: string]: any }
|
||||||
|
export interface ServerHealth { name: string; status: string; [key: string]: any }
|
||||||
|
export interface WorkerInfo { name: string; status: string; [key: string]: any }
|
||||||
|
|
||||||
|
export const serversAPI = {
|
||||||
|
metrics() { return api.get<{ servers: ServerInfo[] }>('/admin-api/servers') },
|
||||||
|
health() { return api.get<ServerHealth[]>('/admin-api/servers/health') },
|
||||||
|
workers() { return api.get<{ workers: WorkerInfo[] }>('/admin-api/servers/workers') },
|
||||||
|
}
|
||||||
5
src/api/throttle.ts
Normal file
5
src/api/throttle.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const throttleAPI = {
|
||||||
|
status: () => api.get('/admin-api/throttle/status'),
|
||||||
|
}
|
||||||
24
src/api/users.ts
Normal file
24
src/api/users.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
import type { PaginatedResult, PaginationParams } from '@/types/api'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export interface AdminUsersQuery extends PaginationParams {
|
||||||
|
search?: string; role?: string; status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersAPI = {
|
||||||
|
list(params: AdminUsersQuery) {
|
||||||
|
try {
|
||||||
|
return api.get<PaginatedResult<AdminUser>>(
|
||||||
|
`/admin-api/admin-users?${new URLSearchParams(params as Record<string, string>).toString()}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) return { items: [], total: 0, page: params.page ?? 1, limit: params.limit ?? 20, totalPages: 0 }
|
||||||
|
throw new Error('获取管理员列表失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getById(id: string) { return api.get<AdminUser>(`/admin-api/admin-users/${id}`) },
|
||||||
|
create(data: { email: string; password: string; displayName: string; role: string }) { return api.post<AdminUser>('/admin-api/admin-users', data) },
|
||||||
|
update(id: string, data: { role?: string; status?: string; displayName?: string }) { return api.put<AdminUser>(`/admin-api/admin-users/${id}`, data) },
|
||||||
|
remove(id: string) { return api.delete<void>(`/admin-api/admin-users/${id}`) },
|
||||||
|
}
|
||||||
8
src/api/vector.ts
Normal file
8
src/api/vector.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
|
export const vectorAPI = {
|
||||||
|
count: () => api.get('/admin-api/vector/count'),
|
||||||
|
collection: () => api.get('/admin-api/vector/collection'),
|
||||||
|
reindex: () => api.post('/admin-api/vector/reindex'),
|
||||||
|
debugSearch: (data: any) => api.post('/admin-api/vector/debug-search', data),
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ export const adminMenuItems: AdminMenuItem[] = [
|
|||||||
{
|
{
|
||||||
name: '学习数据', icon: <ReadOutlined />, children: [
|
name: '学习数据', icon: <ReadOutlined />, children: [
|
||||||
{ path: '/learning-data', name: '学习数据总览', requiredRole: 'ADMIN' },
|
{ path: '/learning-data', name: '学习数据总览', requiredRole: 'ADMIN' },
|
||||||
{ path: '/learning', name: '学习 Dashboard' },
|
{ path: '/learning/dashboard', name: '学习 Dashboard' },
|
||||||
{ path: '/learning/events', name: '阅读事件' },
|
{ path: '/learning/events', name: '阅读事件' },
|
||||||
{ path: '/learning/sessions', name: '学习会话' },
|
{ path: '/learning/sessions', name: '学习会话' },
|
||||||
{ path: '/learning/progress', name: '阅读进度' },
|
{ path: '/learning/progress', name: '阅读进度' },
|
||||||
|
|||||||
@ -86,22 +86,34 @@ export const importStatusLabels: Record<string, string> = {
|
|||||||
DOWNLOADING: '下载中',
|
DOWNLOADING: '下载中',
|
||||||
PARSING: '解析中',
|
PARSING: '解析中',
|
||||||
OCR_PROCESSING: 'OCR 处理中',
|
OCR_PROCESSING: 'OCR 处理中',
|
||||||
|
VISION_PROCESSING: '图像处理中',
|
||||||
CLEANING: '清洗中',
|
CLEANING: '清洗中',
|
||||||
CHUNKING: '分段中',
|
CHUNKING: '分段中',
|
||||||
EMBEDDING: '向量化中',
|
EMBEDDING: '向量化中',
|
||||||
INDEXING: '索引中',
|
INDEXING: '索引中',
|
||||||
GENERATING_CANDIDATES: '生成候选中',
|
GENERATING_CANDIDATES: '生成候选中',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
FAILED: '失败',
|
||||||
|
FAILED_RETRYABLE: '失败(可重试)',
|
||||||
|
FAILED_FINAL: '永久失败',
|
||||||
|
// 兼容小写
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
FAILED_FINAL: '永久失败',
|
|
||||||
};
|
};
|
||||||
export const importStepLabels: Record<string, string> = {
|
export const importStepLabels: Record<string, string> = {
|
||||||
completed: '已完成',
|
QUEUED: '排队中',
|
||||||
failed: '失败',
|
CLAIMED: '已认领',
|
||||||
DOWNLOADING: '下载中',
|
DOWNLOADING: '下载文件',
|
||||||
PARSING: '解析中',
|
PARSING: '文档解析',
|
||||||
CLEANING: '清洗中',
|
CLEANING: '文本清洗',
|
||||||
CHUNKING: '分段中',
|
CHUNKING: '文本分段',
|
||||||
|
EMBEDDING: '向量化',
|
||||||
|
INDEXING: '写入索引',
|
||||||
|
GENERATING_CANDIDATES: 'AI 生成候选',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
FAILED: '失败',
|
||||||
|
FAILED_RETRYABLE: '失败(可重试)',
|
||||||
|
FAILED_FINAL: '永久失败',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Admin / User ──
|
// ── Admin / User ──
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import { authAPI } from '@/api'
|
||||||
loginAdmin,
|
import { getAccessToken, setTokens, clearTokens, setStoredAdminUser } from '@/lib/token'
|
||||||
logoutAdmin,
|
|
||||||
getCurrentAdmin,
|
|
||||||
} from '@/services/admin-api'
|
|
||||||
import { getAccessToken, setTokens, clearTokens, setStoredAdminUser } from '@/services/token-store'
|
|
||||||
import type { AdminUser } from '@/types/admin'
|
import type { AdminUser } from '@/types/admin'
|
||||||
|
|
||||||
export function useAdminUserQuery() {
|
export function useAdminUserQuery() {
|
||||||
@ -13,7 +9,7 @@ export function useAdminUserQuery() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const token = getAccessToken()
|
const token = getAccessToken()
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
const user = await getCurrentAdmin()
|
const user = await authAPI.me()
|
||||||
setStoredAdminUser(user)
|
setStoredAdminUser(user)
|
||||||
return user
|
return user
|
||||||
},
|
},
|
||||||
@ -26,7 +22,7 @@ export function useLoginMutation() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||||
loginAdmin(email, password),
|
authAPI.login(email, password),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setTokens(data.accessToken, data.refreshToken)
|
setTokens(data.accessToken, data.refreshToken)
|
||||||
setStoredAdminUser(data.adminUser)
|
setStoredAdminUser(data.adminUser)
|
||||||
@ -41,7 +37,7 @@ export function useLogoutMutation() {
|
|||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const token = localStorage.getItem('admin_refresh_token')
|
const token = localStorage.getItem('admin_refresh_token')
|
||||||
if (token) {
|
if (token) {
|
||||||
return logoutAdmin(token).catch(() => {})
|
return authAPI.logout(token).catch(() => {})
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
},
|
},
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const breadcrumbMap: Record<string, string> = {
|
|||||||
'/files': '文件与 COS',
|
'/files': '文件与 COS',
|
||||||
'/reviews': '复习数据',
|
'/reviews': '复习数据',
|
||||||
'/learning-data': '学习数据总览',
|
'/learning-data': '学习数据总览',
|
||||||
'/learning': '学习数据',
|
'/learning/dashboard': '学习数据',
|
||||||
'/learning/events': '阅读事件',
|
'/learning/events': '阅读事件',
|
||||||
'/learning/sessions': '学习会话',
|
'/learning/sessions': '学习会话',
|
||||||
'/learning/progress': '阅读进度',
|
'/learning/progress': '阅读进度',
|
||||||
|
|||||||
24
src/lib/antd-global.ts
Normal file
24
src/lib/antd-global.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { App } from 'antd'
|
||||||
|
import type { MessageInstance } from 'antd/es/message/interface'
|
||||||
|
import type { NotificationInstance } from 'antd/es/notification/interface'
|
||||||
|
|
||||||
|
let message: MessageInstance | null = null
|
||||||
|
let notification: NotificationInstance | null = null
|
||||||
|
|
||||||
|
/** 在根组件内调用一次,将 antd App 实例暴露给外部 JS 模块使用 */
|
||||||
|
export function StaticAntdProvider() {
|
||||||
|
const staticFunc = App.useApp()
|
||||||
|
message = staticFunc.message
|
||||||
|
notification = staticFunc.notification
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessage(): MessageInstance {
|
||||||
|
if (!message) throw new Error('StaticAntdProvider not mounted')
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotification(): NotificationInstance {
|
||||||
|
if (!notification) throw new Error('StaticAntdProvider not mounted')
|
||||||
|
return notification
|
||||||
|
}
|
||||||
145
src/lib/api-client.ts
Normal file
145
src/lib/api-client.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import axios, { type AxiosInstance, type InternalAxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||||
|
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from './token'
|
||||||
|
import { getMessage, getNotification } from './antd-global'
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface ApiEnvelope<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
statusCode?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
httpStatus: number
|
||||||
|
code: number
|
||||||
|
constructor(msg: string, httpStatus: number, code?: number) {
|
||||||
|
super(msg)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.httpStatus = httpStatus
|
||||||
|
this.code = code ?? httpStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Instance ──
|
||||||
|
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: '',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Request Interceptor ──
|
||||||
|
|
||||||
|
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Response Interceptor ──
|
||||||
|
|
||||||
|
let refreshPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
async function tryRefresh(): Promise<boolean> {
|
||||||
|
const token = getRefreshToken()
|
||||||
|
if (!token) return false
|
||||||
|
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post<ApiEnvelope<{ accessToken: string; refreshToken: string }>>(
|
||||||
|
'/admin-api/auth/refresh',
|
||||||
|
{ refreshToken: token },
|
||||||
|
)
|
||||||
|
if (res.data.success) {
|
||||||
|
setTokens(res.data.data.accessToken, res.data.data.refreshToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
// ── Success handler ──
|
||||||
|
(response: AxiosResponse<ApiEnvelope>) => {
|
||||||
|
const json = response.data
|
||||||
|
if (!json.success) {
|
||||||
|
const errMsg = json.message || '请求失败'
|
||||||
|
getNotification().error({ message: '请求失败', description: errMsg })
|
||||||
|
return Promise.reject(new ApiError(errMsg, response.status, json.statusCode))
|
||||||
|
}
|
||||||
|
const data = json.data as any
|
||||||
|
if (data?.message) {
|
||||||
|
getMessage().success(data.message)
|
||||||
|
}
|
||||||
|
response.data = json.data as any
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Error handler ──
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const config = error.config
|
||||||
|
if (config && !config._retried) {
|
||||||
|
config._retried = true
|
||||||
|
const refreshed = await tryRefresh()
|
||||||
|
if (refreshed) {
|
||||||
|
return apiClient(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearTokens()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(new ApiError('登录已过期', 401))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.response) {
|
||||||
|
getNotification().error({ message: '网络错误', description: '网络连接失败,请检查网络' })
|
||||||
|
return Promise.reject(new ApiError('网络连接失败', 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const errMsg = error.response.data?.message || error.message || '请求失败'
|
||||||
|
getNotification().error({ message: '请求失败', description: errMsg })
|
||||||
|
return Promise.reject(new ApiError(errMsg, error.response.status))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Convenience wrappers (match old api.get/api.post signature) ──
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get<T = any>(path: string, params?: Record<string, any>) {
|
||||||
|
return apiClient.get<any, AxiosResponse<T>>(path, { params }).then(r => r.data)
|
||||||
|
},
|
||||||
|
post<T = any>(path: string, body?: unknown) {
|
||||||
|
return apiClient.post<any, AxiosResponse<T>>(path, body).then(r => r.data)
|
||||||
|
},
|
||||||
|
put<T = any>(path: string, body?: unknown) {
|
||||||
|
return apiClient.put<any, AxiosResponse<T>>(path, body).then(r => r.data)
|
||||||
|
},
|
||||||
|
patch<T = any>(path: string, body?: unknown) {
|
||||||
|
return apiClient.patch<any, AxiosResponse<T>>(path, body).then(r => r.data)
|
||||||
|
},
|
||||||
|
delete<T = any>(path: string, body?: unknown) {
|
||||||
|
return apiClient.delete<any, AxiosResponse<T>>(path, { data: body }).then(r => r.data)
|
||||||
|
},
|
||||||
|
/** Raw response — for file downloads, blobs, etc. No envelope unwrap. */
|
||||||
|
raw: {
|
||||||
|
get(path: string, responseType: 'text' | 'blob' = 'text') {
|
||||||
|
return apiClient.get(path, { responseType }).then(r => r.data)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export { apiClient }
|
||||||
|
export default api
|
||||||
35
src/lib/token.ts
Normal file
35
src/lib/token.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const ACCESS_TOKEN_KEY = 'admin_access_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
||||||
|
const ADMIN_USER_KEY = 'admin_user'
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTokens(access: string, refresh: string): void {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, access)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens(): void {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ADMIN_USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredAdminUser<T = Record<string, unknown>>(): T | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ADMIN_USER_KEY)
|
||||||
|
return raw ? (JSON.parse(raw) as T) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredAdminUser<T = Record<string, unknown>>(user: T): void {
|
||||||
|
localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user))
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { Card, Row, Col, Statistic, Table, Tag, Button, Typography, Tabs, Modal, Form, Input, InputNumber, Select, Switch, App } from 'antd'
|
import { Card, Row, Col, Statistic, Table, Tag, Button, Typography, Tabs, Modal, Form, Input, InputNumber, Select, Switch, App } from 'antd'
|
||||||
import { ReloadOutlined, CloudOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, CloudOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { aiGatewayAPI } from '@/api'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
@ -15,41 +15,41 @@ export default function AiGatewayPage() {
|
|||||||
|
|
||||||
const { data: status } = useQuery({
|
const { data: status } = useQuery({
|
||||||
queryKey: ['ai-gateway', 'status'],
|
queryKey: ['ai-gateway', 'status'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/status'),
|
queryFn: (): Promise<any> => aiGatewayAPI.status(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: routes, refetch: refetchRoutes } = useQuery({
|
const { data: routes, refetch: refetchRoutes } = useQuery({
|
||||||
queryKey: ['ai-gateway', 'routes'],
|
queryKey: ['ai-gateway', 'routes'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/routes'),
|
queryFn: (): Promise<any> => aiGatewayAPI.routes(),
|
||||||
enabled: activeTab === 'routes',
|
enabled: activeTab === 'routes',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: providers, refetch: refetchProviders } = useQuery({
|
const { data: providers, refetch: refetchProviders } = useQuery({
|
||||||
queryKey: ['ai-gateway', 'providers'],
|
queryKey: ['ai-gateway', 'providers'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/providers'),
|
queryFn: (): Promise<any> => aiGatewayAPI.providers(),
|
||||||
enabled: activeTab === 'providers',
|
enabled: activeTab === 'providers',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: fallbackEvents } = useQuery({
|
const { data: fallbackEvents } = useQuery({
|
||||||
queryKey: ['ai-gateway', 'fallback-events'],
|
queryKey: ['ai-gateway', 'fallback-events'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/ai-gateway/fallback-events'),
|
queryFn: (): Promise<any> => aiGatewayAPI.fallbackEvents(),
|
||||||
enabled: activeTab === 'fallback',
|
enabled: activeTab === 'fallback',
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createRoute = useMutation({
|
const createRoute = useMutation({
|
||||||
mutationFn: (values: any) => api.post('/admin-api/ai-gateway/routes', values),
|
mutationFn: (values: any) => aiGatewayAPI.createRoute(values),
|
||||||
onSuccess: () => { message.success('路由已创建'); refetchRoutes(); setRouteModal({ open: false }); form.resetFields() },
|
onSuccess: () => { message.success('路由已创建'); refetchRoutes(); setRouteModal({ open: false }); form.resetFields() },
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteRoute = useMutation({
|
const deleteRoute = useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/admin-api/ai-gateway/routes/${id}`),
|
mutationFn: (id: string) => aiGatewayAPI.deleteRoute(id),
|
||||||
onSuccess: () => { message.success('路由已删除'); refetchRoutes() },
|
onSuccess: () => { message.success('路由已删除'); refetchRoutes() },
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleProvider = useMutation({
|
const toggleProvider = useMutation({
|
||||||
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) => api.put(`/admin-api/ai-gateway/providers/${name}`, { enabled }),
|
mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) => aiGatewayAPI.toggleProvider(name, enabled),
|
||||||
onSuccess: () => { message.success('Provider 已更新'); refetchProviders() },
|
onSuccess: () => { message.success('Provider 已更新'); refetchProviders() },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import AuditLogTable from '@/components/AuditLogTable'
|
import AuditLogTable from '@/components/AuditLogTable'
|
||||||
import { getAuditLogs } from '@/services/admin-api'
|
import { auditAPI } from '@/api'
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@ -9,7 +9,7 @@ export default function AuditLogPage() {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['audit-logs', page, pageSize],
|
queryKey: ['audit-logs', page, pageSize],
|
||||||
queryFn: () => getAuditLogs({ page, limit: pageSize }),
|
queryFn: () => auditAPI.list({ page, limit: pageSize }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Table, Button, Tag, Space, Typography, Tabs, message } from 'antd'
|
import { Table, Button, Tag, Space, Typography, Tabs, message } from 'antd'
|
||||||
import { CloudUploadOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { CloudUploadOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { backupAPI } from '@/api'
|
||||||
import { backupStatusLabels } from '@/constants/labels'
|
import { backupStatusLabels } from '@/constants/labels'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -15,23 +15,23 @@ export default function BackupAdmin() {
|
|||||||
|
|
||||||
const { data: backups, isLoading: bLoading } = useQuery({
|
const { data: backups, isLoading: bLoading } = useQuery({
|
||||||
queryKey: ['admin', 'backup-jobs'],
|
queryKey: ['admin', 'backup-jobs'],
|
||||||
queryFn: () => api.get<any>('/admin-api/backup/jobs'),
|
queryFn: () => backupAPI.jobs(),
|
||||||
enabled: tab === 'backups',
|
enabled: tab === 'backups',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: cleanups, isLoading: cLoading } = useQuery({
|
const { data: cleanups, isLoading: cLoading } = useQuery({
|
||||||
queryKey: ['admin', 'cleanup-jobs'],
|
queryKey: ['admin', 'cleanup-jobs'],
|
||||||
queryFn: () => api.get<any>('/admin-api/backup/cleanup'),
|
queryFn: () => backupAPI.cleanups(),
|
||||||
enabled: tab === 'cleanup',
|
enabled: tab === 'cleanup',
|
||||||
})
|
})
|
||||||
|
|
||||||
const triggerBackup = useMutation({
|
const triggerBackup = useMutation({
|
||||||
mutationFn: (type: string) => api.post(`/admin-api/backup/trigger/${type}`),
|
mutationFn: (type: string) => backupAPI.triggerBackup(type),
|
||||||
onSuccess: () => { message.success('备份任务已触发'); qc.invalidateQueries({ queryKey: ['admin', 'backup-jobs'] }) },
|
onSuccess: () => { message.success('备份任务已触发'); qc.invalidateQueries({ queryKey: ['admin', 'backup-jobs'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const triggerCleanup = useMutation({
|
const triggerCleanup = useMutation({
|
||||||
mutationFn: (type: string) => api.post(`/admin-api/backup/cleanup/${type}`),
|
mutationFn: (type: string) => backupAPI.triggerCleanup(type),
|
||||||
onSuccess: () => { message.success('清理任务已触发'); qc.invalidateQueries({ queryKey: ['admin', 'cleanup-jobs'] }) },
|
onSuccess: () => { message.success('清理任务已触发'); qc.invalidateQueries({ queryKey: ['admin', 'cleanup-jobs'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App, Table, Modal, Form, Input, Select, DatePicker, InputNumber, Tabs } from 'antd'
|
import { Card, Row, Col, Statistic, Button, Tag, Space, Typography, App, Table, Modal, Form, Input, Select, DatePicker, InputNumber, Tabs } from 'antd'
|
||||||
import { DollarOutlined, ReloadOutlined, LinkOutlined, PlusOutlined, DeleteOutlined, EditOutlined, CloudOutlined, DownloadOutlined } from '@ant-design/icons'
|
import { DollarOutlined, ReloadOutlined, LinkOutlined, PlusOutlined, DeleteOutlined, EditOutlined, CloudOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||||
import { getBilling, type BillingInfo } from '@/services/billing-api'
|
import { billingAPI, costsAPI, type BillingInfo } from '@/api'
|
||||||
import { getCostSummary, createCost, updateCost, deleteCost as deleteCostApi, type CostSummary, type CostItem } from '@/services/costs-api'
|
|
||||||
import { api } from '@/services/http-client'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Text, Title } = Typography
|
const { Text, Title } = Typography
|
||||||
@ -25,21 +23,21 @@ function BillingContent() {
|
|||||||
const [editing, setEditing] = useState<CostItem | null>(null)
|
const [editing, setEditing] = useState<CostItem | null>(null)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 60_000 })
|
const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: billingAPI.get, staleTime: 60_000 })
|
||||||
const { data: costs } = useQuery({ queryKey: ['costs', 'summary'], queryFn: getCostSummary, staleTime: 30_000 })
|
const { data: costs } = useQuery({ queryKey: ['costs', 'summary'], queryFn: costsAPI.summary, staleTime: 30_000 })
|
||||||
const { data: aiReport } = useQuery({ queryKey: ['costs', 'report'], queryFn: (): Promise<any> => api.get('/admin-api/costs/report?days=30'), staleTime: 30_000 })
|
const { data: aiReport } = useQuery({ queryKey: ['costs', 'report'], queryFn: (): Promise<any> => costsAPI.report(), staleTime: 30_000 })
|
||||||
const { data: topUsers } = useQuery({ queryKey: ['costs', 'top-users'], queryFn: (): Promise<any> => api.get('/admin-api/costs/top-users?days=30&limit=10'), staleTime: 30_000 })
|
const { data: topUsers } = useQuery({ queryKey: ['costs', 'top-users'], queryFn: (): Promise<any> => costsAPI.topUsers(), staleTime: 30_000 })
|
||||||
|
|
||||||
const refresh = async () => { setRefreshing(true); await qc.invalidateQueries({ queryKey: ['billing'] }); await qc.invalidateQueries({ queryKey: ['costs'] }); setTimeout(() => setRefreshing(false), 800) }
|
const refresh = async () => { setRefreshing(true); await qc.invalidateQueries({ queryKey: ['billing'] }); await qc.invalidateQueries({ queryKey: ['costs'] }); setTimeout(() => setRefreshing(false), 800) }
|
||||||
|
|
||||||
const handleAggregate = async () => { await api.post('/admin-api/costs/aggregate'); message.success('已汇总'); qc.invalidateQueries({ queryKey: ['costs'] }) }
|
const handleAggregate = async () => { await costsAPI.aggregate(); message.success('已汇总'); qc.invalidateQueries({ queryKey: ['costs'] }) }
|
||||||
const handleExportCsv = () => { window.open('/admin-api/costs/export-csv?days=30', '_blank') }
|
const handleExportCsv = () => { window.open('/admin-api/costs/export-csv?days=30', '_blank') }
|
||||||
|
|
||||||
const saveCost = async (values: any) => {
|
const saveCost = async (values: any) => {
|
||||||
const body = { ...values, purchaseDate: values.purchaseDate?.toISOString(), expiryDate: values.expiryDate?.toISOString() }
|
const body = { ...values, purchaseDate: values.purchaseDate?.toISOString(), expiryDate: values.expiryDate?.toISOString() }
|
||||||
try {
|
try {
|
||||||
if (editing) await updateCost(editing.id, body)
|
if (editing) await costsAPI.update(editing.id, body)
|
||||||
else await createCost(body)
|
else await costsAPI.create(body)
|
||||||
message.success(editing ? '已更新' : '已添加')
|
message.success(editing ? '已更新' : '已添加')
|
||||||
setModalOpen(false); setEditing(null); form.resetFields(); qc.invalidateQueries({ queryKey: ['costs', 'summary'] })
|
setModalOpen(false); setEditing(null); form.resetFields(); qc.invalidateQueries({ queryKey: ['costs', 'summary'] })
|
||||||
} catch { message.error('操作失败') }
|
} catch { message.error('操作失败') }
|
||||||
@ -47,7 +45,7 @@ function BillingContent() {
|
|||||||
|
|
||||||
const deleteCost = (item: CostItem) => modal.confirm({
|
const deleteCost = (item: CostItem) => modal.confirm({
|
||||||
title: '删除费用', content: `确定删除「${item.name}」?`, okType: 'danger', okText: '删除',
|
title: '删除费用', content: `确定删除「${item.name}」?`, okType: 'danger', okText: '删除',
|
||||||
onOk: async () => { await deleteCostApi(item.id); qc.invalidateQueries({ queryKey: ['costs', 'summary'] }) },
|
onOk: async () => { await costsAPI.remove(item.id); qc.invalidateQueries({ queryKey: ['costs', 'summary'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const openEdit = (item: CostItem) => { setEditing(item); form.setFieldsValue({ ...item, purchaseDate: dayjs(item.purchaseDate), expiryDate: item.expiryDate ? dayjs(item.expiryDate) : null }); setModalOpen(true) }
|
const openEdit = (item: CostItem) => { setEditing(item); form.setFieldsValue({ ...item, purchaseDate: dayjs(item.purchaseDate), expiryDate: item.expiryDate ? dayjs(item.expiryDate) : null }); setModalOpen(true) }
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Card, Button, Statistic, Row, Col, Space, Typography, message } from 'antd'
|
import { Card, Button, Statistic, Row, Col, Space, Typography, message } from 'antd'
|
||||||
import { ClearOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { ClearOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { cacheAPI } from '@/api'
|
||||||
import type { CacheStats } from '@/types/api'
|
import type { CacheStats } from '@/types/api'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -11,12 +11,12 @@ export default function CacheAdmin() {
|
|||||||
|
|
||||||
const { data: stats, isLoading } = useQuery({
|
const { data: stats, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'cache-stats'],
|
queryKey: ['admin', 'cache-stats'],
|
||||||
queryFn: (): Promise<CacheStats> => api.get<CacheStats>('/admin-api/cache/stats'),
|
queryFn: (): Promise<CacheStats> => cacheAPI.stats(),
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const flushModule = useMutation({
|
const flushModule = useMutation({
|
||||||
mutationFn: (module: string) => api.post(`/admin-api/cache/flush/${module}`),
|
mutationFn: (module: string) => cacheAPI.flushModule(module),
|
||||||
onSuccess: (_, mod) => {
|
onSuccess: (_, mod) => {
|
||||||
message.success(`已清除 ${mod} 模块缓存`)
|
message.success(`已清除 ${mod} 模块缓存`)
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] })
|
qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] })
|
||||||
@ -24,7 +24,7 @@ export default function CacheAdmin() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const flushAll = useMutation({
|
const flushAll = useMutation({
|
||||||
mutationFn: () => api.post('/admin-api/cache/flush-all'),
|
mutationFn: () => cacheAPI.flushAll(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success('已清除所有缓存')
|
message.success('已清除所有缓存')
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] })
|
qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] })
|
||||||
@ -32,7 +32,7 @@ export default function CacheAdmin() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resetStats = useMutation({
|
const resetStats = useMutation({
|
||||||
mutationFn: () => api.post('/admin-api/cache/reset-stats'),
|
mutationFn: () => cacheAPI.resetStats(),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Tag, Typography, Button, Drawer } from 'antd'
|
import { Table, Tag, Typography, Button, Drawer } from 'antd'
|
||||||
import { ReloadOutlined, MessageOutlined, EyeOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, MessageOutlined, EyeOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { chatLogsAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@ -14,13 +14,13 @@ export default function ChatLogsPage() {
|
|||||||
|
|
||||||
const { data: sessions, isLoading } = useQuery({
|
const { data: sessions, isLoading } = useQuery({
|
||||||
queryKey: ['chat-logs', 'sessions'],
|
queryKey: ['chat-logs', 'sessions'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/rag-chat/sessions'),
|
queryFn: (): Promise<any> => chatLogsAPI.sessions(),
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: messages } = useQuery({
|
const { data: messages } = useQuery({
|
||||||
queryKey: ['chat-logs', 'messages', selectedSession?.id],
|
queryKey: ['chat-logs', 'messages', selectedSession?.id],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/rag-chat/sessions/${selectedSession?.id}/messages`),
|
queryFn: (): Promise<any> => chatLogsAPI.sessionMessages(selectedSession?.id || ''),
|
||||||
enabled: !!selectedSession?.id,
|
enabled: !!selectedSession?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Table, Button, Modal, Form, Input, Tag, Typography, Tabs, DatePicker } from 'antd'
|
import { Table, Button, Modal, Form, Input, Tag, Typography, Tabs, DatePicker, App } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, SafetyOutlined, CheckOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, SafetyOutlined, CheckOutlined } from '@ant-design/icons'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { complianceAPI } from '@/api'
|
||||||
import { complianceStatusLabels } from '@/constants/labels'
|
import { complianceStatusLabels } from '@/constants/labels'
|
||||||
import { message } from 'antd'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -17,47 +16,48 @@ export default function ComplianceAdmin() {
|
|||||||
const [modalType, setModalType] = useState('')
|
const [modalType, setModalType] = useState('')
|
||||||
const [editing, setEditing] = useState<any>(null)
|
const [editing, setEditing] = useState<any>(null)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
const { message } = App.useApp()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: policies, isLoading: pLoading } = useQuery({
|
const { data: policies, isLoading: pLoading } = useQuery({
|
||||||
queryKey: ['compliance', 'policies'],
|
queryKey: ['compliance', 'policies'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/compliance/privacy-policies').then(d => d ?? []),
|
queryFn: () => complianceAPI.privacyPolicies().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: agreements, isLoading: aLoading } = useQuery({
|
const { data: agreements, isLoading: aLoading } = useQuery({
|
||||||
queryKey: ['compliance', 'agreements'],
|
queryKey: ['compliance', 'agreements'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/compliance/user-agreements').then(d => d ?? []),
|
queryFn: () => complianceAPI.userAgreements().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: filings, isLoading: fLoading } = useQuery({
|
const { data: filings, isLoading: fLoading } = useQuery({
|
||||||
queryKey: ['compliance', 'filings'],
|
queryKey: ['compliance', 'filings'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/compliance/filings').then(d => d ?? []),
|
queryFn: () => complianceAPI.filings().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: deletions, isLoading: dLoading } = useQuery({
|
const { data: deletions, isLoading: dLoading } = useQuery({
|
||||||
queryKey: ['compliance', 'deletions'],
|
queryKey: ['compliance', 'deletions'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/compliance/deletion-requests').then(d => d ?? []),
|
queryFn: () => complianceAPI.deletionRequests().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: exports_, isLoading: eLoading } = useQuery({
|
const { data: exports_, isLoading: eLoading } = useQuery({
|
||||||
queryKey: ['compliance', 'exports'],
|
queryKey: ['compliance', 'exports'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/compliance/export-requests').then(d => d ?? []),
|
queryFn: () => complianceAPI.exportRequests().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: securityEvents, isLoading: sLoading } = useQuery({
|
const { data: securityEvents, isLoading: sLoading } = useQuery({
|
||||||
queryKey: ['compliance', 'security-events'],
|
queryKey: ['compliance', 'security-events'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/compliance/security-events').then(d => d ?? []),
|
queryFn: () => complianceAPI.securityEvents().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: any) => {
|
mutationFn: (values: any) => {
|
||||||
if (modalType === 'policy') return editing
|
if (modalType === 'policy') return editing
|
||||||
? api.patch(`/admin-api/compliance/privacy-policies/${editing.id}`, values)
|
? complianceAPI.updatePrivacy(editing.id, values)
|
||||||
: api.post('/admin-api/compliance/privacy-policies', values)
|
: complianceAPI.createPrivacy(values)
|
||||||
if (modalType === 'agreement') return editing
|
if (modalType === 'agreement') return editing
|
||||||
? api.patch(`/admin-api/compliance/user-agreements/${editing.id}`, values)
|
? complianceAPI.updateAgreement(editing.id, values)
|
||||||
: api.post('/admin-api/compliance/user-agreements', values)
|
: complianceAPI.createAgreement(values)
|
||||||
return api.post('/admin-api/compliance/filings', values)
|
return complianceAPI.createFiling(values)
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success('已保存')
|
message.success('已保存')
|
||||||
@ -67,7 +67,7 @@ export default function ComplianceAdmin() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const approveDeletion = useMutation({
|
const approveDeletion = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/admin-api/compliance/deletion-requests/${id}/approve`),
|
mutationFn: (id: string) => complianceAPI.approveDeletion(id),
|
||||||
onSuccess: () => { message.success('已批准'); qc.invalidateQueries({ queryKey: ['compliance', 'deletions'] }) },
|
onSuccess: () => { message.success('已批准'); qc.invalidateQueries({ queryKey: ['compliance', 'deletions'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Switch, Button, Typography, App, Modal, Input, Select, Tabs, Space, Tooltip, Alert } from 'antd'
|
import { Table, Switch, Button, Typography, App, Modal, Input, Select, Tabs, Space, Tooltip, Alert } from 'antd'
|
||||||
import { ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined, QuestionCircleOutlined } from '@ant-design/icons'
|
||||||
import { getConfig, setConfig, updateConfig, deleteConfig, toggleFlag, getChangeLog } from '@/services/config-api'
|
import { configAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text, Paragraph } = Typography
|
||||||
@ -28,19 +28,19 @@ function ConfigPage() {
|
|||||||
const [newKey, setNewKey] = useState('')
|
const [newKey, setNewKey] = useState('')
|
||||||
const [newValue, setNewValue] = useState('')
|
const [newValue, setNewValue] = useState('')
|
||||||
|
|
||||||
const { data } = useQuery({ queryKey: ['config'], queryFn: getConfig, staleTime: 30_000 })
|
const { data } = useQuery({ queryKey: ['config'], queryFn: configAPI.get, staleTime: 30_000 })
|
||||||
const { data: changelog } = useQuery({ queryKey: ['config', 'changelog'], queryFn: getChangeLog, staleTime: 10_000 })
|
const { data: changelog } = useQuery({ queryKey: ['config', 'changelog'], queryFn: configAPI.changeLog, staleTime: 10_000 })
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (editKey) { await updateConfig(editKey, newValue); message.success('已更新') }
|
if (editKey) { await configAPI.update(editKey, newValue); message.success('已更新') }
|
||||||
else { await setConfig(newKey, newValue); message.success('已添加') }
|
else { await configAPI.set(newKey, newValue); message.success('已添加') }
|
||||||
setAddOpen(false); setEditKey(''); setNewKey(''); setNewValue('')
|
setAddOpen(false); setEditKey(''); setNewKey(''); setNewValue('')
|
||||||
qc.invalidateQueries({ queryKey: ['config'] })
|
qc.invalidateQueries({ queryKey: ['config'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (key: string) => modal.confirm({
|
const handleDelete = (key: string) => modal.confirm({
|
||||||
title: '删除配置', content: `确定删除 ${key}?`, okType: 'danger',
|
title: '删除配置', content: `确定删除 ${key}?`, okType: 'danger',
|
||||||
onOk: async () => { await deleteConfig(key); qc.invalidateQueries({ queryKey: ['config'] }) },
|
onOk: async () => { await configAPI.remove(key); qc.invalidateQueries({ queryKey: ['config'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const configColumns = [
|
const configColumns = [
|
||||||
@ -68,7 +68,7 @@ function ConfigPage() {
|
|||||||
{ title: '开关名', dataIndex: 'name', width: 180 },
|
{ title: '开关名', dataIndex: 'name', width: 180 },
|
||||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||||
{ title: '状态', dataIndex: 'enabled', width: 80, render: (v: boolean, r: any) => (
|
{ title: '状态', dataIndex: 'enabled', width: 80, render: (v: boolean, r: any) => (
|
||||||
<Switch checked={v} onChange={(checked) => { toggleFlag(r.name, checked); message.success('已切换'); qc.invalidateQueries({ queryKey: ['config'] }) }} />
|
<Switch checked={v} onChange={(checked) => { configAPI.toggleFlag(r.name, checked); message.success('已切换'); qc.invalidateQueries({ queryKey: ['config'] }) }} />
|
||||||
)},
|
)},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Space, Tabs } from 'antd'
|
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Space, Tabs } from 'antd'
|
||||||
import { ReloadOutlined, PlusOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, PlusOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { contentSafetyAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@ -15,27 +15,27 @@ function CSPage() {
|
|||||||
const [category, setCategory] = useState('general')
|
const [category, setCategory] = useState('general')
|
||||||
const [risk, setRisk] = useState('medium')
|
const [risk, setRisk] = useState('medium')
|
||||||
|
|
||||||
const { data: words } = useQuery({ queryKey: ['safety', 'words'], queryFn: (): Promise<any> => api.get('/admin-api/content-safety/words') })
|
const { data: words } = useQuery({ queryKey: ['safety', 'words'], queryFn: (): Promise<any> => contentSafetyAPI.words() })
|
||||||
const { data: checks } = useQuery({ queryKey: ['safety', 'checks'], queryFn: (): Promise<any> => api.get('/admin-api/content-safety/checks') })
|
const { data: checks } = useQuery({ queryKey: ['safety', 'checks'], queryFn: (): Promise<any> => contentSafetyAPI.checks() })
|
||||||
const { data: reports } = useQuery({ queryKey: ['safety', 'reports'], queryFn: (): Promise<any> => api.get('/admin-api/content-safety/reports') })
|
const { data: reports } = useQuery({ queryKey: ['safety', 'reports'], queryFn: (): Promise<any> => contentSafetyAPI.reports() })
|
||||||
const { data: violations } = useQuery({ queryKey: ['safety', 'violations'], queryFn: (): Promise<any> => api.get('/admin-api/content-safety/violations') })
|
const { data: violations } = useQuery({ queryKey: ['safety', 'violations'], queryFn: (): Promise<any> => contentSafetyAPI.violations() })
|
||||||
|
|
||||||
const addWord = async () => {
|
const addWord = async () => {
|
||||||
await api.post('/admin-api/content-safety/words', { word: newWord, category, riskLevel: risk })
|
await contentSafetyAPI.addWord({ word: newWord, category, riskLevel: risk })
|
||||||
message.success('已添加'); setAddOpen(false); setNewWord('')
|
message.success('已添加'); setAddOpen(false); setNewWord('')
|
||||||
qc.invalidateQueries({ queryKey: ['safety'] })
|
qc.invalidateQueries({ queryKey: ['safety'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeWord = (id: string) => modal.confirm({
|
const removeWord = (id: string) => modal.confirm({
|
||||||
title: '删除敏感词', okType: 'danger',
|
title: '删除敏感词', okType: 'danger',
|
||||||
onOk: async () => { await api.delete(`/admin-api/content-safety/words/${id}`); qc.invalidateQueries({ queryKey: ['safety'] }) },
|
onOk: async () => { await contentSafetyAPI.deleteWord(id); qc.invalidateQueries({ queryKey: ['safety'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleReport = (id: string, action: string) => modal.confirm({
|
const handleReport = (id: string, action: string) => modal.confirm({
|
||||||
title: action === 'confirmed' ? '确认违规' : '驳回举报',
|
title: action === 'confirmed' ? '确认违规' : '驳回举报',
|
||||||
content: action === 'confirmed' ? '确认后将创建违规记录' : '驳回后将关闭此举报',
|
content: action === 'confirmed' ? '确认后将创建违规记录' : '驳回后将关闭此举报',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await api.post(`/admin-api/content-safety/reports/${id}/handle`, { action, note: `Admin ${action}` })
|
await contentSafetyAPI.handleReport(id, { action, note: `Admin ${action}` })
|
||||||
message.success('已处理'); qc.invalidateQueries({ queryKey: ['safety'] })
|
message.success('已处理'); qc.invalidateQueries({ queryKey: ['safety'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -44,7 +44,7 @@ function CSPage() {
|
|||||||
title: '应用处罚',
|
title: '应用处罚',
|
||||||
content: `确认对违规记录应用「${penalty}」处罚?`,
|
content: `确认对违规记录应用「${penalty}」处罚?`,
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await api.post(`/admin-api/content-safety/violations/${id}/penalty`, { penalty })
|
await contentSafetyAPI.penaltyViolation(id, { penalty })
|
||||||
message.success('处罚已应用'); qc.invalidateQueries({ queryKey: ['safety'] })
|
message.success('处罚已应用'); qc.invalidateQueries({ queryKey: ['safety'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -12,8 +12,7 @@ import MetricCard from '@/components/MetricCard'
|
|||||||
import { Progress, Space } from 'antd'
|
import { Progress, Space } from 'antd'
|
||||||
const DText = Typography.Text
|
const DText = Typography.Text
|
||||||
import EChartsChartContainer from '@/components/EChartsChartContainer'
|
import EChartsChartContainer from '@/components/EChartsChartContainer'
|
||||||
import { getDashboardStats } from '@/services/admin-api'
|
import { dashboardAPI, serversAPI } from '@/api'
|
||||||
import { getServerMetrics } from '@/services/server-api'
|
|
||||||
|
|
||||||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, TitleComponent, LegendComponent, CanvasRenderer])
|
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, TitleComponent, LegendComponent, CanvasRenderer])
|
||||||
|
|
||||||
@ -26,13 +25,13 @@ function formatStorage(bytes: number): string {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
queryKey: ['dashboard', 'stats'],
|
queryKey: ['dashboard', 'stats'],
|
||||||
queryFn: getDashboardStats,
|
queryFn: dashboardAPI.getStats,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: serverData } = useQuery({
|
const { data: serverData } = useQuery({
|
||||||
queryKey: ['servers', 'metrics'],
|
queryKey: ['servers', 'metrics'],
|
||||||
queryFn: getServerMetrics,
|
queryFn: serversAPI.metrics,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Badge, Card, Row, Col, Statistic, Space, Tabs, Popconfirm } from 'antd'
|
import { Table, Button, Typography, App, Badge, Card, Row, Col, Statistic, Space, Tabs, Popconfirm } from 'antd'
|
||||||
import { ReloadOutlined, RetweetOutlined, CloudServerOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, RetweetOutlined, CloudServerOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { eventsQueueAPI } from '@/api'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
@ -14,37 +14,37 @@ function EventsPage() {
|
|||||||
|
|
||||||
const { data: overview } = useQuery({
|
const { data: overview } = useQuery({
|
||||||
queryKey: ['events', 'overview'],
|
queryKey: ['events', 'overview'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/events'),
|
queryFn: (): Promise<any> => eventsQueueAPI.overview(),
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: stats } = useQuery({
|
const { data: stats } = useQuery({
|
||||||
queryKey: ['events', 'stats'],
|
queryKey: ['events', 'stats'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/events/stats'),
|
queryFn: (): Promise<any> => eventsQueueAPI.stats(),
|
||||||
enabled: activeTab === 'stats',
|
enabled: activeTab === 'stats',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: workers } = useQuery({
|
const { data: workers } = useQuery({
|
||||||
queryKey: ['events', 'workers'],
|
queryKey: ['events', 'workers'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/events/workers'),
|
queryFn: (): Promise<any> => eventsQueueAPI.workers(),
|
||||||
enabled: activeTab === 'stats',
|
enabled: activeTab === 'stats',
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: failed } = useQuery({
|
const { data: failed } = useQuery({
|
||||||
queryKey: ['events', 'failed', selectedQueue],
|
queryKey: ['events', 'failed', selectedQueue],
|
||||||
queryFn: (): Promise<any> => selectedQueue ? api.get(`/admin-api/events/${selectedQueue}/failed`) : Promise.resolve(null),
|
queryFn: (): Promise<any> => selectedQueue ? eventsQueueAPI.failedJobs(selectedQueue) : Promise.resolve(null),
|
||||||
enabled: !!selectedQueue,
|
enabled: !!selectedQueue,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleRetry = async (queue: string, jobId: string) => {
|
const handleRetry = async (queue: string, jobId: string) => {
|
||||||
await api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`)
|
await eventsQueueAPI.retry(queue, jobId)
|
||||||
message.success('已重试')
|
message.success('已重试')
|
||||||
qc.invalidateQueries({ queryKey: ['events'] })
|
qc.invalidateQueries({ queryKey: ['events'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBatchRetry = async (queue: string) => {
|
const handleBatchRetry = async (queue: string) => {
|
||||||
const res: any = await api.post(`/admin-api/events/${queue}/jobs/batch-retry`, { count: 50 })
|
const res: any = await eventsQueueAPI.batchRetry(queue)
|
||||||
message.success(`批量重试完成: ${res?.data?.retried || 0}/${res?.data?.total || 0}`)
|
message.success(`批量重试完成: ${res?.data?.retried || 0}/${res?.data?.total || 0}`)
|
||||||
qc.invalidateQueries({ queryKey: ['events'] })
|
qc.invalidateQueries({ queryKey: ['events'] })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Tag } from 'antd'
|
import { Table, Button, Typography, App, Tag } from 'antd'
|
||||||
import { ReloadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { filesAdminAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const mimeLabels: Record<string, string> = {
|
const mimeLabels: Record<string, string> = {
|
||||||
@ -26,12 +26,12 @@ export default function FilesAdminPage() {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin-files', page],
|
queryKey: ['admin-files', page],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/files?page=${page}&limit=20`),
|
queryFn: () => filesAdminAPI.list(page),
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = (file: any) => modal.confirm({
|
const handleDelete = (file: any) => modal.confirm({
|
||||||
title: '删除文件', content: `确定删除 ${file.filename}?`, okType: 'danger',
|
title: '删除文件', content: `确定删除 ${file.filename}?`, okType: 'danger',
|
||||||
onOk: async () => { await api.delete(`/admin-api/files/${file.id}`); message.success('已删除'); qc.invalidateQueries({ queryKey: ['admin-files'] }) },
|
onOk: async () => { await filesAdminAPI.delete(file.id); message.success('已删除'); qc.invalidateQueries({ queryKey: ['admin-files'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const cols = [
|
const cols = [
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { Tabs, Table, Tag, Button, Space, Typography } from 'antd'
|
import { Tabs, Table, Tag, Button, Space, Typography, App } from 'antd'
|
||||||
import { CheckOutlined, CloseOutlined, RobotOutlined } from '@ant-design/icons'
|
import { CheckOutlined, CloseOutlined, RobotOutlined } from '@ant-design/icons'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { hermesAPI } from '@/api'
|
||||||
import { agentTaskStatusLabels, artifactStatusLabels } from '@/constants/labels'
|
import { agentTaskStatusLabels, artifactStatusLabels } from '@/constants/labels'
|
||||||
import { message } from 'antd'
|
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
@ -11,25 +10,26 @@ const statusColors: Record<string, string> = { pending: 'blue', approved: 'green
|
|||||||
const artifactTypeLabels: Record<string, string> = { issue_draft: 'Issue 草稿', milestone_summary: '里程碑总结', release_notes: '发布说明', decision_record: '决策记录' }
|
const artifactTypeLabels: Record<string, string> = { issue_draft: 'Issue 草稿', milestone_summary: '里程碑总结', release_notes: '发布说明', decision_record: '决策记录' }
|
||||||
|
|
||||||
export default function HermesSettings() {
|
export default function HermesSettings() {
|
||||||
|
const { message } = App.useApp()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: tasks, isLoading: tLoading } = useQuery({
|
const { data: tasks, isLoading: tLoading } = useQuery({
|
||||||
queryKey: ['hermes', 'tasks'],
|
queryKey: ['hermes', 'tasks'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/hermes/tasks').then(d => d ?? []),
|
queryFn: () => hermesAPI.tasks().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: artifacts, isLoading: aLoading } = useQuery({
|
const { data: artifacts, isLoading: aLoading } = useQuery({
|
||||||
queryKey: ['hermes', 'artifacts'],
|
queryKey: ['hermes', 'artifacts'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/hermes/artifacts').then(d => d ?? []),
|
queryFn: () => hermesAPI.artifacts().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const approveTask = useMutation({
|
const approveTask = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/admin-api/hermes/tasks/${id}/approve`),
|
mutationFn: (id: string) => hermesAPI.approve(id),
|
||||||
onSuccess: () => { message.success('已通过'); qc.invalidateQueries({ queryKey: ['hermes', 'tasks'] }) },
|
onSuccess: () => { message.success('已通过'); qc.invalidateQueries({ queryKey: ['hermes', 'tasks'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const rejectTask = useMutation({
|
const rejectTask = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/admin-api/hermes/tasks/${id}/reject`),
|
mutationFn: (id: string) => hermesAPI.reject(id),
|
||||||
onSuccess: () => { message.success('已驳回'); qc.invalidateQueries({ queryKey: ['hermes', 'tasks'] }) },
|
onSuccess: () => { message.success('已驳回'); qc.invalidateQueries({ queryKey: ['hermes', 'tasks'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,76 +1,271 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useCallback, useRef } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Tag, Typography, Button, App, Drawer, Space } from 'antd'
|
import { Table, Tag, Typography, Button, App, Drawer, Space, Select, Popconfirm, Row, Col, Checkbox } from 'antd'
|
||||||
import { ReloadOutlined, RetweetOutlined, ImportOutlined, EyeOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, RetweetOutlined, ImportOutlined, EyeOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { importsAdminAPI, knowledgeAPI } from '@/api'
|
||||||
import { importStatusLabels, importStepLabels } from '@/constants/labels'
|
import { importStatusLabels, importStepLabels } from '@/constants/labels'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
// 格式化知识库选项:用户 - 名称 (id前8位)
|
||||||
|
function formatKbLabel(kb: any) {
|
||||||
|
const user = kb.user?.email || kb.user?.nickname || kb.userId || '?'
|
||||||
|
const name = kb.title || '未命名'
|
||||||
|
const shortId = kb.id?.slice(0, 8) || kb.id
|
||||||
|
return `${user} - ${name} (${shortId})`
|
||||||
|
}
|
||||||
|
|
||||||
export default function ImportMonitorPage() {
|
export default function ImportMonitorPage() {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const [selectedJob, setSelectedJob] = useState<any>(null)
|
const [selectedJob, setSelectedJob] = useState<any>(null)
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||||
|
const [kbFilter, setKbFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [retryAllOpen, setRetryAllOpen] = useState(false)
|
||||||
|
const [showDeleted, setShowDeleted] = useState(false)
|
||||||
|
const [sortBy, setSortBy] = useState<string>('createdAt')
|
||||||
|
const [sortOrder, setSortOrder] = useState<string>('descend')
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
// 知识库远程搜索(防抖)
|
||||||
queryKey: ['admin-imports'],
|
const [kbSearch, setKbSearch] = useState('')
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/imports?limit=50'),
|
const kbSearchTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||||
staleTime: 10_000,
|
|
||||||
|
const handleKbSearch = useCallback((val: string) => {
|
||||||
|
clearTimeout(kbSearchTimer.current)
|
||||||
|
kbSearchTimer.current = setTimeout(() => setKbSearch(val), 300)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 知识库列表
|
||||||
|
const { data: kbData, isLoading: kbLoading } = useQuery({
|
||||||
|
queryKey: ['admin-knowledge-bases', kbSearch],
|
||||||
|
queryFn: (): Promise<any> => {
|
||||||
|
const params = new URLSearchParams({ limit: '50' })
|
||||||
|
if (kbSearch) params.set('search', kbSearch)
|
||||||
|
return knowledgeAPI.search(params.toString())
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
queryParams.set('limit', '200')
|
||||||
|
if (kbFilter) queryParams.set('kbId', kbFilter)
|
||||||
|
queryParams.set('sortBy', sortBy)
|
||||||
|
queryParams.set('sortOrder', sortOrder)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-imports', 'list', kbFilter, sortBy, sortOrder],
|
||||||
|
queryFn: (): Promise<any> => importsAdminAPI.list(queryParams.toString()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 默认过滤已删除 source 的记录
|
||||||
|
const tableData = showDeleted
|
||||||
|
? (data?.items || [])
|
||||||
|
: (data?.items || []).filter((r: any) => !r.sourceDeleted)
|
||||||
|
|
||||||
const { data: detail } = useQuery({
|
const { data: detail } = useQuery({
|
||||||
queryKey: ['admin-imports', selectedJob?.id],
|
queryKey: ['admin-import-detail', selectedJob?.id],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/imports/${selectedJob?.id}`),
|
queryFn: (): Promise<any> => importsAdminAPI.detail(selectedJob?.id || ''),
|
||||||
enabled: !!selectedJob?.id,
|
enabled: !!selectedJob?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleRetry = async (id: string) => {
|
const refreshList = () => {
|
||||||
await api.post(`/admin-api/imports/${id}/retry`)
|
qc.invalidateQueries({ queryKey: ['admin-imports', 'list'] })
|
||||||
message.success('已重新入队')
|
}
|
||||||
qc.invalidateQueries({ queryKey: ['admin-imports'] })
|
|
||||||
|
const handleRetry = async (id: string, sourceDeleted?: boolean) => {
|
||||||
|
if (sourceDeleted) {
|
||||||
|
message.warning('关联的资料来源已删除,无法重新解析。请删除此导入记录后重新上传文件。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await importsAdminAPI.retry(id)
|
||||||
|
message.success('已重新入队,等待 Worker 处理')
|
||||||
|
refreshList()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || '重试失败'
|
||||||
|
message.error(`重试失败: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchRetry = async () => {
|
||||||
|
try {
|
||||||
|
const ids = selectedRowKeys as string[]
|
||||||
|
const res = await importsAdminAPI.batchRetry(ids as string[])
|
||||||
|
const { updated, skipped, skippedCount } = res
|
||||||
|
let msg = `已重新入队 ${updated} 个任务`
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
msg += `,${skippedCount} 个跳过(源已删除: ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '...' : ''})`
|
||||||
|
}
|
||||||
|
message.success(msg)
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
refreshList()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || '批量重试失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetryAll = async () => {
|
||||||
|
try {
|
||||||
|
const allItems = data?.items || []
|
||||||
|
const retryableIds = allItems
|
||||||
|
.filter((r: any) => r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED')
|
||||||
|
.map((r: any) => r.id)
|
||||||
|
if (retryableIds.length === 0) {
|
||||||
|
message.warning('没有可重新解析的任务')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await importsAdminAPI.batchRetry(retryableIds)
|
||||||
|
const { updated, skipped, skippedCount } = res
|
||||||
|
let msg = `已重新入队 ${updated} 个任务`
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
msg += `,${skippedCount} 个跳过(源已删除: ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '...' : ''})`
|
||||||
|
}
|
||||||
|
message.success(msg)
|
||||||
|
setSelectedRowKeys([])
|
||||||
|
setRetryAllOpen(false)
|
||||||
|
refreshList()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || err?.message || '全部重试失败'
|
||||||
|
message.error(msg)
|
||||||
|
setRetryAllOpen(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
QUEUED: 'blue', processing: 'processing', completed: 'green', failed: 'red',
|
QUEUED: 'blue',
|
||||||
|
CLAIMED: 'cyan',
|
||||||
|
DOWNLOADING: 'processing',
|
||||||
|
PARSING: 'processing',
|
||||||
|
OCR_PROCESSING: 'processing',
|
||||||
|
VISION_PROCESSING: 'processing',
|
||||||
|
CLEANING: 'processing',
|
||||||
|
CHUNKING: 'processing',
|
||||||
|
EMBEDDING: 'processing',
|
||||||
|
INDEXING: 'processing',
|
||||||
|
GENERATING_CANDIDATES: 'processing',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
FAILED: 'red',
|
||||||
|
FAILED_RETRYABLE: 'orange',
|
||||||
|
FAILED_FINAL: 'red',
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '名称', dataIndex: 'sourceName', width: 180, ellipsis: true },
|
{ title: '名称', dataIndex: 'sourceName', width: 180, ellipsis: true, sorter: true, sortOrder: sortBy === 'sourceName' ? (sortOrder as any) : undefined,
|
||||||
{ title: '类型', dataIndex: 'sourceType', width: 80, render: (s: string) => (s === 'file' ? '文件' : s === 'url' ? '链接' : s === 'text' ? '文本' : s) },
|
render: (v: string, r: any) => (
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={statusColor[s] || 'default'}>{importStatusLabels[s] || s}</Tag> },
|
<Space size={4}>
|
||||||
{ title: '进度', dataIndex: 'progress', width: 70, render: (v: number) => `${v || 0}%` },
|
<Text ellipsis style={{ maxWidth: 140 }}>{v}</Text>
|
||||||
{ title: '重试', dataIndex: 'retryCount', width: 60, align: 'center' as const },
|
{r.sourceDeleted && <Tag color="error" style={{ fontSize: 10 }}>源已删</Tag>}
|
||||||
{ title: '步骤', dataIndex: 'step', width: 100, ellipsis: true },
|
</Space>
|
||||||
{ title: '错误', dataIndex: 'errorMessage', width: 150, ellipsis: true, render: (v: string) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : '-' },
|
),
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 130, render: (d: string) => dayjs(d).format('MM-DD HH:mm') },
|
},
|
||||||
|
{ title: '类型', dataIndex: 'sourceType', width: 80, sorter: true, sortOrder: sortBy === 'sourceType' ? (sortOrder as any) : undefined, render: (s: string) => (s === 'file' ? '文件' : s === 'url' ? '链接' : s === 'text' ? '文本' : s) },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 100, sorter: true, sortOrder: sortBy === 'status' ? (sortOrder as any) : undefined, render: (s: string) => <Tag color={statusColor[s] || 'default'}>{importStatusLabels[s] || s}</Tag> },
|
||||||
|
{ title: '进度', dataIndex: 'progress', width: 70, sorter: true, sortOrder: sortBy === 'progress' ? (sortOrder as any) : undefined, render: (v: number) => `${v || 0}%` },
|
||||||
|
{ title: '重试', dataIndex: 'retryCount', width: 60, align: 'center' as const, sorter: true, sortOrder: sortBy === 'retryCount' ? (sortOrder as any) : undefined },
|
||||||
|
{ title: '错误', dataIndex: 'errorMessage', width: 180, ellipsis: true, render: (v: string) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : '-' },
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 130, sorter: true, sortOrder: sortBy === 'createdAt' ? (sortOrder as any) : undefined, render: (d: string) => dayjs(d).format('MM-DD HH:mm') },
|
||||||
{
|
{
|
||||||
title: '操作', width: 100,
|
title: '操作', width: 100,
|
||||||
render: (_: any, r: any) => (
|
render: (_: any, r: any) => (
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => { setSelectedJob(r); setDrawerOpen(true) }}>详情</Button>
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => { setSelectedJob(r); setDrawerOpen(true) }}>详情</Button>
|
||||||
{r.status === 'FAILED' && <Button type="link" size="small" icon={<RetweetOutlined />} onClick={() => handleRetry(r.id)}>重试</Button>}
|
{(r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED') && <Button type="link" size="small" icon={<RetweetOutlined />} onClick={() => handleRetry(r.id, r.sourceDeleted)}>重新解析</Button>}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const stepCols = [
|
const stepCols = [
|
||||||
{ title: '步骤', dataIndex: 'step', width: 120 },
|
{ title: '步骤', dataIndex: 'step', width: 120, render: (s: string) => importStepLabels[s] || importStatusLabels[s] || s },
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => <Tag color={s === 'completed' ? 'green' : s === 'failed' ? 'red' : 'blue'}>{importStepLabels[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => <Tag color={s === 'COMPLETED' || s === 'completed' ? 'green' : s?.includes('FAILED') || s === 'failed' ? 'red' : 'blue'}>{importStepLabels[s] || importStatusLabels[s] || s}</Tag> },
|
||||||
{ title: '详情', dataIndex: 'detail', ellipsis: true },
|
{ title: '详情', dataIndex: 'detail', ellipsis: true },
|
||||||
{ title: '开始', dataIndex: 'startedAt', width: 130, render: (d: string) => d ? dayjs(d).format('HH:mm:ss') : '-' },
|
{ title: '开始', dataIndex: 'startedAt', width: 130, render: (d: string) => d ? dayjs(d).format('HH:mm:ss') : '-' },
|
||||||
{ title: '完成', dataIndex: 'completedAt', width: 130, render: (d: string) => d ? dayjs(d).format('HH:mm:ss') : '-' },
|
{ title: '完成', dataIndex: 'completedAt', width: 130, render: (d: string) => d ? dayjs(d).format('HH:mm:ss') : '-' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const kbOptions = (kbData?.items || []).map((kb: any) => ({
|
||||||
|
value: kb.id,
|
||||||
|
label: formatKbLabel(kb),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Title level={5} style={{ margin: 0 }}><ImportOutlined /> 文档导入</Title>
|
<Title level={5} style={{ margin: 0 }}><ImportOutlined /> 文档导入</Title>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-imports'] })}>刷新</Button>
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-imports', 'list'] })}>刷新</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Table dataSource={data?.items || []} columns={columns} rowKey="id" loading={isLoading} pagination={{ pageSize: 20 }} size="small" />
|
|
||||||
|
<Row style={{ marginBottom: 12 }} gutter={12}>
|
||||||
|
<Col>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
filterOption={false}
|
||||||
|
placeholder="搜索知识库(名称/ID/用户)"
|
||||||
|
style={{ width: 320 }}
|
||||||
|
value={kbFilter}
|
||||||
|
loading={kbLoading}
|
||||||
|
onChange={(val) => { setKbFilter(val); setSelectedRowKeys([]) }}
|
||||||
|
onSearch={handleKbSearch}
|
||||||
|
options={kbOptions}
|
||||||
|
notFoundContent={kbLoading ? '搜索中...' : '无匹配知识库'}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Checkbox checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)}>
|
||||||
|
显示已删除源
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Button
|
||||||
|
disabled={selectedRowKeys.length === 0}
|
||||||
|
icon={<RetweetOutlined />}
|
||||||
|
onClick={handleBatchRetry}
|
||||||
|
>
|
||||||
|
批量重新解析 ({selectedRowKeys.length})
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定重新解析当前筛选的所有任务?"
|
||||||
|
description="将把所有 FAILED / QUEUED / CLAIMED 任务重新入队"
|
||||||
|
open={retryAllOpen}
|
||||||
|
onOpenChange={setRetryAllOpen}
|
||||||
|
onConfirm={handleRetryAll}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button icon={<RetweetOutlined />}>全部重新解析</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={tableData}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={{ pageSize: 30 }}
|
||||||
|
size="small"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys),
|
||||||
|
}}
|
||||||
|
onChange={(_pagination, _filters, sorter: any) => {
|
||||||
|
if (sorter.order) {
|
||||||
|
setSortBy(sorter.field || 'createdAt')
|
||||||
|
setSortOrder(sorter.order)
|
||||||
|
} else {
|
||||||
|
setSortBy('createdAt')
|
||||||
|
setSortOrder('descend')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Drawer title={selectedJob?.sourceName || '导入详情'} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={700}>
|
<Drawer title={selectedJob?.sourceName || '导入详情'} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={700}>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Tag, Typography, Button, App, Drawer, Space, Descriptions } from 'antd'
|
import { Table, Tag, Typography, Button, App, Drawer, Space, Descriptions } from 'antd'
|
||||||
import { ReloadOutlined, DeleteOutlined, EyeOutlined, FileTextOutlined, LinkOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, DeleteOutlined, EyeOutlined, FileTextOutlined, LinkOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { knowledgeAPI } from '@/api'
|
||||||
import { kbStatusLabels } from '@/constants/labels'
|
import { kbStatusLabels } from '@/constants/labels'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@ -18,25 +18,25 @@ function KBPage() {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['knowledge-bases', page, pageSize],
|
queryKey: ['knowledge-bases', page, pageSize],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/knowledge-bases?page=${page}&limit=${pageSize}`),
|
queryFn: () => knowledgeAPI.list(page, pageSize),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: sources } = useQuery({
|
const { data: sources } = useQuery({
|
||||||
queryKey: ['kb-sources', selectedKb?.id],
|
queryKey: ['kb-sources', selectedKb?.id],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/knowledge-bases/${selectedKb?.id}/sources`),
|
queryFn: (): Promise<any> => knowledgeAPI.sources(selectedKb?.id || ''),
|
||||||
enabled: !!selectedKb?.id,
|
enabled: !!selectedKb?.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [refSourceId, setRefSourceId] = useState<string | null>(null)
|
const [refSourceId, setRefSourceId] = useState<string | null>(null)
|
||||||
const { data: references } = useQuery({
|
const { data: references } = useQuery({
|
||||||
queryKey: ['source-refs', refSourceId],
|
queryKey: ['source-refs', refSourceId],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/knowledge-bases/sources/${refSourceId}/references`),
|
queryFn: (): Promise<any> => knowledgeAPI.sourceReferences(refSourceId),
|
||||||
enabled: !!refSourceId,
|
enabled: !!refSourceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = (kb: any) => modal.confirm({
|
const handleDelete = (kb: any) => modal.confirm({
|
||||||
title: '删除知识库', content: `确定删除「${kb.title}」?`, okType: 'danger',
|
title: '删除知识库', content: `确定删除「${kb.title}」?`, okType: 'danger',
|
||||||
onOk: async () => { await api.delete(`/admin-api/knowledge-bases/${kb.id}`); qc.invalidateQueries({ queryKey: ['knowledge-bases'] }); message.success('已删除') },
|
onOk: async () => { await knowledgeAPI.remove(kb.id); qc.invalidateQueries({ queryKey: ['knowledge-bases'] }); message.success('已删除') },
|
||||||
})
|
})
|
||||||
|
|
||||||
const sourceCols = [
|
const sourceCols = [
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Tag, Typography, Button, App, Tabs, Input, Card, Space } from 'antd'
|
import { Table, Tag, Typography, Button, App, Tabs, Input, Card, Space } from 'antd'
|
||||||
import { ReloadOutlined, SearchOutlined, ExperimentOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, SearchOutlined, ExperimentOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { knowledgeAPI, vectorAPI } from '@/api'
|
||||||
import { candidateStatusLabels } from '@/constants/labels'
|
import { candidateStatusLabels } from '@/constants/labels'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@ -16,18 +16,18 @@ export default function KnowledgeOpsPage() {
|
|||||||
|
|
||||||
const { data: candidates } = useQuery({
|
const { data: candidates } = useQuery({
|
||||||
queryKey: ['ops', 'candidates'],
|
queryKey: ['ops', 'candidates'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/knowledge-bases/candidates?limit=50'),
|
queryFn: (): Promise<any> => knowledgeAPI.candidates(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: chunks } = useQuery({
|
const { data: chunks } = useQuery({
|
||||||
queryKey: ['ops', 'chunks', sourceId],
|
queryKey: ['ops', 'chunks', sourceId],
|
||||||
queryFn: (): Promise<any> => api.get(`/admin-api/knowledge-bases/chunks?sourceId=${sourceId}&limit=50`),
|
queryFn: (): Promise<any> => knowledgeAPI.chunks(sourceId),
|
||||||
enabled: !!sourceId,
|
enabled: !!sourceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDebugSearch = async () => {
|
const handleDebugSearch = async () => {
|
||||||
if (!debugQuery) return
|
if (!debugQuery) return
|
||||||
const res: any = await api.post('/admin-api/vector/debug-search', { query: debugQuery })
|
const res: any = await vectorAPI.debugSearch({ query: debugQuery })
|
||||||
message.info(JSON.stringify(res.data))
|
message.info(JSON.stringify(res.data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Table, Tag, Space, Input, Select, Typography } from 'antd'
|
import { Table, Tag, Space, Input, Select, Typography } from 'antd'
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { knowledgeAPI } from '@/api'
|
||||||
import { parseStatusLabels } from '@/constants/labels'
|
import { parseStatusLabels } from '@/constants/labels'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@ -77,14 +77,14 @@ export default function KnowledgeSourcesPage() {
|
|||||||
if (kbFilter) p.set('knowledgeBaseId', kbFilter)
|
if (kbFilter) p.set('knowledgeBaseId', kbFilter)
|
||||||
if (statusFilter) p.set('parseStatus', statusFilter)
|
if (statusFilter) p.set('parseStatus', statusFilter)
|
||||||
if (search) p.set('search', search)
|
if (search) p.set('search', search)
|
||||||
return api.get<PaginatedSources>(`/admin-api/knowledge-bases/all-sources?${p.toString()}`)
|
return knowledgeAPI.allSources(p.toString())
|
||||||
},
|
},
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: kbList } = useQuery({
|
const { data: kbList } = useQuery({
|
||||||
queryKey: ['admin', 'kb-list'],
|
queryKey: ['admin', 'kb-list'],
|
||||||
queryFn: () => api.get<PaginatedKnowledgeBases>('/admin-api/knowledge-bases?limit=200').then(d => d.items),
|
queryFn: () => knowledgeAPI.list(1, 200).then(d => d.items),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Table, Tabs, Tag, Space, Input, Typography } from 'antd'
|
import { Table, Tabs, Tag, Space, Input, Typography } from 'antd'
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { learningAPI } from '@/api'
|
||||||
import { sessionStatusLabels, sessionModeLabels } from '@/constants/labels'
|
import { sessionStatusLabels, sessionModeLabels } from '@/constants/labels'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -18,7 +18,7 @@ export default function LearningData() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const p = new URLSearchParams()
|
const p = new URLSearchParams()
|
||||||
if (search) p.set('userId', search)
|
if (search) p.set('userId', search)
|
||||||
return api.get<any>(`/admin-api/learning/sessions?${p.toString()}`)
|
return learningAPI.getSessionsRaw(p.toString())
|
||||||
},
|
},
|
||||||
enabled: tab === 'sessions',
|
enabled: tab === 'sessions',
|
||||||
})
|
})
|
||||||
@ -28,7 +28,7 @@ export default function LearningData() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const p = new URLSearchParams()
|
const p = new URLSearchParams()
|
||||||
if (search) p.set('userId', search)
|
if (search) p.set('userId', search)
|
||||||
return api.get<any>(`/admin-api/learning/analysis?${p.toString()}`)
|
return learningAPI.getAnalysis(p.toString())
|
||||||
},
|
},
|
||||||
enabled: tab === 'analysis',
|
enabled: tab === 'analysis',
|
||||||
})
|
})
|
||||||
@ -38,7 +38,7 @@ export default function LearningData() {
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const p = new URLSearchParams()
|
const p = new URLSearchParams()
|
||||||
if (search) p.set('userId', search)
|
if (search) p.set('userId', search)
|
||||||
return api.get<any>(`/admin-api/learning/ai-usage?${p.toString()}`)
|
return learningAPI.getAiUsage(p.toString())
|
||||||
},
|
},
|
||||||
enabled: tab === 'ai-usage',
|
enabled: tab === 'ai-usage',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Tabs, Tag, Space, Modal, Form, Input, Select, DatePicker } from 'antd'
|
import { Table, Button, Typography, App, Tabs, Tag, Space, Modal, Form, Input, Select, DatePicker } from 'antd'
|
||||||
import { ReloadOutlined, UserOutlined, PlusOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, UserOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { membersAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
@ -13,12 +13,12 @@ export default function MemberManagement() {
|
|||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
const { data: usersData } = useQuery({ queryKey: ['users', 'list'], queryFn: (): Promise<any> => api.get('/admin-api/users') })
|
const { data: usersData } = useQuery({ queryKey: ['users', 'list'], queryFn: (): Promise<any> => membersAPI.list() })
|
||||||
const { data: memberships } = useQuery({ queryKey: ['users', 'memberships'], queryFn: (): Promise<any> => api.get('/admin-api/users/memberships') })
|
const { data: memberships } = useQuery({ queryKey: ['users', 'memberships'], queryFn: (): Promise<any> => membersAPI.memberships() })
|
||||||
const { data: deletions } = useQuery({ queryKey: ['users', 'deletions'], queryFn: (): Promise<any> => api.get('/admin-api/users/deletion-requests') })
|
const { data: deletions } = useQuery({ queryKey: ['users', 'deletions'], queryFn: (): Promise<any> => membersAPI.deletionRequests() })
|
||||||
|
|
||||||
const assignMembership = async (v: any) => {
|
const assignMembership = async (v: any) => {
|
||||||
await api.post('/admin-api/users/memberships', { userId: v.userId, planId: v.planId, expiresAt: v.expiresAt?.toISOString() })
|
await membersAPI.updateMembership({ userId: v.userId, planId: v.planId, expiresAt: v.expiresAt?.toISOString() })
|
||||||
message.success('已分配'); setAddOpen(false); form.resetFields(); qc.invalidateQueries({ queryKey: ['users'] })
|
message.success('已分配'); setAddOpen(false); form.resetFields(); qc.invalidateQueries({ queryKey: ['users'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ export default function MemberManagement() {
|
|||||||
content: action === 'approve' ? '批准后将立即执行账号注销' : '驳回后用户可正常使用',
|
content: action === 'approve' ? '批准后将立即执行账号注销' : '驳回后用户可正常使用',
|
||||||
okType: action === 'approve' ? 'danger' : undefined,
|
okType: action === 'approve' ? 'danger' : undefined,
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await api.post(`/admin-api/users/deletion-requests/${id}/${action}`)
|
await membersAPI.processDeletion(id, action)
|
||||||
message.success(action === 'approve' ? '已批准注销' : '已驳回')
|
message.success(action === 'approve' ? '已批准注销' : '已驳回')
|
||||||
qc.invalidateQueries({ queryKey: ['users'] })
|
qc.invalidateQueries({ queryKey: ['users'] })
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Modal, Input, InputNumber, Tag, Tabs, Space } from 'antd'
|
import { Table, Button, Typography, App, Modal, Input, InputNumber, Tag, Tabs, Space } from 'antd'
|
||||||
import { ReloadOutlined, PlusOutlined, DollarOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, PlusOutlined, DollarOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { quotaAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -13,12 +13,12 @@ function MembershipPage() {
|
|||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [newPlan, setNewPlan] = useState({ name: '', code: '', priceMonthly: 0, monthlyChatCount: 10 })
|
const [newPlan, setNewPlan] = useState({ name: '', code: '', priceMonthly: 0, monthlyChatCount: 10 })
|
||||||
|
|
||||||
const { data: plans } = useQuery({ queryKey: ['quota', 'plans'], queryFn: (): Promise<any> => api.get('/admin-api/quota/plans') })
|
const { data: plans } = useQuery({ queryKey: ['quota', 'plans'], queryFn: (): Promise<any> => quotaAPI.plans() })
|
||||||
const { data: memberships } = useQuery({ queryKey: ['quota', 'memberships'], queryFn: (): Promise<any> => api.get('/admin-api/quota/memberships') })
|
const { data: memberships } = useQuery({ queryKey: ['quota', 'memberships'], queryFn: (): Promise<any> => quotaAPI.memberships() })
|
||||||
const { data: costs } = useQuery({ queryKey: ['quota', 'costs'], queryFn: (): Promise<any> => api.get('/admin-api/quota/costs') })
|
const { data: costs } = useQuery({ queryKey: ['quota', 'costs'], queryFn: (): Promise<any> => quotaAPI.costs() })
|
||||||
|
|
||||||
const addPlan = async () => {
|
const addPlan = async () => {
|
||||||
await api.post('/admin-api/quota/plans', newPlan)
|
await quotaAPI.createPlan(newPlan)
|
||||||
message.success('已添加')
|
message.success('已添加')
|
||||||
setAddOpen(false)
|
setAddOpen(false)
|
||||||
qc.invalidateQueries({ queryKey: ['quota'] })
|
qc.invalidateQueries({ queryKey: ['quota'] })
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Card, Row, Col, Statistic, Button, Typography, Tabs } from 'antd'
|
import { Table, Card, Row, Col, Statistic, Button, Typography, Tabs } from 'antd'
|
||||||
import { ReloadOutlined, DashboardOutlined, CloudOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, DashboardOutlined, CloudOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { metricsAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -9,11 +9,11 @@ const { Title } = Typography
|
|||||||
function MetricsPage() {
|
function MetricsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: overview } = useQuery({ queryKey: ['metrics', 'overview'], queryFn: (): Promise<any> => api.get('/admin-api/metrics/overview'), staleTime: 10_000 })
|
const { data: overview } = useQuery({ queryKey: ['metrics', 'overview'], queryFn: (): Promise<any> => metricsAPI.overview(), staleTime: 10_000 })
|
||||||
const { data: top } = useQuery({ queryKey: ['metrics', 'top'], queryFn: (): Promise<any> => api.get('/admin-api/metrics/top?limit=15'), staleTime: 10_000 })
|
const { data: top } = useQuery({ queryKey: ['metrics', 'top'], queryFn: (): Promise<any> => metricsAPI.top(), staleTime: 10_000 })
|
||||||
const { data: recent } = useQuery({ queryKey: ['metrics', 'recent'], queryFn: (): Promise<any> => api.get('/admin-api/metrics/recent?limit=30'), staleTime: 5_000 })
|
const { data: recent } = useQuery({ queryKey: ['metrics', 'recent'], queryFn: (): Promise<any> => metricsAPI.recent(), staleTime: 5_000 })
|
||||||
const { data: ai } = useQuery({ queryKey: ['metrics', 'ai'], queryFn: (): Promise<any> => api.get('/admin-api/metrics/ai?days=7'), staleTime: 10_000 })
|
const { data: ai } = useQuery({ queryKey: ['metrics', 'ai'], queryFn: (): Promise<any> => metricsAPI.ai(), staleTime: 10_000 })
|
||||||
const { data: worker } = useQuery({ queryKey: ['metrics', 'worker'], queryFn: (): Promise<any> => api.get('/admin-api/metrics/worker?days=7'), staleTime: 10_000 })
|
const { data: worker } = useQuery({ queryKey: ['metrics', 'worker'], queryFn: (): Promise<any> => metricsAPI.worker(), staleTime: 10_000 })
|
||||||
|
|
||||||
const topCols = [
|
const topCols = [
|
||||||
{ title: '接口', dataIndex: 'path', width: 300, ellipsis: true },
|
{ title: '接口', dataIndex: 'path', width: 300, ellipsis: true },
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Card, Table, Button, Modal, Form, Input, Select, Tag, Space, Typography, message } from 'antd'
|
import { Card, Table, Button, Modal, Form, Input, Select, Tag, Space, Typography, message } from 'antd'
|
||||||
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { notificationsAPI } from '@/api'
|
||||||
import type { NotificationTemplate, NotificationLog } from '@/types/api'
|
import type { NotificationTemplate, NotificationLog } from '@/types/api'
|
||||||
import { BellOutlined, SendOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
import { BellOutlined, SendOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||||
|
|
||||||
@ -21,20 +21,20 @@ export default function NotificationAdmin() {
|
|||||||
const { data: templates, isLoading } = useQuery({
|
const { data: templates, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'notification-templates'],
|
queryKey: ['admin', 'notification-templates'],
|
||||||
queryFn: (): Promise<NotificationTemplate[]> =>
|
queryFn: (): Promise<NotificationTemplate[]> =>
|
||||||
api.get<NotificationTemplate[]>('/admin-api/notifications/templates').then(d => d ?? []),
|
notificationsAPI.templates().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: sendLogs } = useQuery({
|
const { data: sendLogs } = useQuery({
|
||||||
queryKey: ['admin', 'notification-logs'],
|
queryKey: ['admin', 'notification-logs'],
|
||||||
queryFn: (): Promise<NotificationLog[]> =>
|
queryFn: (): Promise<NotificationLog[]> =>
|
||||||
api.get<NotificationLog[]>('/admin-api/notifications/send-log?limit=50').then(d => d ?? []),
|
notificationsAPI.sendLog().then(d => d ?? []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: any) =>
|
mutationFn: (values: any) =>
|
||||||
editing
|
editing
|
||||||
? api.patch(`/admin-api/notifications/templates/${editing.id}`, values)
|
? notificationsAPI.updateTemplate(editing.id, values)
|
||||||
: api.post('/admin-api/notifications/templates', values),
|
: notificationsAPI.createTemplate(values),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success(editing ? '模板已更新' : '模板已创建')
|
message.success(editing ? '模板已更新' : '模板已创建')
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] })
|
qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] })
|
||||||
@ -45,7 +45,7 @@ export default function NotificationAdmin() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/admin-api/notifications/templates/${id}`),
|
mutationFn: (id: string) => notificationsAPI.deleteTemplate(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success('模板已删除')
|
message.success('模板已删除')
|
||||||
qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] })
|
qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] })
|
||||||
@ -165,18 +165,18 @@ function AdminNotificationsCard() {
|
|||||||
|
|
||||||
const { data: adminNotifs } = useQuery({
|
const { data: adminNotifs } = useQuery({
|
||||||
queryKey: ['admin', 'admin-notifications'],
|
queryKey: ['admin', 'admin-notifications'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/admin-notifications'),
|
queryFn: () => notificationsAPI.adminNotifications(),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendAlert = useMutation({
|
const sendAlert = useMutation({
|
||||||
mutationFn: (body: { type: string; title: string; content?: string }) =>
|
mutationFn: (body: { type: string; title: string; content?: string }) =>
|
||||||
api.post('/admin-api/admin-notifications/send', body),
|
notificationsAPI.send(body),
|
||||||
onSuccess: () => { message.success('通知已发送'); qc.invalidateQueries({ queryKey: ['admin', 'admin-notifications'] }) },
|
onSuccess: () => { message.success('通知已发送'); qc.invalidateQueries({ queryKey: ['admin', 'admin-notifications'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const markRead = useMutation({
|
const markRead = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/admin-api/admin-notifications/${id}/read`),
|
mutationFn: (id: string) => notificationsAPI.markRead(id),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'admin-notifications'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'admin-notifications'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Table, Button, Modal, Form, Input, Checkbox, Tag, Space, Typography, Tabs, Card } from 'antd'
|
import { Table, Button, Modal, Form, Input, Checkbox, Tag, Space, Typography, Tabs, Card, App } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, RocketOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, RocketOutlined } from '@ant-design/icons'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { releaseAPI } from '@/api'
|
||||||
import { releaseStatusLabels } from '@/constants/labels'
|
import { releaseStatusLabels } from '@/constants/labels'
|
||||||
import { message } from 'antd'
|
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
const { TextArea } = Input
|
const { TextArea } = Input
|
||||||
@ -15,23 +14,24 @@ export default function ReleaseAdmin() {
|
|||||||
const [editing, setEditing] = useState<any>(null)
|
const [editing, setEditing] = useState<any>(null)
|
||||||
const [checklistVer, setChecklistVer] = useState('1.0')
|
const [checklistVer, setChecklistVer] = useState('1.0')
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
const { message } = App.useApp()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: changelogs, isLoading: cLoading } = useQuery({
|
const { data: changelogs, isLoading: cLoading } = useQuery({
|
||||||
queryKey: ['release', 'changelogs'],
|
queryKey: ['release', 'changelogs'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/release/changelogs').then(d => d ?? []),
|
queryFn: () => releaseAPI.changelogs().then(d => d ?? []),
|
||||||
enabled: tab === 'changelogs',
|
enabled: tab === 'changelogs',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: decisions, isLoading: dLoading } = useQuery({
|
const { data: decisions, isLoading: dLoading } = useQuery({
|
||||||
queryKey: ['release', 'decisions'],
|
queryKey: ['release', 'decisions'],
|
||||||
queryFn: () => api.get<any[]>('/admin-api/release/decisions').then(d => d ?? []),
|
queryFn: () => releaseAPI.decisions().then(d => d ?? []),
|
||||||
enabled: tab === 'decisions',
|
enabled: tab === 'decisions',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: checklist } = useQuery({
|
const { data: checklist } = useQuery({
|
||||||
queryKey: ['release', 'checklist'],
|
queryKey: ['release', 'checklist'],
|
||||||
queryFn: () => api.get<any[]>(`/admin-api/release/checklists/${checklistVer}`).then(d => d ?? []),
|
queryFn: () => releaseAPI.checklists(checklistVer).then(d => d ?? []),
|
||||||
enabled: tab === 'checklist',
|
enabled: tab === 'checklist',
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -39,8 +39,8 @@ export default function ReleaseAdmin() {
|
|||||||
mutationFn: (values: any) => {
|
mutationFn: (values: any) => {
|
||||||
const endpoint = tab === 'changelogs' ? 'changelogs' : 'decisions'
|
const endpoint = tab === 'changelogs' ? 'changelogs' : 'decisions'
|
||||||
return editing
|
return editing
|
||||||
? api.patch(`/admin-api/release/${endpoint}/${editing.id}`, values)
|
? releaseAPI.update(endpoint, editing.id, values)
|
||||||
: api.post(`/admin-api/release/${endpoint}`, values)
|
: releaseAPI.create(endpoint, values)
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success(editing ? '已更新' : '已创建')
|
message.success(editing ? '已更新' : '已创建')
|
||||||
@ -50,13 +50,13 @@ export default function ReleaseAdmin() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/admin-api/release/${tab === 'changelogs' ? 'changelogs' : 'decisions'}/${id}`),
|
mutationFn: (id: string) => releaseAPI.delete(tab === 'changelogs' ? 'changelogs' : 'decisions', id),
|
||||||
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['release'] }) },
|
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['release'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleCheck = useMutation({
|
const toggleCheck = useMutation({
|
||||||
mutationFn: ({ id, checked }: { id: string; checked: boolean }) =>
|
mutationFn: ({ id, checked }: { id: string; checked: boolean }) =>
|
||||||
api.patch(`/admin-api/release/checklists/${id}`, { checked }),
|
releaseAPI.toggleChecklist(id, checked),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['release', 'checklist'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['release', 'checklist'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { Card, Button, Space, Typography, Table, message, Select } from 'antd'
|
import { Card, Button, Space, Typography, Table, message, Select } from 'antd'
|
||||||
import { DownloadOutlined } from '@ant-design/icons'
|
import { DownloadOutlined } from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { reportingAPI } from '@/api'
|
||||||
import { getAccessToken } from '@/services/token-store'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
@ -20,17 +19,13 @@ export default function ReportingAdmin() {
|
|||||||
|
|
||||||
const { data: jobs } = useQuery({
|
const { data: jobs } = useQuery({
|
||||||
queryKey: ['admin', 'export-jobs'],
|
queryKey: ['admin', 'export-jobs'],
|
||||||
queryFn: () => api.get<any>('/admin-api/reporting/jobs'),
|
queryFn: () => reportingAPI.jobs(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const download = async (type: string) => {
|
const download = async (type: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/admin-api/reporting/export/${type}?days=${days}`, {
|
const text = await reportingAPI.exportCSV(type, days)
|
||||||
headers: { Authorization: `Bearer ${getAccessToken() || ''}` },
|
downloadBlob(text as string, `${type}-report-${days}d.csv`)
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('下载失败')
|
|
||||||
const text = await res.text()
|
|
||||||
downloadBlob(text, `${type}-report-${days}d.csv`)
|
|
||||||
} catch {
|
} catch {
|
||||||
message.error('导出失败')
|
message.error('导出失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Table, Tag, Space, Input, Select, Typography } from 'antd'
|
import { Table, Tag, Space, Input, Select, Typography } from 'antd'
|
||||||
import { SearchOutlined } from '@ant-design/icons'
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '@/services/http-client'
|
import { reviewsAPI } from '@/api'
|
||||||
import type { ReviewCardItem, PaginatedResult } from '@/types/api'
|
import type { ReviewCardItem, PaginatedResult } from '@/types/api'
|
||||||
import { reviewStatusLabels, scheduleStateLabels, difficultyLabels } from '@/constants/labels'
|
import { reviewStatusLabels, scheduleStateLabels, difficultyLabels } from '@/constants/labels'
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ export default function ReviewAdmin() {
|
|||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
if (statusFilter) params.set('status', statusFilter); else params.delete('status')
|
if (statusFilter) params.set('status', statusFilter); else params.delete('status')
|
||||||
return api.get<PaginatedResult<ReviewCardItem>>(`/admin-api/reviews?${params.toString()}`)
|
return reviewsAPI.list(params.toString())
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { PlusOutlined, DeleteOutlined, KeyOutlined, RotateRightOutlined, StopOut
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Tabs, DatePicker, InputNumber, message } from 'antd'
|
import { Table, Button, Typography, App, Modal, Input, Select, Tag, Tabs, DatePicker, InputNumber, message } from 'antd'
|
||||||
import { api } from '@/services/http-client'
|
import { secretsAPI } from '@/api'
|
||||||
import { secretStatusLabels } from '@/constants/labels'
|
import { secretStatusLabels } from '@/constants/labels'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@ -16,40 +16,40 @@ function SecretsPage() {
|
|||||||
const [form, setForm] = useState({ name: '', provider: 'deepseek', value: '', expiresAt: '' })
|
const [form, setForm] = useState({ name: '', provider: 'deepseek', value: '', expiresAt: '' })
|
||||||
const [billForm, setBillForm] = useState({ provider: 'deepseek', billMonth: dayjs().format('YYYY-MM'), amount: 0, notes: '' })
|
const [billForm, setBillForm] = useState({ provider: 'deepseek', billMonth: dayjs().format('YYYY-MM'), amount: 0, notes: '' })
|
||||||
|
|
||||||
const { data: secrets } = useQuery({ queryKey: ['secrets'], queryFn: (): Promise<any> => api.get('/admin-api/secrets') })
|
const { data: secrets } = useQuery({ queryKey: ['secrets'], queryFn: (): Promise<any> => secretsAPI.list() })
|
||||||
const { data: logs } = useQuery({ queryKey: ['secrets', 'logs'], queryFn: (): Promise<any> => api.get('/admin-api/secrets/logs') })
|
const { data: logs } = useQuery({ queryKey: ['secrets', 'logs'], queryFn: (): Promise<any> => secretsAPI.logs() })
|
||||||
const { data: bills } = useQuery({ queryKey: ['vendor', 'bills'], queryFn: (): Promise<any> => api.get('/admin-api/vendor/bills') })
|
const { data: bills } = useQuery({ queryKey: ['vendor', 'bills'], queryFn: (): Promise<any> => secretsAPI.vendorBills() })
|
||||||
|
|
||||||
const addSecret = async () => {
|
const addSecret = async () => {
|
||||||
await api.post('/admin-api/secrets', form)
|
await secretsAPI.create(form)
|
||||||
message.success('已添加')
|
message.success('已添加')
|
||||||
setAddOpen(false); qc.invalidateQueries({ queryKey: ['secrets'] })
|
setAddOpen(false); qc.invalidateQueries({ queryKey: ['secrets'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const rotateKey = useMutation({
|
const rotateKey = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/admin-api/vendor/secrets/${id}/rotate`),
|
mutationFn: (id: string) => secretsAPI.rotateVendorSecret(id),
|
||||||
onSuccess: () => { message.success('已轮换'); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
onSuccess: () => { message.success('已轮换'); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const revokeKey = useMutation({
|
const revokeKey = useMutation({
|
||||||
mutationFn: (id: string) => api.post(`/admin-api/vendor/secrets/${id}/revoke`),
|
mutationFn: (id: string) => secretsAPI.revokeVendorSecret(id),
|
||||||
onSuccess: () => { message.success('已吊销'); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
onSuccess: () => { message.success('已吊销'); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteSecret = (id: string) => modal.confirm({
|
const deleteSecret = (id: string) => modal.confirm({
|
||||||
title: '删除密钥', okType: 'danger',
|
title: '删除密钥', okType: 'danger',
|
||||||
onOk: async () => { await api.delete(`/admin-api/secrets/${id}`); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
onOk: async () => { await secretsAPI.delete(id); qc.invalidateQueries({ queryKey: ['secrets'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const addBill = async () => {
|
const addBill = async () => {
|
||||||
await api.post('/admin-api/vendor/bills', billForm)
|
await secretsAPI.createVendorBill(billForm)
|
||||||
message.success('账单已录入')
|
message.success('账单已录入')
|
||||||
setBillOpen(false); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] })
|
setBillOpen(false); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteBill = (id: string) => modal.confirm({
|
const deleteBill = (id: string) => modal.confirm({
|
||||||
title: '删除账单', okType: 'danger',
|
title: '删除账单', okType: 'danger',
|
||||||
onOk: async () => { await api.delete(`/admin-api/vendor/bills/${id}`); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] }) },
|
onOk: async () => { await secretsAPI.deleteVendorBill(id); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const secCols = [
|
const secCols = [
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Table, Button, Typography, Tag } from 'antd'
|
import { Table, Button, Typography, Tag } from 'antd'
|
||||||
import { ReloadOutlined, SafetyOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { contentSafetyAPI } from '@/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const contentTypeLabels: Record<string, string> = {
|
const contentTypeLabels: Record<string, string> = {
|
||||||
@ -14,7 +14,7 @@ const { Title } = Typography
|
|||||||
|
|
||||||
export default function SecurityEventsPage() {
|
export default function SecurityEventsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data } = useQuery({ queryKey: ['security-events'], queryFn: (): Promise<any> => api.get('/admin-api/content-safety/checks'), staleTime: 10_000 })
|
const { data } = useQuery({ queryKey: ['security-events'], queryFn: () => contentSafetyAPI.checks(), staleTime: 10_000 })
|
||||||
|
|
||||||
const cols = [
|
const cols = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 140, render: (d: string) => dayjs(d).format('MM-DD HH:mm:ss') },
|
{ title: '时间', dataIndex: 'createdAt', width: 140, render: (d: string) => dayjs(d).format('MM-DD HH:mm:ss') },
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Card, Row, Col, Progress, Table, Tag, Typography, Button, Space, Tooltip, App } from 'antd'
|
import { Card, Row, Col, Progress, Table, Tag, Typography, Button, Space, Tooltip, App } from 'antd'
|
||||||
import { CloudServerOutlined, ReloadOutlined, CopyOutlined, GlobalOutlined } from '@ant-design/icons'
|
import { CloudServerOutlined, ReloadOutlined, CopyOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||||
import { getServerMetrics, getServerHealth, getWorkerStatus, type ServerInfo, type ProcessInfo, type ServerHealth } from '@/services/server-api'
|
import { serversAPI, type ServerInfo, type ServerHealth } from '@/api/servers'
|
||||||
|
|
||||||
const { Text, Title } = Typography
|
const { Text, Title } = Typography
|
||||||
|
|
||||||
@ -89,7 +89,7 @@ function ServerCard({ server }: { server: ServerInfo }) {
|
|||||||
function WorkerStatusSection() {
|
function WorkerStatusSection() {
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['servers', 'workers'],
|
queryKey: ['servers', 'workers'],
|
||||||
queryFn: getWorkerStatus,
|
queryFn: serversAPI.workers,
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
})
|
})
|
||||||
@ -146,13 +146,13 @@ function ServersContent() {
|
|||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['servers', 'metrics'],
|
queryKey: ['servers', 'metrics'],
|
||||||
queryFn: getServerMetrics,
|
queryFn: serversAPI.metrics,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: health } = useQuery({
|
const { data: health } = useQuery({
|
||||||
queryKey: ['servers', 'health'],
|
queryKey: ['servers', 'health'],
|
||||||
queryFn: getServerHealth,
|
queryFn: serversAPI.health,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { Input, Button, Typography, App, message as antMsg } from 'antd'
|
import { Input, Button, Typography, App, message as antMsg } from 'antd'
|
||||||
import { PlusOutlined, ToolOutlined, DeleteOutlined, MessageOutlined, ArrowUpOutlined, CopyOutlined } from '@ant-design/icons'
|
import { PlusOutlined, ToolOutlined, DeleteOutlined, MessageOutlined, ArrowUpOutlined, CopyOutlined } from '@ant-design/icons'
|
||||||
import { streamChat, resolveApproval, type StreamEvent } from '@/services/ai-chat'
|
import { aiAPI, conversationsAPI } from '@/api'
|
||||||
import { listConversations, createConversation, deleteConversation, getMessages, updateConversation, type Conversation } from '@/services/conversation-api'
|
import { streamChat, type StreamEvent } from '@/services/ai-chat'
|
||||||
import Markdown from '@/components/Markdown'
|
import Markdown from '@/components/Markdown'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
@ -29,7 +29,7 @@ function ChatPage() {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const editInputRef = useRef<any>(null)
|
const editInputRef = useRef<any>(null)
|
||||||
|
|
||||||
const loadConversations = useCallback(async () => { try { setConversations(await listConversations()) } catch {} }, [])
|
const loadConversations = useCallback(async () => { try { setConversations(await conversationsAPI.list()) } catch {} }, [])
|
||||||
useEffect(() => { loadConversations() }, [loadConversations])
|
useEffect(() => { loadConversations() }, [loadConversations])
|
||||||
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||||
useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations])
|
useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations])
|
||||||
@ -37,30 +37,30 @@ function ChatPage() {
|
|||||||
const switchConversation = useCallback(async (id: string) => {
|
const switchConversation = useCallback(async (id: string) => {
|
||||||
if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) }
|
if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) }
|
||||||
setActiveId(id); setMessages([])
|
setActiveId(id); setMessages([])
|
||||||
try { const records = await getMessages(id); setMessages(records.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: new Date(m.createdAt).getTime() }))) } catch {}
|
try { const records = await conversationsAPI.messages(id); setMessages(records.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: new Date(m.createdAt).getTime() }))) } catch {}
|
||||||
}, [streaming])
|
}, [streaming])
|
||||||
|
|
||||||
const handleNew = async () => {
|
const handleNew = async () => {
|
||||||
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
|
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
|
||||||
try { const conv = await createConversation(); setConversations(prev => [conv, ...prev]); setActiveId(conv.id); setMessages([]); setInput('') } catch {}
|
try { const conv = await conversationsAPI.create(); setConversations(prev => [conv, ...prev]); setActiveId(conv.id); setMessages([]); setInput('') } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id: string) => modal.confirm({
|
const handleDelete = (id: string) => modal.confirm({
|
||||||
title: '删除对话', okText: '删除', okType: 'danger', cancelText: '取消',
|
title: '删除对话', okText: '删除', okType: 'danger', cancelText: '取消',
|
||||||
onOk: async () => { try { await deleteConversation(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch {} },
|
onOk: async () => { try { await conversationsAPI.remove(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch {} },
|
||||||
})
|
})
|
||||||
|
|
||||||
const startEdit = (conv: Conversation) => { setEditingId(conv.id); setEditTitle(conv.title); setTimeout(() => editInputRef.current?.focus(), 50) }
|
const startEdit = (conv: Conversation) => { setEditingId(conv.id); setEditTitle(conv.title); setTimeout(() => editInputRef.current?.focus(), 50) }
|
||||||
const saveTitle = async (id: string) => {
|
const saveTitle = async (id: string) => {
|
||||||
const t = editTitle.trim()
|
const t = editTitle.trim()
|
||||||
if (t && t !== conversations.find(c => c.id === id)?.title) { await updateConversation(id, t).catch(() => {}); setConversations(prev => prev.map(c => c.id === id ? { ...c, title: t } : c)) }
|
if (t && t !== conversations.find(c => c.id === id)?.title) { await conversationsAPI.update(id, t).catch(() => {}); setConversations(prev => prev.map(c => c.id === id ? { ...c, title: t } : c)) }
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApprove = async (approvalMsg: Message, choice: string) => {
|
const handleApprove = async (approvalMsg: Message, choice: string) => {
|
||||||
setMessages(prev => prev.map(m => m.id === approvalMsg.id ? { ...m, approval: { ...m.approval!, resolved: true } } : m))
|
setMessages(prev => prev.map(m => m.id === approvalMsg.id ? { ...m, approval: { ...m.approval!, resolved: true } } : m))
|
||||||
setWaitingApproval(false)
|
setWaitingApproval(false)
|
||||||
await resolveApproval(approvalMsg.approval!.runId, choice)
|
await aiAPI.resolveApproval(approvalMsg.approval!.runId, choice)
|
||||||
antMsg.success(choice === 'deny' ? '已拒绝' : '已批准')
|
antMsg.success(choice === 'deny' ? '已拒绝' : '已批准')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Card, Row, Col, Statistic, Table, Button, Typography, Tag } from 'antd'
|
import { Card, Row, Col, Statistic, Table, Button, Typography, Tag } from 'antd'
|
||||||
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { throttleAPI } from '@/api'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
export default function ThrottlePage() {
|
export default function ThrottlePage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data } = useQuery({ queryKey: ['throttle'], queryFn: (): Promise<any> => api.get('/admin-api/throttle/status'), staleTime: 30_000 })
|
const { data } = useQuery({ queryKey: ['throttle'], queryFn: () => throttleAPI.status(), staleTime: 30_000 })
|
||||||
|
|
||||||
const rules = data?.rules || []
|
const rules = data?.rules || []
|
||||||
const cols = [
|
const cols = [
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { AdminUser } from '@/types/admin'
|
import type { AdminUser } from '@/types/admin'
|
||||||
import { ADMIN_ROLE_LABELS, ADMIN_ROLE_COLORS } from '@/constants/roles'
|
import { ADMIN_ROLE_LABELS, ADMIN_ROLE_COLORS } from '@/constants/roles'
|
||||||
import { getAdminUsers, deleteAdminUser } from '@/services/admin-api'
|
import { usersAPI } from '@/api'
|
||||||
import DetailDrawer from '@/components/DetailDrawer'
|
import DetailDrawer from '@/components/DetailDrawer'
|
||||||
import ConfirmDangerModal from '@/components/ConfirmDangerModal'
|
import ConfirmDangerModal from '@/components/ConfirmDangerModal'
|
||||||
|
|
||||||
@ -29,11 +29,11 @@ export default function UserManagement() {
|
|||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin-users'],
|
queryKey: ['admin-users'],
|
||||||
queryFn: () => getAdminUsers({ page: 1, limit: 20 }),
|
queryFn: () => usersAPI.list({ page: 1, limit: 20 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => deleteAdminUser(id),
|
mutationFn: (id: string) => usersAPI.remove(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
message.success('已删除')
|
message.success('已删除')
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd'
|
import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd'
|
||||||
import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons'
|
||||||
import { api } from '@/services/http-client'
|
import { vectorAPI } from '@/api'
|
||||||
|
|
||||||
const { Title } = Typography
|
const { Title } = Typography
|
||||||
|
|
||||||
@ -11,19 +11,19 @@ export default function VectorAdminPage() {
|
|||||||
|
|
||||||
const { data: coll, isLoading: collLoading } = useQuery({
|
const { data: coll, isLoading: collLoading } = useQuery({
|
||||||
queryKey: ['vector', 'collection'],
|
queryKey: ['vector', 'collection'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/vector/collection'),
|
queryFn: (): Promise<any> => vectorAPI.collection(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: count } = useQuery({
|
const { data: count } = useQuery({
|
||||||
queryKey: ['vector', 'count'],
|
queryKey: ['vector', 'count'],
|
||||||
queryFn: (): Promise<any> => api.get('/admin-api/vector/count'),
|
queryFn: (): Promise<any> => vectorAPI.count(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleReindex = async () => {
|
const handleReindex = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/admin-api/vector/reindex')
|
await vectorAPI.reindex()
|
||||||
message.success('索引重建已提交')
|
message.success('索引重建已提交')
|
||||||
qc.invalidateQueries({ queryKey: ['vector'] })
|
qc.invalidateQueries({ queryKey: ['vector'] })
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
67
src/pages/learning/AnomalyPage.tsx
Normal file
67
src/pages/learning/AnomalyPage.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Space, Tag, Tabs, Drawer, Descriptions } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { eventTypeLabels } from '@/constants/labels';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function AnomalyPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('failed');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [errorCodeFilter, setErrorCodeFilter] = useState('');
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const statusParam = activeTab === 'warning' ? 'warning' : activeTab === 'duplicate' ? 'duplicate' : 'failed';
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-anomalies', activeTab, page, errorCodeFilter],
|
||||||
|
queryFn: () => learningAPI.getReadingEvents({ page, limit: 20, status: statusParam, errorCode: errorCodeFilter || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||||
|
queryKey: ['admin-anomaly-detail', detailId],
|
||||||
|
queryFn: () => learningAPI.getReadingEventDetail(detailId!),
|
||||||
|
enabled: !!detailId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
|
||||||
|
{ title: '事件类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{eventTypeLabels[t] || t}</Tag> },
|
||||||
|
{ title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140, render: (c?: string) => c ? <Tag color="red">{c}</Tag> : '-' },
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusColor = (s: string) => s === 'failed' ? 'red' : s === 'warning' ? 'orange' : 'blue';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="异常事件">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="错误码筛选" allowClear value={errorCodeFilter}
|
||||||
|
onChange={e => { setErrorCodeFilter(e.target.value); setPage(1); }} style={{ width: 180 }} />
|
||||||
|
</Space>
|
||||||
|
<Tabs activeKey={activeTab} onChange={(k) => { setActiveTab(k); setPage(1); }}
|
||||||
|
items={[
|
||||||
|
{ key: 'failed', label: <span>失败事件 <Tag color="red">{data?.total || 0}</Tag></span>, children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small" onRow={(r: any) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
|
||||||
|
{ key: 'warning', label: <span>警告事件 <Tag color="orange">{data?.total || 0}</Tag></span>, children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small" onRow={(r: any) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
|
||||||
|
{ key: 'duplicate', label: <span>重复事件 <Tag color="blue">{data?.total || 0}</Tag></span>, children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small" onRow={(r: any) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
|
||||||
|
]} />
|
||||||
|
<Drawer title="事件详情" open={!!detailId} onClose={() => setDetailId(null)} width={640} loading={detailLoading}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="事件ID" span={2}>{detail.eventId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事件类型">{detail.eventType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态"><Tag color={statusColor(detail.status)}>{detail.status}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="错误码"><Tag color="red">{detail.errorCode || '-'}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="错误信息" span={2}>{detail.errorMessage || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="重试次数">{detail.retryCount ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/pages/learning/DailyActivityPage.tsx
Normal file
60
src/pages/learning/DailyActivityPage.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Select, Space, Button, Drawer, Descriptions, DatePicker } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function DailyActivityPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-daily', page, filters],
|
||||||
|
queryFn: () => learningAPI.getDailyActivities({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
||||||
|
{ title: '日期', dataIndex: 'activityDate', key: 'date', width: 120, render: (t: string) => t ? dayjs(t).format('YYYY-MM-DD') : '-' },
|
||||||
|
{ title: '阅读秒', dataIndex: 'readingSeconds', key: 'seconds', width: 80,
|
||||||
|
render: (v: number) => <span style={{ color: v > 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v}</span> },
|
||||||
|
{ title: '资料数', dataIndex: 'materialsReadCount', key: 'matCount', width: 70 },
|
||||||
|
{ title: '已读数', dataIndex: 'markedReadCount', key: 'marked', width: 70 },
|
||||||
|
{ title: '专注(分)', dataIndex: 'focusMinutes', key: 'focus', width: 70, render: (v?: number) => v ?? '-' },
|
||||||
|
{ title: '会话数', dataIndex: 'sessionCount', key: 'sessions', width: 70, render: (v?: number) => v ?? '-' },
|
||||||
|
{ title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kb', width: 80, ellipsis: true, render: (v?: string) => v?.slice(0,8) || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="每日学习活动">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<DatePicker.RangePicker onChange={(dates) => {
|
||||||
|
if (dates?.[0] && dates?.[1]) setFilters(f => ({ ...f, startDate: dates[0]!.format('YYYY-MM-DD'), endDate: dates[1]!.format('YYYY-MM-DD') }));
|
||||||
|
else setFilters(f => { const { startDate, endDate, ...rest } = f; return rest; });
|
||||||
|
}} />
|
||||||
|
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 750 }}
|
||||||
|
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
||||||
|
onRow={(r: any) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
|
||||||
|
<Drawer title="活动详情" open={!!detail} onClose={() => setDetail(null)} width={520}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="日期">{detail.activityDate ? dayjs(detail.activityDate).format('YYYY-MM-DD') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="阅读秒数">{detail.readingSeconds ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="专注分钟">{detail.focusMinutes ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="阅读资料数">{detail.materialsReadCount ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="标记已读数">{detail.markedReadCount ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="会话数">{detail.sessionCount ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事件数" span={2}>{detail.eventCount ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { Card, Col, Row, Statistic, Spin, Alert, Select, DatePicker, Table, Tag,
|
|||||||
import { BookOutlined, ThunderboltOutlined, UserOutlined, WarningOutlined, CheckCircleOutlined, ClockCircleOutlined, FireOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { BookOutlined, ThunderboltOutlined, UserOutlined, WarningOutlined, CheckCircleOutlined, ClockCircleOutlined, FireOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { learningAdminAPI } from '../../services/learningAdmin';
|
import { learningAPI } from '@/api';
|
||||||
import { eventTypeLabels } from '../../constants/labels';
|
import { eventTypeLabels } from '../../constants/labels';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@ -14,13 +14,13 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const { data: kbList = [] } = useQuery({
|
const { data: kbList = [] } = useQuery({
|
||||||
queryKey: ['admin-kb-list'],
|
queryKey: ['admin-kb-list'],
|
||||||
queryFn: () => learningAdminAPI.getKnowledgeBases(),
|
queryFn: () => learningAPI.getKnowledgeBases(),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['admin-dashboard', kbId, dateRange],
|
queryKey: ['admin-dashboard', kbId, dateRange],
|
||||||
queryFn: () => learningAdminAPI.getDashboard({
|
queryFn: () => learningAPI.getDashboard({
|
||||||
knowledgeBaseId: kbId,
|
knowledgeBaseId: kbId,
|
||||||
startDate: dateRange?.[0],
|
startDate: dateRange?.[0],
|
||||||
endDate: dateRange?.[1],
|
endDate: dateRange?.[1],
|
||||||
|
|||||||
@ -1,520 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Table, Input, Select, Space, Tag, Button, Tabs, Drawer, Descriptions, DatePicker, Timeline, Typography } from 'antd';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { learningAdminAPI } from '../../services/learningAdmin';
|
|
||||||
import { eventTypeLabels, eventStatusLabels, sessionStatusLabels, progressStatusLabels, targetTypeLabels, recordTypeLabels, recordTitleLabels, sessionModeLabels } from '../../constants/labels';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
|
||||||
|
|
||||||
function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return <div><h2 style={{ marginBottom: 16 }}>{title}</h2>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reading Events ──
|
|
||||||
|
|
||||||
export function ReadingEventPage() {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const init: Record<string, string> = {};
|
|
||||||
const csid = params.get('clientSessionId');
|
|
||||||
if (csid) init.clientSessionId = csid;
|
|
||||||
return init;
|
|
||||||
});
|
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin-events', page, filters],
|
|
||||||
queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, ...filters }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
|
||||||
queryKey: ['admin-event-detail', detailId],
|
|
||||||
queryFn: () => learningAdminAPI.getReadingEventDetail(detailId!),
|
|
||||||
enabled: !!detailId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
|
|
||||||
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
|
||||||
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
|
|
||||||
{ title: '类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{eventTypeLabels[t] || t}</Tag> },
|
|
||||||
{ title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
|
||||||
render: (s: string) => <Tag color={s === 'processed' ? 'green' : s === 'failed' ? 'red' : s === 'duplicate' ? 'orange' : 'blue'}>{eventStatusLabels[s] || s}</Tag> },
|
|
||||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
|
||||||
render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="阅读事件">
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
|
||||||
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Input placeholder="Client Session ID" allowClear onChange={e => setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
|
|
||||||
<Select placeholder="事件类型" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, eventType: v || '' }))}
|
|
||||||
options={Object.entries(eventTypeLabels).map(([v, l]) => ({ value: v, label: l }))} />
|
|
||||||
<Select placeholder="状态" allowClear style={{ width: 110 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
|
|
||||||
options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }, { value: 'pending', label: '待处理' }]} />
|
|
||||||
<RangePicker
|
|
||||||
onChange={(dates) => {
|
|
||||||
if (dates && dates[0] && dates[1]) {
|
|
||||||
setFilters(f => ({ ...f, startDate: dates[0]!.format('YYYY-MM-DD'), endDate: dates[1]!.format('YYYY-MM-DD') }));
|
|
||||||
} else {
|
|
||||||
setFilters(f => { const { startDate, endDate, ...rest } = f; return rest; });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
|
||||||
</Space>
|
|
||||||
<Table rowKey="eventId" columns={columns} dataSource={data?.items || []}
|
|
||||||
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(record: any) => ({ onClick: () => setDetailId(record.eventId), style: { cursor: 'pointer' } })} />
|
|
||||||
|
|
||||||
<Drawer title="事件详情" open={!!detailId} onClose={() => setDetailId(null)} width={640} loading={detailLoading}>
|
|
||||||
{detail && (
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<Descriptions.Item label="事件ID" span={2}>{detail.eventId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="事件类型">{detail.eventType}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态"><Tag>{detail.status}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="活跃秒数">{detail.activeSecondsDelta ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="序列号">{detail.sequence ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Session ID" span={2}>{detail.clientSessionId ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="readingTargetType">{detail.readingTargetType ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="platform">{detail.platform ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="appVersion">{detail.appVersion ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="timezoneOffset">{detail.clientTimezoneOffsetMinutes ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="错误码">{detail.errorCode || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="重试次数">{detail.retryCount ?? 0}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="knowledgeBaseId">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sessions ──
|
|
||||||
|
|
||||||
export function SessionPage() {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin-sessions', page, filters],
|
|
||||||
queryFn: () => learningAdminAPI.getSessions({ page, limit: 20, ...filters }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
|
||||||
queryKey: ['admin-session-detail', detailId],
|
|
||||||
queryFn: () => learningAdminAPI.getSessionDetail(detailId!),
|
|
||||||
enabled: !!detailId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'Session ID', dataIndex: 'id', key: 'id', width: 180, ellipsis: true },
|
|
||||||
{ title: 'Client ID', dataIndex: 'clientSessionId', key: 'clientId', width: 140, ellipsis: true, render: (v?: string) => v?.slice(0, 12) || '-' },
|
|
||||||
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
|
||||||
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 90, ellipsis: true },
|
|
||||||
{ title: '类型', dataIndex: 'targetType', key: 'targetType', width: 80, render: (v?: string) => v ? <Tag>{targetTypeLabels[v] || v}</Tag> : '-' },
|
|
||||||
{ title: '模式', dataIndex: 'mode', key: 'mode', width: 70, render: (v?: string) => (v ? sessionModeLabels[v] || v : '-') },
|
|
||||||
{ title: 'KB', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 80, ellipsis: true, render: (_: any, r: any) => r.kbName || r.knowledgeBaseId },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
|
||||||
render: (s: string) => <Tag color={s === 'active' ? 'green' : s === 'interrupted' ? 'red' : s === 'completed' ? 'blue' : 'orange'}>{sessionStatusLabels[s] || s}</Tag> },
|
|
||||||
{ title: '时长(s)', dataIndex: 'totalActiveSeconds', key: 'seconds', width: 70 },
|
|
||||||
{ title: '专注(分)', dataIndex: 'focusMinutes', key: 'focus', width: 70, render: (v?: number) => v ?? '-' },
|
|
||||||
{ title: '开始', dataIndex: 'startedAt', key: 'startedAt', width: 150, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
|
||||||
{ title: '结束', dataIndex: 'endedAt', key: 'endedAt', width: 150, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
|
||||||
{ title: '最后事件', dataIndex: 'lastEventAt', key: 'lastEventAt', width: 150, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="学习会话">
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
|
||||||
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Input placeholder="Client Session ID" allowClear onChange={e => setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
|
|
||||||
<Select placeholder="状态" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
|
|
||||||
options={[{ value: 'active', label: '活跃' }, { value: 'interrupted', label: '中断' }, { value: 'completed', label: '已完成' }]} />
|
|
||||||
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
|
||||||
</Space>
|
|
||||||
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 1200 }}
|
|
||||||
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetailId(r.id), style: { cursor: 'pointer' } })} />
|
|
||||||
|
|
||||||
<Drawer title="会话详情" open={!!detailId} onClose={() => setDetailId(null)} width={680} loading={detailLoading}>
|
|
||||||
{detail && (
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<Descriptions.Item label="Session ID" span={2}>{detail.id}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Client Session ID" span={2}>{detail.clientSessionId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="目标类型">{detail.targetType || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="模式">{detail.mode || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态"><Tag color={detail.status === 'active' ? 'green' : detail.status === 'interrupted' ? 'red' : 'blue'}>{detail.status}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="活跃秒数">{detail.totalActiveSeconds ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="专注时间(分)">{detail.focusMinutes ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="中断">{detail.interrupted ? <Tag color="red">是</Tag> : '否'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="缺 MaterialClosed">{detail.missingMaterialClosed ? <Tag color="orange">是</Tag> : '否'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="异常 Delta">{detail.abnormalDelta ? <Tag color="red">{detail.abnormalDelta}</Tag> : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="开始时间" span={2}>{detail.startedAt ? dayjs(detail.startedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="结束时间" span={2}>{detail.endedAt ? dayjs(detail.endedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="最后事件时间" span={2}>{detail.lastEventAt ? dayjs(detail.lastEventAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联事件数">{detail.eventCount ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="时长组成">{detail.durationBreakdown || '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
{detail?.clientSessionId && (
|
|
||||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
|
||||||
<Button type="primary" onClick={() => window.location.href = `/learning/events?clientSessionId=${encodeURIComponent(detail.clientSessionId)}`}>查看关联事件</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Progress ──
|
|
||||||
|
|
||||||
export function ProgressPage() {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
||||||
const [detail, setDetail] = useState<any>(null);
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin-progress', page, filters],
|
|
||||||
queryFn: () => learningAdminAPI.getProgress({ page, limit: 20, ...filters }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
|
||||||
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 110, ellipsis: true },
|
|
||||||
{ title: '目标类型', dataIndex: 'readingTargetType', key: 'targetType', width: 85, render: (v?: string) => v ? <Tag>{targetTypeLabels[v] || v}</Tag> : '-' },
|
|
||||||
{ title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 75, ellipsis: true, render: (_: any, r: any) => r.kbName || r.knowledgeBaseId },
|
|
||||||
{ title: 'Session', dataIndex: 'lastClientSessionId', key: 'sessionId', width: 120, ellipsis: true, render: (v?: string) => v?.slice(0,16) || '-' },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 85, render: (s: string) => <Tag color={s === 'reading' ? 'blue' : s === 'completed' ? 'green' : 'orange'}>{progressStatusLabels[s] || s}</Tag> },
|
|
||||||
{ title: '进度', dataIndex: 'lastProgress', key: 'progress', width: 65, render: (v: number) => v != null ? `${Math.round(v * 100)}%` : '-' },
|
|
||||||
{ title: '位置', dataIndex: 'lastPosition', key: 'position', width: 100, ellipsis: true, render: (v?: any) => {
|
|
||||||
if (!v) return '-';
|
|
||||||
const pct = v.scrollProgress != null ? `${Math.round(v.scrollProgress * 100)}%` : '';
|
|
||||||
const type = v.type || '';
|
|
||||||
if (type === 'Markdown' || type === 'Text') return `文本 · ${pct || '—'}`;
|
|
||||||
if (type === 'PDF' || type === 'Image') return `${type} · ${pct || '—'}`;
|
|
||||||
if (type === 'EPUB') return `EPUB · ${pct || '—'}`;
|
|
||||||
return pct || JSON.stringify(v).slice(0, 20);
|
|
||||||
} },
|
|
||||||
{ title: '活跃秒', dataIndex: 'totalActiveSeconds', key: 'seconds', width: 70 },
|
|
||||||
{ title: '已读', dataIndex: 'isMarkedRead', key: 'marked', width: 55, render: (v: boolean) => v ? '✅' : '' },
|
|
||||||
{ title: '更新', dataIndex: 'lastReadAt', key: 'updatedAt', width: 140, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="阅读进度">
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
|
||||||
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Select placeholder="状态" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
|
|
||||||
options={[{ value: 'reading', label: '阅读中' }, { value: 'completed', label: '已完成' }, { value: 'not_started', label: '未开始' }]} />
|
|
||||||
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
|
||||||
</Space>
|
|
||||||
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 1000 }}
|
|
||||||
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
|
|
||||||
|
|
||||||
<Drawer title="进度详情" open={!!detail} onClose={() => setDetail(null)} width={600}>
|
|
||||||
{detail && (
|
|
||||||
<>
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="目标类型">{detail.readingTargetType || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态"><Tag>{detail.status}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="已读">{detail.isMarkedRead ? '✅' : '否'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="进度">{detail.lastProgress != null ? `${Math.round(detail.lastProgress * 100)}%` : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="活跃秒数">{detail.totalActiveSeconds ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="最后 Session ID" span={2}>{detail.lastClientSessionId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="最后位置" span={2}><pre style={{ margin: 0, fontSize: 12, maxHeight: 80, overflow: 'auto' }}>{detail.lastPosition ? JSON.stringify(detail.lastPosition, null, 2) : '-'}</pre></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="首次打开">{detail.firstOpenedAt ? dayjs(detail.firstOpenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="最后阅读">{detail.lastReadAt ? dayjs(detail.lastReadAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联资料" span={2}>{detail.relatedMaterials?.join(', ') || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联事件数">{detail.eventCount ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联会话数">{detail.sessionCount ?? '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
|
||||||
<Button disabled title="API /admin/learning/progress/:id/recalculate 就绪后启用">重算进度</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Daily Activities ──
|
|
||||||
|
|
||||||
export function DailyActivityPage() {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
||||||
const [detail, setDetail] = useState<any>(null);
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin-daily', page, filters],
|
|
||||||
queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20, ...filters }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
|
||||||
{ title: '日期', dataIndex: 'activityDate', key: 'date', width: 120, render: (t: string) => t ? dayjs(t).format('YYYY-MM-DD') : '-' },
|
|
||||||
{ title: '阅读秒', dataIndex: 'readingSeconds', key: 'seconds', width: 80,
|
|
||||||
render: (v: number) => <span style={{ color: v > 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v}</span> },
|
|
||||||
{ title: '资料数', dataIndex: 'materialsReadCount', key: 'matCount', width: 70 },
|
|
||||||
{ title: '已读数', dataIndex: 'markedReadCount', key: 'marked', width: 70 },
|
|
||||||
{ title: '专注(分)', dataIndex: 'focusMinutes', key: 'focus', width: 70, render: (v?: number) => v ?? '-' },
|
|
||||||
{ title: '会话数', dataIndex: 'sessionCount', key: 'sessions', width: 70, render: (v?: number) => v ?? '-' },
|
|
||||||
{ title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kb', width: 80, ellipsis: true, render: (v?: string) => v?.slice(0,8) || '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="每日学习活动">
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
|
||||||
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Select placeholder="知识库" allowClear style={{ width: 140 }} onChange={v => setFilters(f => ({ ...f, knowledgeBaseId: v || '' }))} options={[]} />
|
|
||||||
<DatePicker.RangePicker onChange={(dates) => {
|
|
||||||
if (dates?.[0] && dates?.[1]) setFilters(f => ({ ...f, startDate: dates[0]!.format('YYYY-MM-DD'), endDate: dates[1]!.format('YYYY-MM-DD') }));
|
|
||||||
else setFilters(f => { const { startDate, endDate, ...rest } = f; return rest; });
|
|
||||||
}} />
|
|
||||||
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
|
||||||
</Space>
|
|
||||||
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 750 }}
|
|
||||||
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
|
|
||||||
|
|
||||||
<Drawer title="活动详情" open={!!detail} onClose={() => setDetail(null)} width={520}>
|
|
||||||
{detail && (
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="日期">{detail.activityDate ? dayjs(detail.activityDate).format('YYYY-MM-DD') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="阅读秒数">{detail.readingSeconds ?? 0}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="专注分钟">{detail.focusMinutes ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="阅读资料数">{detail.materialsReadCount ?? 0}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="标记已读数">{detail.markedReadCount ?? 0}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="会话数">{detail.sessionCount ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="事件数" span={2}>{detail.eventCount ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Records ──
|
|
||||||
|
|
||||||
export function RecordPage() {
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
||||||
const [detail, setDetail] = useState<any>(null);
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin-records', page, filters],
|
|
||||||
queryFn: () => learningAdminAPI.getRecords({ page, limit: 20, ...filters }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
|
||||||
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || (r.materialId ? r.materialId.slice(0,12) : '-'), width: 100, ellipsis: true },
|
|
||||||
{ title: '标题', dataIndex: 'title', key: 'title', width: 180, render: (t: string) => recordTitleLabels[t] || t },
|
|
||||||
{ title: '类型', dataIndex: 'recordType', key: 'recordType', width: 100, render: (t: string) => <Tag>{recordTypeLabels[t] || t}</Tag> },
|
|
||||||
{ title: '时长', dataIndex: 'durationSeconds', key: 'duration', width: 80 },
|
|
||||||
{ title: '时间', dataIndex: 'occurredAt', key: 'occurredAt', width: 160, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="学习记录">
|
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
|
||||||
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
|
||||||
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
|
||||||
</Space>
|
|
||||||
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 700 }}
|
|
||||||
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
|
|
||||||
|
|
||||||
<Drawer title="记录详情" open={!!detail} onClose={() => setDetail(null)} width={500}>
|
|
||||||
{detail && (
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="记录类型"><Tag>{recordTypeLabels[detail.recordType] || detail.recordType}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="标题" span={2}>{recordTitleLabels[detail.title] || detail.title || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="时长(秒)">{detail.durationSeconds ?? '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联资料ID">{detail.materialId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="发生时间" span={2}>{detail.occurredAt ? dayjs(detail.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Failed / Warning / Duplicate Events ──
|
|
||||||
|
|
||||||
export function AnomalyPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('failed');
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [errorCodeFilter, setErrorCodeFilter] = useState('');
|
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const statusParam = activeTab === 'warning' ? 'warning' : activeTab === 'duplicate' ? 'duplicate' : 'failed';
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin-anomalies', activeTab, page, errorCodeFilter],
|
|
||||||
queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, status: statusParam, errorCode: errorCodeFilter || undefined }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
|
||||||
queryKey: ['admin-anomaly-detail', detailId],
|
|
||||||
queryFn: () => learningAdminAPI.getReadingEventDetail(detailId!),
|
|
||||||
enabled: !!detailId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
|
|
||||||
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
|
||||||
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
|
|
||||||
{ title: '事件类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{eventTypeLabels[t] || t}</Tag> },
|
|
||||||
{ title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140,
|
|
||||||
render: (c?: string) => c ? <Tag color="red">{c}</Tag> : '-' },
|
|
||||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
|
||||||
render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusColor = (s: string) => s === 'failed' ? 'red' : s === 'warning' ? 'orange' : 'blue';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="异常事件">
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Input placeholder="错误码筛选" allowClear value={errorCodeFilter}
|
|
||||||
onChange={e => { setErrorCodeFilter(e.target.value); setPage(1); }} style={{ width: 180 }} />
|
|
||||||
</Space>
|
|
||||||
<Tabs activeKey={activeTab} onChange={(k) => { setActiveTab(k); setPage(1); }}
|
|
||||||
items={[
|
|
||||||
{ key: 'failed', label: <span>失败事件 <Tag color="red">{data?.total || 0}</Tag></span>,
|
|
||||||
children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading}
|
|
||||||
pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
|
|
||||||
{ key: 'warning', label: <span>警告事件 <Tag color="orange">{data?.total || 0}</Tag></span>,
|
|
||||||
children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading}
|
|
||||||
pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
|
|
||||||
{ key: 'duplicate', label: <span>重复事件 <Tag color="blue">{data?.total || 0}</Tag></span>,
|
|
||||||
children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading}
|
|
||||||
pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
|
||||||
onRow={(r: any) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
|
|
||||||
]} />
|
|
||||||
|
|
||||||
<Drawer title="事件详情" open={!!detailId} onClose={() => setDetailId(null)} width={640} loading={detailLoading}>
|
|
||||||
{detail && (
|
|
||||||
<Descriptions column={2} bordered size="small">
|
|
||||||
<Descriptions.Item label="事件ID" span={2}>{detail.eventId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="事件类型">{detail.eventType}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态"><Tag color={statusColor(detail.status)}>{detail.status}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="错误码"><Tag color="red">{detail.errorCode || '-'}</Tag></Descriptions.Item>
|
|
||||||
<Descriptions.Item label="错误信息" span={2}>{detail.errorMessage || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="重试次数">{detail.retryCount ?? 0}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="创建时间">{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── User Timeline ──
|
|
||||||
|
|
||||||
export function UserTimelinePage() {
|
|
||||||
const [userId, setUserId] = useState('');
|
|
||||||
const { data, isFetching, refetch } = useQuery({
|
|
||||||
queryKey: ['admin-user-timeline', userId],
|
|
||||||
queryFn: () => learningAdminAPI.getUserTimeline(userId),
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = Array.isArray(data)
|
|
||||||
? (data as any[]).map((entry: any, i: number) => ({
|
|
||||||
key: i,
|
|
||||||
color: entry.recordType === 'session' ? 'blue' : entry.recordType === 'reading' ? 'green' : 'gray',
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>{entry.occurredAt ? dayjs(entry.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Text>
|
|
||||||
<div><Tag style={{ marginTop: 4 }}>{recordTypeLabels[entry.recordType] || entry.recordType}</Tag></div>
|
|
||||||
<Text strong>{entry.title || '-'}</Text>
|
|
||||||
{entry.durationSeconds != null && <div><Text type="secondary">时长 {entry.durationSeconds}s</Text></div>}
|
|
||||||
{entry.materialId && <div><Text type="secondary">资料 {entry.materialId}</Text></div>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="用户学习时间线">
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Input placeholder="User ID" value={userId} onChange={e => setUserId(e.target.value)} style={{ width: 300 }} />
|
|
||||||
<Button type="primary" onClick={() => refetch()} loading={isFetching}>查询</Button>
|
|
||||||
</Space>
|
|
||||||
{data === undefined && !isFetching && <Text type="secondary">输入用户 ID 后点击查询</Text>}
|
|
||||||
{isFetching && <Text type="secondary">加载中...</Text>}
|
|
||||||
{data && !Array.isArray(data) ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data as any, null, 2)}</pre> : null}
|
|
||||||
{Array.isArray(data) && data.length === 0 && !isFetching && <Text type="secondary">该用户暂无学习记录</Text>}
|
|
||||||
{items.length > 0 && <Timeline items={items} />}
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── User Diagnose ──
|
|
||||||
|
|
||||||
export function UserDiagnosePage() {
|
|
||||||
const [userId, setUserId] = useState('');
|
|
||||||
const { data, refetch } = useQuery({
|
|
||||||
queryKey: ['admin-user-diag', userId],
|
|
||||||
queryFn: () => learningAdminAPI.getUserDiagnose(userId),
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="用户诊断">
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Input placeholder="User ID" value={userId} onChange={e => setUserId(e.target.value)} style={{ width: 300 }} />
|
|
||||||
<Button type="primary" onClick={() => refetch()}>查询</Button>
|
|
||||||
</Space>
|
|
||||||
{data ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data, null, 2)}</pre> : null}
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Material Diagnose ──
|
|
||||||
|
|
||||||
export function MaterialDiagnosePage() {
|
|
||||||
const [materialId, setMaterialId] = useState('');
|
|
||||||
const { data, refetch } = useQuery({
|
|
||||||
queryKey: ['admin-mat-diag', materialId],
|
|
||||||
queryFn: () => learningAdminAPI.getMaterialDiagnose(materialId),
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper title="资料诊断">
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
|
||||||
<Input placeholder="Material ID" value={materialId} onChange={e => setMaterialId(e.target.value)} style={{ width: 300 }} />
|
|
||||||
<Button type="primary" onClick={() => refetch()}>查询</Button>
|
|
||||||
</Space>
|
|
||||||
{data ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data, null, 2)}</pre> : null}
|
|
||||||
</PageWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
24
src/pages/learning/MaterialDiagnosePage.tsx
Normal file
24
src/pages/learning/MaterialDiagnosePage.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Input, Space, Button } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
|
||||||
|
export default function MaterialDiagnosePage() {
|
||||||
|
const [materialId, setMaterialId] = useState('');
|
||||||
|
const { data, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-mat-diag', materialId],
|
||||||
|
queryFn: () => learningAPI.getMaterialDiagnose(materialId),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="资料诊断">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="Material ID" value={materialId} onChange={e => setMaterialId(e.target.value)} style={{ width: 300 }} />
|
||||||
|
<Button type="primary" onClick={() => refetch()}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
{data ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data, null, 2)}</pre> : null}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/pages/learning/PageWrapper.tsx
Normal file
3
src/pages/learning/PageWrapper.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return <div><h2 style={{ marginBottom: 16 }}>{title}</h2>{children}</div>;
|
||||||
|
}
|
||||||
77
src/pages/learning/ProgressPage.tsx
Normal file
77
src/pages/learning/ProgressPage.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Select, Space, Tag, Button, Drawer, Descriptions } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { progressStatusLabels, targetTypeLabels } from '@/constants/labels';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function ProgressPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-progress', page, filters],
|
||||||
|
queryFn: () => learningAPI.getProgress({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 110, ellipsis: true },
|
||||||
|
{ title: '目标类型', dataIndex: 'readingTargetType', key: 'targetType', width: 85, render: (v?: string) => v ? <Tag>{targetTypeLabels[v] || v}</Tag> : '-' },
|
||||||
|
{ title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 75, ellipsis: true, render: (_: any, r: any) => r.kbName || r.knowledgeBaseId },
|
||||||
|
{ title: 'Session', dataIndex: 'lastClientSessionId', key: 'sessionId', width: 120, ellipsis: true, render: (v?: string) => v?.slice(0,16) || '-' },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 85, render: (s: string) => <Tag color={s === 'reading' ? 'blue' : s === 'completed' ? 'green' : 'orange'}>{progressStatusLabels[s] || s}</Tag> },
|
||||||
|
{ title: '进度', dataIndex: 'lastProgress', key: 'progress', width: 65, render: (v: number) => v != null ? `${Math.round(v * 100)}%` : '-' },
|
||||||
|
{ title: '位置', dataIndex: 'lastPosition', key: 'position', width: 100, ellipsis: true, render: (v?: any) => {
|
||||||
|
if (!v) return '-';
|
||||||
|
const pct = v.scrollProgress != null ? `${Math.round(v.scrollProgress * 100)}%` : '';
|
||||||
|
const type = v.type || '';
|
||||||
|
if (type === 'Markdown' || type === 'Text') return `文本 · ${pct || '—'}`;
|
||||||
|
if (type === 'PDF' || type === 'Image') return `${type} · ${pct || '—'}`;
|
||||||
|
if (type === 'EPUB') return `EPUB · ${pct || '—'}`;
|
||||||
|
return pct || JSON.stringify(v).slice(0, 20);
|
||||||
|
} },
|
||||||
|
{ title: '活跃秒', dataIndex: 'totalActiveSeconds', key: 'seconds', width: 70 },
|
||||||
|
{ title: '已读', dataIndex: 'isMarkedRead', key: 'marked', width: 55, render: (v: boolean) => v ? '✅' : '' },
|
||||||
|
{ title: '更新', dataIndex: 'lastReadAt', key: 'updatedAt', width: 140, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="阅读进度">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Select placeholder="状态" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
|
||||||
|
options={[{ value: 'reading', label: '阅读中' }, { value: 'completed', label: '已完成' }, { value: 'not_started', label: '未开始' }]} />
|
||||||
|
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 1000 }}
|
||||||
|
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
||||||
|
onRow={(r: any) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
|
||||||
|
<Drawer title="进度详情" open={!!detail} onClose={() => setDetail(null)} width={600}>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="目标类型">{detail.readingTargetType || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态"><Tag>{detail.status}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已读">{detail.isMarkedRead ? '✅' : '否'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="进度">{detail.lastProgress != null ? `${Math.round(detail.lastProgress * 100)}%` : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃秒数">{detail.totalActiveSeconds ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后 Session ID" span={2}>{detail.lastClientSessionId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后位置" span={2}><pre style={{ margin: 0, fontSize: 12, maxHeight: 80, overflow: 'auto' }}>{detail.lastPosition ? JSON.stringify(detail.lastPosition, null, 2) : '-'}</pre></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="首次打开">{detail.firstOpenedAt ? dayjs(detail.firstOpenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后阅读">{detail.lastReadAt ? dayjs(detail.lastReadAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联资料" span={2}>{detail.relatedMaterials?.join(', ') || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联事件数">{detail.eventCount ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联会话数">{detail.sessionCount ?? '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/pages/learning/ReadingEventPage.tsx
Normal file
91
src/pages/learning/ReadingEventPage.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Select, Space, Tag, Button, Drawer, Descriptions, DatePicker } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { eventTypeLabels, eventStatusLabels } from '@/constants/labels';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
export default function ReadingEventPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
const csid = params.get('clientSessionId');
|
||||||
|
if (csid) init.clientSessionId = csid;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-events', page, filters],
|
||||||
|
queryFn: () => learningAPI.getReadingEvents({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||||
|
queryKey: ['admin-event-detail', detailId],
|
||||||
|
queryFn: () => learningAPI.getReadingEventDetail(detailId!),
|
||||||
|
enabled: !!detailId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
|
||||||
|
{ title: '类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{eventTypeLabels[t] || t}</Tag> },
|
||||||
|
{ title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||||
|
render: (s: string) => <Tag color={s === 'processed' ? 'green' : s === 'failed' ? 'red' : s === 'duplicate' ? 'orange' : 'blue'}>{eventStatusLabels[s] || s}</Tag> },
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
||||||
|
render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="阅读事件">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Input placeholder="Client Session ID" allowClear onChange={e => setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
|
||||||
|
<Select placeholder="事件类型" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, eventType: v || '' }))}
|
||||||
|
options={Object.entries(eventTypeLabels).map(([v, l]) => ({ value: v, label: l }))} />
|
||||||
|
<Select placeholder="状态" allowClear style={{ width: 110 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
|
||||||
|
options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }, { value: 'pending', label: '待处理' }]} />
|
||||||
|
<RangePicker onChange={(dates) => {
|
||||||
|
if (dates && dates[0] && dates[1]) {
|
||||||
|
setFilters(f => ({ ...f, startDate: dates[0]!.format('YYYY-MM-DD'), endDate: dates[1]!.format('YYYY-MM-DD') }));
|
||||||
|
} else {
|
||||||
|
setFilters(f => { const { startDate, endDate, ...rest } = f; return rest; });
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="eventId" columns={columns} dataSource={data?.items || []}
|
||||||
|
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
||||||
|
onRow={(record: any) => ({ onClick: () => setDetailId(record.eventId), style: { cursor: 'pointer' } })} />
|
||||||
|
<Drawer title="事件详情" open={!!detailId} onClose={() => setDetailId(null)} width={640} loading={detailLoading}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="事件ID" span={2}>{detail.eventId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="事件类型">{detail.eventType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态"><Tag>{detail.status}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃秒数">{detail.activeSecondsDelta ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="序列号">{detail.sequence ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Session ID" span={2}>{detail.clientSessionId ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="readingTargetType">{detail.readingTargetType ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="platform">{detail.platform ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="appVersion">{detail.appVersion ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="timezoneOffset">{detail.clientTimezoneOffsetMinutes ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="错误码">{detail.errorCode || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="重试次数">{detail.retryCount ?? 0}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="knowledgeBaseId">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/pages/learning/RecordPage.tsx
Normal file
52
src/pages/learning/RecordPage.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Space, Tag, Button, Drawer, Descriptions } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { recordTypeLabels, recordTitleLabels } from '@/constants/labels';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function RecordPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-records', page, filters],
|
||||||
|
queryFn: () => learningAPI.getRecords({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || (r.materialId ? r.materialId.slice(0,12) : '-'), width: 100, ellipsis: true },
|
||||||
|
{ title: '标题', dataIndex: 'title', key: 'title', width: 180, render: (t: string) => recordTitleLabels[t] || t },
|
||||||
|
{ title: '类型', dataIndex: 'recordType', key: 'recordType', width: 100, render: (t: string) => <Tag>{recordTypeLabels[t] || t}</Tag> },
|
||||||
|
{ title: '时长', dataIndex: 'durationSeconds', key: 'duration', width: 80 },
|
||||||
|
{ title: '时间', dataIndex: 'occurredAt', key: 'occurredAt', width: 160, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="学习记录">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 700 }}
|
||||||
|
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
||||||
|
onRow={(r: any) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
|
||||||
|
<Drawer title="记录详情" open={!!detail} onClose={() => setDetail(null)} width={500}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="记录类型"><Tag>{recordTypeLabels[detail.recordType] || detail.recordType}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="标题" span={2}>{recordTitleLabels[detail.title] || detail.title || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时长(秒)">{detail.durationSeconds ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联资料ID">{detail.materialId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发生时间" span={2}>{detail.occurredAt ? dayjs(detail.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { Card, Input, Button, Space, message, Modal, Form, DatePicker, Select, D
|
|||||||
import { ThunderboltOutlined, WarningOutlined } from '@ant-design/icons';
|
import { ThunderboltOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { learningAdminAPI } from '../../services/learningAdmin';
|
import { learningAPI } from '@/api';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@ -12,13 +12,13 @@ export default function ReplayPage() {
|
|||||||
const [batchForm] = Form.useForm();
|
const [batchForm] = Form.useForm();
|
||||||
|
|
||||||
const singleMutation = useMutation({
|
const singleMutation = useMutation({
|
||||||
mutationFn: (id: string) => learningAdminAPI.replayEvent(id),
|
mutationFn: (id: string) => learningAPI.replayEvent(id),
|
||||||
onSuccess: (data) => message.success(data.message || '重放成功'),
|
onSuccess: (data) => message.success(data.message || '重放成功'),
|
||||||
onError: (e: Error) => message.error(`重放失败: ${e.message}`),
|
onError: (e: Error) => message.error(`重放失败: ${e.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const batchMutation = useMutation({
|
const batchMutation = useMutation({
|
||||||
mutationFn: (params: Record<string, string>) => learningAdminAPI.replayEventsBatch(params),
|
mutationFn: (params: Record<string, string>) => learningAPI.replayEventsBatch(params),
|
||||||
onSuccess: (data) => message.success(`完成: ${data.replayed} 成功, ${data.failed} 失败`),
|
onSuccess: (data) => message.success(`完成: ${data.replayed} 成功, ${data.failed} 失败`),
|
||||||
onError: (e: Error) => message.error(`批量重放失败: ${e.message}`),
|
onError: (e: Error) => message.error(`批量重放失败: ${e.message}`),
|
||||||
});
|
});
|
||||||
|
|||||||
96
src/pages/learning/SessionPage.tsx
Normal file
96
src/pages/learning/SessionPage.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Select, Space, Tag, Button, Tabs, Drawer, Descriptions, DatePicker, Timeline, Typography, Popconfirm } from 'antd';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { sessionStatusLabels, targetTypeLabels, sessionModeLabels } from '@/constants/labels';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export default function SessionPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-sessions', page, filters],
|
||||||
|
queryFn: () => learningAPI.getSessions({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||||
|
queryKey: ['admin-session-detail', detailId],
|
||||||
|
queryFn: () => learningAPI.getSessionDetail(detailId!),
|
||||||
|
enabled: !!detailId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
await learningAPI.deleteSessions(selectedRowKeys as string[]);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
qc.invalidateQueries({ queryKey: ['admin-sessions'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userName', key: 'userName', width: 140, ellipsis: true, render: (_: any, r: any) => r.userName || r.userId || '-' },
|
||||||
|
{ title: '资料', dataIndex: 'materialName', key: 'materialName', width: 180, ellipsis: true, render: (_: any, r: any) => r.materialName || r.materialId || '-' },
|
||||||
|
{ title: '类型', dataIndex: 'targetType', key: 'targetType', width: 70, render: (v?: string) => v ? <Tag>{targetTypeLabels[v] || v}</Tag> : '-' },
|
||||||
|
{ title: '模式', dataIndex: 'mode', key: 'mode', width: 70, render: (v?: string) => (v ? sessionModeLabels[v] || v : '-') },
|
||||||
|
{ title: '知识库', dataIndex: 'kbName', key: 'kbName', width: 130, ellipsis: true, render: (_: any, r: any) => r.kbName || r.knowledgeBaseId || '-' },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||||
|
render: (s: string) => <Tag color={s === 'active' ? 'green' : s === 'interrupted' ? 'red' : s === 'completed' ? 'blue' : 'orange'}>{sessionStatusLabels[s] || s}</Tag> },
|
||||||
|
{ title: '时长', dataIndex: 'totalActiveSeconds', key: 'seconds', width: 80, render: (v: number) => v ? `${v}s` : '-' },
|
||||||
|
{ title: '开始', dataIndex: 'startedAt', key: 'startedAt', width: 130, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
||||||
|
{ title: '结束', dataIndex: 'endedAt', key: 'endedAt', width: 130, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="学习会话">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input placeholder="用户名/ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Input placeholder="资料名/ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
|
||||||
|
<Select placeholder="状态" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
|
||||||
|
options={[{ value: 'active', label: '活跃' }, { value: 'interrupted', label: '中断' }, { value: 'completed', label: '已完成' }]} />
|
||||||
|
<Button onClick={() => setPage(1)} type="primary">查询</Button>
|
||||||
|
<Popconfirm title="确定删除选中的会话?" onConfirm={handleBatchDelete} okText="确定" cancelText="取消" onCancel={() => {}}>
|
||||||
|
<Button danger disabled={selectedRowKeys.length === 0}>批量删除 ({selectedRowKeys.length})</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 900 }}
|
||||||
|
loading={isLoading} pagination={{ current: page, defaultPageSize: 20, total: data?.total || 0, onChange: setPage }} size="small"
|
||||||
|
rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys) }}
|
||||||
|
onRow={(r: any) => ({ onClick: () => setDetailId(r.id), style: { cursor: 'pointer' } })} />
|
||||||
|
<Drawer title="会话详情" open={!!detailId} onClose={() => setDetailId(null)} width={680} loading={detailLoading}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="Session ID" span={2}>{detail.id}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Client Session ID" span={2}>{detail.clientSessionId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="资料名">{detail.materialName || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="目标类型">{detail.targetType || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模式">{detail.mode || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="知识库名">{detail.kbName || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态"><Tag color={detail.status === 'active' ? 'green' : detail.status === 'interrupted' ? 'red' : 'blue'}>{detail.status}</Tag></Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活跃秒数">{detail.totalActiveSeconds ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="专注时间(分)">{detail.focusMinutes ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="中断">{detail.interrupted ? <Tag color="red">是</Tag> : '否'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="缺 MaterialClosed">{detail.missingMaterialClosed ? <Tag color="orange">是</Tag> : '否'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="异常 Delta">{detail.abnormalDelta ? <Tag color="red">{detail.abnormalDelta}</Tag> : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开始时间" span={2}>{detail.startedAt ? dayjs(detail.startedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结束时间" span={2}>{detail.endedAt ? dayjs(detail.endedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后事件时间" span={2}>{detail.lastEventAt ? dayjs(detail.lastEventAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联事件数">{detail.eventCount ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时长组成">{detail.durationBreakdown || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
{detail?.clientSessionId && (
|
||||||
|
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||||
|
<Button type="primary" onClick={() => window.location.href = `/learning/events?clientSessionId=${encodeURIComponent(detail.clientSessionId)}`}>查看关联事件</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/pages/learning/UserDiagnosePage.tsx
Normal file
24
src/pages/learning/UserDiagnosePage.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Input, Space, Button } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
|
||||||
|
export default function UserDiagnosePage() {
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const { data, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-user-diag', userId],
|
||||||
|
queryFn: () => learningAPI.getUserDiagnose(userId),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="用户诊断">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="User ID" value={userId} onChange={e => setUserId(e.target.value)} style={{ width: 300 }} />
|
||||||
|
<Button type="primary" onClick={() => refetch()}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
{data ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data, null, 2)}</pre> : null}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/pages/learning/UserTimelinePage.tsx
Normal file
48
src/pages/learning/UserTimelinePage.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Input, Space, Tag, Button, Typography, Timeline } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAPI } from '@/api';
|
||||||
|
import { recordTypeLabels } from '@/constants/labels';
|
||||||
|
import { PageWrapper } from './PageWrapper';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function UserTimelinePage() {
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const { data, isFetching, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-user-timeline', userId],
|
||||||
|
queryFn: () => learningAPI.getUserTimeline(userId),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = Array.isArray(data)
|
||||||
|
? (data as any[]).map((entry: any, i: number) => ({
|
||||||
|
key: i,
|
||||||
|
color: entry.recordType === 'session' ? 'blue' : entry.recordType === 'reading' ? 'green' : 'gray',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{entry.occurredAt ? dayjs(entry.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Text>
|
||||||
|
<div><Tag style={{ marginTop: 4 }}>{recordTypeLabels[entry.recordType] || entry.recordType}</Tag></div>
|
||||||
|
<Text strong>{entry.title || '-'}</Text>
|
||||||
|
{entry.durationSeconds != null && <div><Text type="secondary">时长 {entry.durationSeconds}s</Text></div>}
|
||||||
|
{entry.materialId && <div><Text type="secondary">资料 {entry.materialId}</Text></div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="用户学习时间线">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="User ID" value={userId} onChange={e => setUserId(e.target.value)} style={{ width: 300 }} />
|
||||||
|
<Button type="primary" onClick={() => refetch()} loading={isFetching}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
{data === undefined && !isFetching && <Text type="secondary">输入用户 ID 后点击查询</Text>}
|
||||||
|
{isFetching && <Text type="secondary">加载中...</Text>}
|
||||||
|
{data && !Array.isArray(data) ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data as any, null, 2)}</pre> : null}
|
||||||
|
{Array.isArray(data) && data.length === 0 && !isFetching && <Text type="secondary">该用户暂无学习记录</Text>}
|
||||||
|
{items.length > 0 && <Timeline items={items} />}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,132 +0,0 @@
|
|||||||
import type {
|
|
||||||
AdminUser,
|
|
||||||
DashboardStats,
|
|
||||||
AuditLog,
|
|
||||||
} from '@/types/admin'
|
|
||||||
import type { PaginatedResult, PaginationParams } from '@/types/api'
|
|
||||||
import { api } from './http-client'
|
|
||||||
import { MOCK_DASHBOARD_STATS, MOCK_AUDIT_LOGS } from './mock-data'
|
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface LoginResponse {
|
|
||||||
accessToken: string
|
|
||||||
refreshToken: string
|
|
||||||
adminUser: AdminUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loginAdmin(email: string, password: string): Promise<LoginResponse> {
|
|
||||||
return api.post<LoginResponse>('/admin-api/auth/login', { email, password })
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TokenPair {
|
|
||||||
accessToken: string
|
|
||||||
refreshToken: string
|
|
||||||
adminUser: AdminUser
|
|
||||||
}
|
|
||||||
|
|
||||||
export function refreshAdminToken(refreshToken: string): Promise<TokenPair> {
|
|
||||||
return api.post<TokenPair>('/admin-api/auth/refresh', { refreshToken })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logoutAdmin(refreshToken: string): Promise<void> {
|
|
||||||
return api.post<void>('/admin-api/auth/logout', { refreshToken })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCurrentAdmin(): Promise<AdminUser> {
|
|
||||||
return api.get<AdminUser>('/admin-api/auth/me')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dashboard ─────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
|
||||||
if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS
|
|
||||||
return api.get<DashboardStats>('/admin-api/dashboard/stats')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin Users ───────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AdminUsersQuery extends PaginationParams {
|
|
||||||
search?: string
|
|
||||||
role?: string
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminUsers(
|
|
||||||
params: AdminUsersQuery,
|
|
||||||
): Promise<PaginatedResult<AdminUser>> {
|
|
||||||
try {
|
|
||||||
return await api.get<PaginatedResult<AdminUser>>(
|
|
||||||
`/admin-api/admin-users?${new URLSearchParams(params as Record<string, string>).toString()}`,
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
return {
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
page: params.page ?? 1,
|
|
||||||
limit: params.limit ?? 20,
|
|
||||||
totalPages: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('获取管理员列表失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminUserById(id: string): Promise<AdminUser> {
|
|
||||||
return api.get<AdminUser>(`/admin-api/admin-users/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminUser(data: {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
displayName: string
|
|
||||||
role: string
|
|
||||||
}): Promise<AdminUser> {
|
|
||||||
return api.post<AdminUser>('/admin-api/admin-users', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAdminUser(
|
|
||||||
id: string,
|
|
||||||
data: { role?: string; status?: string; displayName?: string },
|
|
||||||
): Promise<AdminUser> {
|
|
||||||
return api.put<AdminUser>(`/admin-api/admin-users/${id}`, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteAdminUser(id: string): Promise<void> {
|
|
||||||
return api.delete<void>(`/admin-api/admin-users/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audit Logs ────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AuditLogsQuery extends PaginationParams {
|
|
||||||
adminUserId?: string
|
|
||||||
action?: string
|
|
||||||
startDate?: string
|
|
||||||
endDate?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuditLogs(
|
|
||||||
params: AuditLogsQuery,
|
|
||||||
): Promise<PaginatedResult<AuditLog>> {
|
|
||||||
try {
|
|
||||||
return await api.get<PaginatedResult<AuditLog>>(
|
|
||||||
`/admin-api/audit-logs?${new URLSearchParams(params as Record<string, string>).toString()}`,
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
return {
|
|
||||||
items: MOCK_AUDIT_LOGS,
|
|
||||||
total: MOCK_AUDIT_LOGS.length,
|
|
||||||
page: params.page ?? 1,
|
|
||||||
limit: params.limit ?? 20,
|
|
||||||
totalPages: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('获取审计日志失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuditLogById(id: string): Promise<AuditLog> {
|
|
||||||
return api.get<AuditLog>(`/admin-api/audit-logs/${id}`)
|
|
||||||
}
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
const BASE_URL = ''
|
|
||||||
|
|
||||||
interface ApiResponse<T = unknown> {
|
|
||||||
success: boolean
|
|
||||||
data: T
|
|
||||||
message?: string
|
|
||||||
statusCode?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function getToken(): string | null {
|
|
||||||
return localStorage.getItem('admin_access_token')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const token = getToken()
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers as Record<string, string>),
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers })
|
|
||||||
|
|
||||||
if (res.status === 401) {
|
|
||||||
const refreshed = await tryRefresh()
|
|
||||||
if (refreshed) {
|
|
||||||
headers['Authorization'] = `Bearer ${getToken()}`
|
|
||||||
const retryRes = await fetch(`${BASE_URL}${path}`, { ...options, headers })
|
|
||||||
return retryRes.json()
|
|
||||||
}
|
|
||||||
localStorage.removeItem('admin_access_token')
|
|
||||||
localStorage.removeItem('admin_refresh_token')
|
|
||||||
localStorage.removeItem('admin_user')
|
|
||||||
window.location.href = '/login'
|
|
||||||
throw new Error('登录已过期')
|
|
||||||
}
|
|
||||||
|
|
||||||
const json: ApiResponse<T> = await res.json()
|
|
||||||
if (!json.success) {
|
|
||||||
throw new Error(json.message || '请求失败')
|
|
||||||
}
|
|
||||||
return json.data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryRefresh(): Promise<boolean> {
|
|
||||||
const refreshToken = localStorage.getItem('admin_refresh_token')
|
|
||||||
if (!refreshToken) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE_URL}/admin-api/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refreshToken }),
|
|
||||||
})
|
|
||||||
if (!res.ok) return false
|
|
||||||
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json()
|
|
||||||
if (json.success) {
|
|
||||||
localStorage.setItem('admin_access_token', json.data.accessToken)
|
|
||||||
localStorage.setItem('admin_refresh_token', json.data.refreshToken)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get<T>(path: string) {
|
|
||||||
return request<T>(path)
|
|
||||||
},
|
|
||||||
post<T>(path: string, body?: unknown) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'POST',
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
put<T>(path: string, body?: unknown) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
delete<T>(path: string, body?: unknown) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
export interface BillingInfo {
|
|
||||||
name: string; model: string; balance: string; currency: string; status: 'ok' | 'unknown'; consoleUrl: string; note: string
|
|
||||||
}
|
|
||||||
export function getBilling(): Promise<{ providers: BillingInfo[] }> { return api.get('/admin-api/billing') }
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
|
|
||||||
export interface AppConfigItem { id: string; key: string; value: string; description: string | null; environment: string; updatedAt: string }
|
|
||||||
export interface FeatureFlagItem { id: string; name: string; enabled: boolean; description: string | null; rolloutPct: number; whitelist?: string | null }
|
|
||||||
export interface ChangeLogItem { id: string; entityType: string; entityId: string; field: string; oldValue: string | null; newValue: string | null; changedBy: string | null; createdAt: string }
|
|
||||||
|
|
||||||
export function getConfig(): Promise<{ configs: AppConfigItem[]; flags: FeatureFlagItem[] }> { return api.get('/admin-api/config') }
|
|
||||||
export function setConfig(key: string, value: string): Promise<any> { return api.post('/admin-api/config', { key, value }) }
|
|
||||||
export function updateConfig(key: string, value: string): Promise<any> { return api.patch(`/admin-api/config/${key}`, { value }) }
|
|
||||||
export function deleteConfig(key: string): Promise<any> { return api.delete(`/admin-api/config/${key}`) }
|
|
||||||
export function toggleFlag(name: string, enabled: boolean): Promise<any> { return api.post(`/admin-api/config/flags/${name}`, { enabled }) }
|
|
||||||
export function setFlagWhitelist(name: string, whitelist: string): Promise<any> { return api.post(`/admin-api/config/flags/${name}`, { whitelist }) }
|
|
||||||
export function getChangeLog(): Promise<ChangeLogItem[]> { return api.get('/admin-api/config/changelog') }
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
|
|
||||||
export interface Conversation {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageRecord {
|
|
||||||
id: string
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
content: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listConversations(): Promise<Conversation[]> {
|
|
||||||
return api.get('/admin-api/conversations')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMessages(conversationId: string): Promise<MessageRecord[]> {
|
|
||||||
return api.get(`/admin-api/conversations/${conversationId}/messages`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createConversation(title?: string): Promise<Conversation> {
|
|
||||||
return api.post('/admin-api/conversations', { title })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateConversation(id: string, title: string): Promise<void> {
|
|
||||||
return api.patch(`/admin-api/conversations/${id}`, { title })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteConversation(id: string): Promise<void> {
|
|
||||||
return api.delete(`/admin-api/conversations/${id}`)
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
|
|
||||||
export interface CostItem { id: string; name: string; category: string; amount: number; currency: string; purchaseDate: string; expiryDate: string | null; billingCycle: string; note: string | null }
|
|
||||||
export interface CostSummary { totalMonthly: number; totalYearly: number; totalOneTime: number; items: CostItem[]; byMonth: { month: string; total: number; items: { name: string; amount: number }[] }[]; expiringSoon: (CostItem & { daysLeft: number })[] }
|
|
||||||
|
|
||||||
export function getCostSummary(): Promise<CostSummary> { return api.get('/admin-api/costs/summary') }
|
|
||||||
export function listCosts(): Promise<CostItem[]> { return api.get('/admin-api/costs') }
|
|
||||||
export function createCost(data: Omit<CostItem, 'id' | 'currency'> & { currency?: string }): Promise<CostItem> { return api.post('/admin-api/costs', data) }
|
|
||||||
export function updateCost(id: string, data: Partial<CostItem>): Promise<CostItem> { return api.patch(`/admin-api/costs/${id}`, data) }
|
|
||||||
export function deleteCost(id: string): Promise<void> { return api.delete(`/admin-api/costs/${id}`) }
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
|
|
||||||
export interface QueueInfo { name: string; waiting: number; active: number; completed: number; failed: number; delayed: number; total: number }
|
|
||||||
export interface FailedJob { id: string; name: string; timestamp: number; attemptsMade: number; failedReason?: string }
|
|
||||||
export interface JobDetail extends FailedJob { state: string; data: any; stacktrace?: string[] }
|
|
||||||
|
|
||||||
export function getQueueOverview(): Promise<{ queues: QueueInfo[] }> { return api.get('/admin-api/events') }
|
|
||||||
export function getFailedJobs(queue: string): Promise<{ jobs: FailedJob[] }> { return api.get(`/admin-api/events/${queue}/failed`) }
|
|
||||||
export function getJobDetail(queue: string, jobId: string): Promise<JobDetail> { return api.get(`/admin-api/events/${queue}/jobs/${jobId}`) }
|
|
||||||
export function retryJob(queue: string, jobId: string): Promise<any> { return api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`) }
|
|
||||||
@ -1,121 +1,3 @@
|
|||||||
import {
|
// Re-export from centralized lib — backward compatible for existing pages
|
||||||
getAccessToken,
|
export { api, ApiError } from '../lib/api-client'
|
||||||
getRefreshToken,
|
export { default } from '../lib/api-client'
|
||||||
setTokens,
|
|
||||||
clearTokens,
|
|
||||||
} from './token-store'
|
|
||||||
|
|
||||||
interface ApiResponse<T = unknown> {
|
|
||||||
success: boolean
|
|
||||||
data: T
|
|
||||||
message?: string
|
|
||||||
statusCode?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
httpStatus: number
|
|
||||||
code: number
|
|
||||||
constructor(message: string, httpStatus: number, code?: number) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'ApiError'
|
|
||||||
this.httpStatus = httpStatus
|
|
||||||
this.code = code ?? httpStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshPromise: Promise<boolean> | null = null
|
|
||||||
|
|
||||||
async function tryRefresh(): Promise<boolean> {
|
|
||||||
const token = getRefreshToken()
|
|
||||||
if (!token) return false
|
|
||||||
|
|
||||||
if (!refreshPromise) {
|
|
||||||
refreshPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/admin-api/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refreshToken: token }),
|
|
||||||
})
|
|
||||||
if (!res.ok) return false
|
|
||||||
const json: ApiResponse<{ accessToken: string; refreshToken: string }> = await res.json()
|
|
||||||
if (json.success) {
|
|
||||||
setTokens(json.data.accessToken, json.data.refreshToken)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
refreshPromise = null
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
return refreshPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
retried = false,
|
|
||||||
): Promise<T> {
|
|
||||||
const token = getAccessToken()
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers as Record<string, string>),
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const { signal, ...restOpts } = options;
|
|
||||||
const res = await fetch(path, { ...restOpts, headers, signal: signal ?? undefined })
|
|
||||||
|
|
||||||
if (res.status === 401 && !retried) {
|
|
||||||
const refreshed = await tryRefresh()
|
|
||||||
if (refreshed) {
|
|
||||||
return request<T>(path, options, true)
|
|
||||||
}
|
|
||||||
clearTokens()
|
|
||||||
window.location.href = '/login'
|
|
||||||
throw new ApiError('登录已过期', 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
const json: ApiResponse<T> = await res.json()
|
|
||||||
if (!json.success) {
|
|
||||||
throw new ApiError(json.message || '请求失败', res.status, json.statusCode)
|
|
||||||
}
|
|
||||||
return json.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get<T>(path: string) {
|
|
||||||
return request<T>(path)
|
|
||||||
},
|
|
||||||
post<T>(path: string, body?: unknown, extra?: RequestInit & { signal?: AbortSignal }) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'POST',
|
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
|
||||||
...extra,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
put<T>(path: string, body?: unknown) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
patch<T>(path: string, body?: unknown) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
delete<T>(path: string, body?: unknown) {
|
|
||||||
return request<T>(path, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
|
|
||||||
export interface KnowledgeBase {
|
|
||||||
id: string; title: string; description: string | null; status: string; itemCount: number;
|
|
||||||
user?: { nickname: string | null; email: string };
|
|
||||||
createdAt: string; updatedAt: string;
|
|
||||||
sources?: any[]; _count?: { items: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getKnowledgeBases(page = 1, limit = 20): Promise<{ items: KnowledgeBase[]; total: number; page: number; limit: number; totalPages: number }> {
|
|
||||||
return api.get(`/admin-api/knowledge-bases?page=${page}&limit=${limit}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getKnowledgeBase(id: string): Promise<KnowledgeBase> { return api.get(`/admin-api/knowledge-bases/${id}`) }
|
|
||||||
export function deleteKnowledgeBase(id: string): Promise<void> { return api.delete(`/admin-api/knowledge-bases/${id}`) }
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
const BASE = '/admin-api/learning';
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnomalyData {
|
|
||||||
deltaOutliers: unknown[];
|
|
||||||
futureEvents: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getApi<T>(path: string, params?: Record<string, any>): Promise<T> {
|
|
||||||
let url = `${BASE}${path}`;
|
|
||||||
if (params) {
|
|
||||||
const qs = Object.entries(params)
|
|
||||||
.filter(([, v]) => v !== undefined && v !== '')
|
|
||||||
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
||||||
.join('&');
|
|
||||||
if (qs) url += `?${qs}`;
|
|
||||||
}
|
|
||||||
const res = await fetch(url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('admin_access_token') || ''}` } });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
return json.data ?? json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postApi<T>(path: string, body?: any): Promise<T> {
|
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('admin_access_token') || ''}` },
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
return json.data ?? json;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardData {
|
|
||||||
overview: { totalEvents: number; todayEvents: number; failedEvents: number; duplicateEvents: number; warningEvents: number; totalActiveSeconds: number };
|
|
||||||
sessions: { active: number; interrupted: number; completed: number; total: number };
|
|
||||||
users: { activeToday: number; totalWithEvents: number };
|
|
||||||
materials: { totalRead: number; totalMarkedRead: number };
|
|
||||||
recentAnomalies: Array<{ eventId: string; userId: string; materialId: string; eventType: string; errorCode?: string; occurredAt: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const learningAdminAPI = {
|
|
||||||
getDashboard: (params?: { knowledgeBaseId?: string; startDate?: string; endDate?: string }) =>
|
|
||||||
getApi<DashboardData>('/dashboard', params),
|
|
||||||
getKnowledgeBases: () => getApi<Array<{ id: string; name: string }>>('/knowledge-bases'),
|
|
||||||
getReadingEvents: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/reading-events', params),
|
|
||||||
getReadingEventDetail: (id: string) => getApi<any>(`/reading-events/${id}`),
|
|
||||||
getFailedEvents: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/reading-events/failed', params),
|
|
||||||
getSessions: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/sessions', params),
|
|
||||||
getSessionDetail: (id: string) => getApi<any>(`/sessions/${id}`),
|
|
||||||
getProgress: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/progress', params),
|
|
||||||
getDailyActivities: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/daily-activities', params),
|
|
||||||
getRecords: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/records', params),
|
|
||||||
getUserTimeline: (userId: string) => getApi<unknown>('/user-timeline', { userId }),
|
|
||||||
getUserDiagnose: (userId: string) => getApi<unknown>('/user-diagnose', { userId }),
|
|
||||||
getMaterialDiagnose: (materialId: string) => getApi<unknown>('/material-diagnose', { materialId }),
|
|
||||||
getAnomalies: () => getApi<AnomalyData>('/anomalies'),
|
|
||||||
getTemporaryMaterials: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/temporary-materials', params),
|
|
||||||
recalculate: () => postApi<unknown>('/recalculate'),
|
|
||||||
replayEvent: (eventId: string) => postApi<{ success: boolean; message: string }>(`/reading-events/${eventId}/replay`),
|
|
||||||
replayEventsBatch: (params: { userId?: string; materialId?: string; startDate?: string; endDate?: string; eventType?: string }) =>
|
|
||||||
postApi<{ replayed: number; failed: number }>('/reading-events/replay-batch', params),
|
|
||||||
exportData: (type: string) => getApi<unknown>('/export', { type }),
|
|
||||||
};
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { api } from './http-client'
|
|
||||||
|
|
||||||
export interface DiskInfo { mount: string; total: string; used: string; free: string; percent: number }
|
|
||||||
export interface ProcessInfo { pid: number; cpu: string; mem: string; name: string; desc: string; command: string }
|
|
||||||
export interface ServerInfo {
|
|
||||||
name: string; role: string; hostname: string;
|
|
||||||
cpu: { model: string; cores: number; usagePercent: number };
|
|
||||||
memory: { total: string; used: string; free: string; percent: number };
|
|
||||||
disks: DiskInfo[];
|
|
||||||
uptime: string;
|
|
||||||
processes: ProcessInfo[];
|
|
||||||
network: { publicIp: string; privateIp: string; domains: string[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceHealthItem {
|
|
||||||
serviceName: string; status: string; message: string;
|
|
||||||
}
|
|
||||||
export interface ServerHealth {
|
|
||||||
serverName: string; services: ServiceHealthItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerMetrics(): Promise<{ servers: ServerInfo[] }> {
|
|
||||||
return api.get('/admin-api/servers/metrics')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServerHealth(): Promise<ServerHealth[]> {
|
|
||||||
return api.get('/admin-api/servers/health')
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkerInfo {
|
|
||||||
instanceId: string
|
|
||||||
appVersion: string
|
|
||||||
startedAt: string
|
|
||||||
queues: string[]
|
|
||||||
hostname: string
|
|
||||||
lastHeartbeatAgo: number
|
|
||||||
status: 'online' | 'stale' | 'offline'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkerStatus(): Promise<{ workers: WorkerInfo[] }> {
|
|
||||||
return api.get('/admin-api/servers/workers')
|
|
||||||
}
|
|
||||||
@ -1,35 +1,9 @@
|
|||||||
const ACCESS_TOKEN_KEY = 'admin_access_token'
|
// Re-export from centralized lib — backward compatible for existing imports
|
||||||
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
export {
|
||||||
const ADMIN_USER_KEY = 'admin_user'
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
export function getAccessToken(): string | null {
|
setTokens,
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY)
|
clearTokens,
|
||||||
}
|
getStoredAdminUser,
|
||||||
|
setStoredAdminUser,
|
||||||
export function getRefreshToken(): string | null {
|
} from '../lib/token'
|
||||||
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setTokens(access: string, refresh: string): void {
|
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, access)
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearTokens(): void {
|
|
||||||
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
|
||||||
localStorage.removeItem(ADMIN_USER_KEY)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStoredAdminUser<T>(): T | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(ADMIN_USER_KEY)
|
|
||||||
return raw ? (JSON.parse(raw) as T) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setStoredAdminUser<T>(user: T): void {
|
|
||||||
localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user))
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user