feat: HTTP 层重构 + API 抽离 + 状态管理优化 + 路由修复
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:
wangdl 2026-07-01 22:44:51 +08:00
parent 7c802bdf47
commit 8663ceba66
100 changed files with 2002 additions and 1363 deletions

388
package-lock.json generated
View File

@ -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"
} }

View File

@ -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",

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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),
}

View File

@ -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: '阅读进度' },

View File

@ -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 ──

View File

@ -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()
}, },

View File

@ -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
View 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
View 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
View 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))
}

View File

@ -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() },
}) })

View File

@ -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 (

View File

@ -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'] }) },
}) })

View File

@ -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) }

View File

@ -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'] }),
}) })

View File

@ -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,
}) })

View File

@ -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'] }) },
}) })

View File

@ -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'] }) }} />
)}, )},
] ]

View File

@ -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'] })
}, },
}) })

View File

@ -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,
}) })

View File

@ -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'] })
} }

View File

@ -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 = [

View File

@ -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'] }) },
}) })

View File

@ -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 && (

View File

@ -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 = [

View File

@ -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))
} }

View File

@ -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,
}) })

View File

@ -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',
}) })

View File

@ -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'] })
}, },

View File

@ -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'] })

View File

@ -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 },

View File

@ -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'] }),
}) })

View File

@ -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'] }),
}) })

View File

@ -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('导出失败')
} }

View File

@ -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,

View File

@ -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 = [

View File

@ -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') },

View File

@ -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,
}) })

View File

@ -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' ? '已拒绝' : '已批准')
} }

View File

@ -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 = [

View File

@ -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'] })

View File

@ -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 {

View 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>
);
}

View 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>
);
}

View File

@ -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],

View File

@ -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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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}`),
}); });

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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}`)
}

View File

@ -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,
})
},
}

View File

@ -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') }

View File

@ -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') }

View File

@ -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}`)
}

View File

@ -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}`) }

View File

@ -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`) }

View File

@ -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,
})
},
}

View File

@ -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}`) }

View File

@ -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 }),
};

View File

@ -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')
}

View File

@ -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))
}