diff --git a/package-lock.json b/package-lock.json index dc00426..4413e25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@ant-design/pro-components": "^2.8.10", "@tanstack/react-query": "^5.100.11", "antd": "^5.29.3", + "axios": "^1.18.1", "dayjs": "^1.11.20", "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", @@ -754,7 +755,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^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", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1063,10 +1062,33 @@ "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": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1755,6 +1777,40 @@ "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": { "version": "1.0.2", "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==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2189,7 +2244,6 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2265,7 +2319,6 @@ "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", @@ -2517,7 +2570,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2544,6 +2596,18 @@ "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": { "version": "6.15.0", "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", "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", "license": "MIT", - "peer": true, "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", @@ -2668,6 +2731,24 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", @@ -2734,7 +2815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2749,6 +2829,19 @@ "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": { "version": "1.0.30001793", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -2835,6 +2928,18 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2944,6 +3049,15 @@ "dev": true, "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": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", @@ -2976,6 +3090,20 @@ "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": { "version": "6.1.0", "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", @@ -3027,6 +3155,51 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -3056,7 +3229,6 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3351,6 +3523,42 @@ "dev": true, "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": { "version": "0.2.2", "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_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": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3384,6 +3601,43 @@ "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": { "version": "6.0.2", "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3410,6 +3664,18 @@ "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": { "version": "4.2.11", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3417,6 +3683,45 @@ "dev": true, "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": { "version": "4.0.0", "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" } }, + "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": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -4098,6 +4416,15 @@ "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": { "version": "3.0.2", "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" }, + "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": { "version": "10.2.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", @@ -5127,7 +5475,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5210,6 +5557,15 @@ "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": { "version": "2.3.1", "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", "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/async-validator": "^5.0.3", @@ -5832,7 +6187,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5842,7 +6196,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6347,7 +6700,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6558,7 +6910,6 @@ "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6692,7 +7043,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index df321b9..44229e5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@ant-design/pro-components": "^2.8.10", "@tanstack/react-query": "^5.100.11", "antd": "^5.29.3", + "axios": "^1.18.1", "dayjs": "^1.11.20", "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", diff --git a/src/App.tsx b/src/App.tsx index cf83898..dc6215b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ import { Suspense, lazy } from 'react' import { BrowserRouter, Routes, Route } from 'react-router-dom' 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 { Empty } from 'antd' +import { StaticAntdProvider } from './lib/antd-global' import { AuthProvider } from './contexts/AuthContext' import AuthGuard from './components/AuthGuard' import PermissionGuard from './components/PermissionGuard' @@ -45,15 +46,15 @@ const NotificationAdmin = lazy(() => import('./pages/NotificationAdmin')) const CacheAdmin = lazy(() => import('./pages/CacheAdmin')) const LearningData = lazy(() => import('./pages/LearningData')) const LearningDashboard = lazy(() => import('./pages/learning/Dashboard')) -const ReadingEventPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.ReadingEventPage }))) -const SessionPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.SessionPage }))) -const ProgressPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.ProgressPage }))) -const DailyActivityPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.DailyActivityPage }))) -const RecordPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.RecordPage }))) -const AnomalyPage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.AnomalyPage }))) -const UserTimelinePage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.UserTimelinePage }))) -const UserDiagnosePage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.UserDiagnosePage }))) -const MaterialDiagnosePage = lazy(() => import('./pages/learning/DataPages').then(m => ({ default: m.MaterialDiagnosePage }))) +const ReadingEventPage = lazy(() => import('./pages/learning/ReadingEventPage')) +const SessionPage = lazy(() => import('./pages/learning/SessionPage')) +const ProgressPage = lazy(() => import('./pages/learning/ProgressPage')) +const DailyActivityPage = lazy(() => import('./pages/learning/DailyActivityPage')) +const RecordPage = lazy(() => import('./pages/learning/RecordPage')) +const AnomalyPage = lazy(() => import('./pages/learning/AnomalyPage')) +const UserTimelinePage = lazy(() => import('./pages/learning/UserTimelinePage')) +const UserDiagnosePage = lazy(() => import('./pages/learning/UserDiagnosePage')) +const MaterialDiagnosePage = lazy(() => import('./pages/learning/MaterialDiagnosePage')) const LearningReplay = lazy(() => import('./pages/learning/ReplayPage')) const BackupAdmin = lazy(() => import('./pages/BackupAdmin')) const KnowledgeSourcesPage = lazy(() => import('./pages/KnowledgeSources')) @@ -65,6 +66,8 @@ function App() { return ( }> + + }> @@ -194,7 +197,7 @@ function App() { /> }>} /> }>} /> - }>} /> + }>} /> }>} /> }>} /> }>} /> @@ -216,6 +219,7 @@ function App() { + ) diff --git a/src/api/ai-gateway.ts b/src/api/ai-gateway.ts new file mode 100644 index 0000000..0a72446 --- /dev/null +++ b/src/api/ai-gateway.ts @@ -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 }) }, +} diff --git a/src/api/ai.ts b/src/api/ai.ts new file mode 100644 index 0000000..0984764 --- /dev/null +++ b/src/api/ai.ts @@ -0,0 +1,15 @@ +import { api } from '@/lib/api-client' + +export const aiAPI = { + sendMessage(messages: { role: string; content: string }[], conversationId?: string) { + const body: Record = { 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 }) + }, +} diff --git a/src/api/audit.ts b/src/api/audit.ts new file mode 100644 index 0000000..3652229 --- /dev/null +++ b/src/api/audit.ts @@ -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>( + `/admin-api/audit-logs?${new URLSearchParams(params as Record).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(`/admin-api/audit-logs/${id}`) }, +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..069634b --- /dev/null +++ b/src/api/auth.ts @@ -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('/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('/admin-api/auth/logout', { refreshToken }) + }, + me() { + return api.get('/admin-api/auth/me') + }, +} diff --git a/src/api/backup.ts b/src/api/backup.ts new file mode 100644 index 0000000..7f19c06 --- /dev/null +++ b/src/api/backup.ts @@ -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}`), +} diff --git a/src/api/billing.ts b/src/api/billing.ts new file mode 100644 index 0000000..de5b5c8 --- /dev/null +++ b/src/api/billing.ts @@ -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') }, +} diff --git a/src/api/cache.ts b/src/api/cache.ts new file mode 100644 index 0000000..6ae6f85 --- /dev/null +++ b/src/api/cache.ts @@ -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'), +} diff --git a/src/api/chat-logs.ts b/src/api/chat-logs.ts new file mode 100644 index 0000000..e29aa8e --- /dev/null +++ b/src/api/chat-logs.ts @@ -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`), +} diff --git a/src/api/compliance.ts b/src/api/compliance.ts new file mode 100644 index 0000000..ef47ddc --- /dev/null +++ b/src/api/compliance.ts @@ -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'), +} diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..2ae217d --- /dev/null +++ b/src/api/config.ts @@ -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('/admin-api/config/changelog') }, +} diff --git a/src/api/content-safety.ts b/src/api/content-safety.ts new file mode 100644 index 0000000..f0f02cf --- /dev/null +++ b/src/api/content-safety.ts @@ -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) }, +} diff --git a/src/api/conversations.ts b/src/api/conversations.ts new file mode 100644 index 0000000..0c0747a --- /dev/null +++ b/src/api/conversations.ts @@ -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('/admin-api/conversations') }, + messages(conversationId: string) { return api.get(`/admin-api/conversations/${conversationId}/messages`) }, + create(title?: string) { return api.post('/admin-api/conversations', { title }) }, + update(id: string, title: string) { return api.patch(`/admin-api/conversations/${id}`, { title }) }, + remove(id: string) { return api.delete(`/admin-api/conversations/${id}`) }, +} diff --git a/src/api/costs.ts b/src/api/costs.ts new file mode 100644 index 0000000..e63a51f --- /dev/null +++ b/src/api/costs.ts @@ -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 }>('/admin-api/costs/summary') }, + list() { return api.get('/admin-api/costs') }, + create(data: Omit & { currency?: string }) { return api.post('/admin-api/costs', data) }, + update(id: string, data: Partial) { return api.patch(`/admin-api/costs/${id}`, data) }, + remove(id: string) { return api.delete(`/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') }, +} diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts new file mode 100644 index 0000000..6afeda6 --- /dev/null +++ b/src/api/dashboard.ts @@ -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('/admin-api/dashboard/stats') + }, +} diff --git a/src/api/events-queue.ts b/src/api/events-queue.ts new file mode 100644 index 0000000..c565721 --- /dev/null +++ b/src/api/events-queue.ts @@ -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(`/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 }) }, +} diff --git a/src/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..4c55aee --- /dev/null +++ b/src/api/files.ts @@ -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}`), +} diff --git a/src/api/hermes.ts b/src/api/hermes.ts new file mode 100644 index 0000000..d7288d4 --- /dev/null +++ b/src/api/hermes.ts @@ -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`), +} diff --git a/src/api/imports.ts b/src/api/imports.ts new file mode 100644 index 0000000..59c823d --- /dev/null +++ b/src/api/imports.ts @@ -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 }), +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..32aefc4 --- /dev/null +++ b/src/api/index.ts @@ -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' diff --git a/src/api/knowledge.ts b/src/api/knowledge.ts new file mode 100644 index 0000000..07df741 --- /dev/null +++ b/src/api/knowledge.ts @@ -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(`/admin-api/knowledge-bases/${id}`) }, + remove(id: string) { return api.delete(`/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}`) }, +} diff --git a/src/api/learning.ts b/src/api/learning.ts new file mode 100644 index 0000000..87be7bd --- /dev/null +++ b/src/api/learning.ts @@ -0,0 +1,50 @@ +import { api } from '@/lib/api-client' + +const BASE = '/admin-api/learning' + +function qs(params?: Record): 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 { items: T[]; total: number } + +export const learningAPI = { + // dashboard + getDashboard: (params?: Record) => api.get(`${BASE}/dashboard${qs(params)}`), + getKnowledgeBases: () => api.get>(`${BASE}/knowledge-bases`), + + // reading events + getReadingEvents: (params?: Record) => api.get>(`${BASE}/reading-events${qs(params)}`), + getReadingEventDetail: (id: string) => api.get(`${BASE}/reading-events/${id}`), + getFailedEvents: (params?: Record) => api.get>(`${BASE}/reading-events/failed${qs(params)}`), + + // sessions + getSessions: (params?: Record) => api.get>(`${BASE}/sessions${qs(params)}`), + getSessionDetail: (id: string) => api.get(`${BASE}/sessions/${id}`), + deleteSessions: (ids: string[]) => api.post<{ deleted: number; message: string }>(`${BASE}/sessions/batch-delete`, { ids }), + + // progress & activities + getProgress: (params?: Record) => api.get>(`${BASE}/progress${qs(params)}`), + getDailyActivities: (params?: Record) => api.get>(`${BASE}/daily-activities${qs(params)}`), + getRecords: (params?: Record) => api.get>(`${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(`${BASE}/anomalies`), + getTemporaryMaterials: (params?: Record) => api.get>(`${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) => 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}`), +} diff --git a/src/api/members.ts b/src/api/members.ts new file mode 100644 index 0000000..82951e8 --- /dev/null +++ b/src/api/members.ts @@ -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), +} diff --git a/src/api/metrics.ts b/src/api/metrics.ts new file mode 100644 index 0000000..5429a6c --- /dev/null +++ b/src/api/metrics.ts @@ -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'), +} diff --git a/src/api/notifications.ts b/src/api/notifications.ts new file mode 100644 index 0000000..5f0fec6 --- /dev/null +++ b/src/api/notifications.ts @@ -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`), +} diff --git a/src/api/quota.ts b/src/api/quota.ts new file mode 100644 index 0000000..9fd86cb --- /dev/null +++ b/src/api/quota.ts @@ -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'), +} diff --git a/src/api/release.ts b/src/api/release.ts new file mode 100644 index 0000000..528006e --- /dev/null +++ b/src/api/release.ts @@ -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 }), +} diff --git a/src/api/reporting.ts b/src/api/reporting.ts new file mode 100644 index 0000000..9094d3d --- /dev/null +++ b/src/api/reporting.ts @@ -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}`), +} diff --git a/src/api/reviews.ts b/src/api/reviews.ts new file mode 100644 index 0000000..1d061e4 --- /dev/null +++ b/src/api/reviews.ts @@ -0,0 +1,5 @@ +import { api } from '@/lib/api-client' + +export const reviewsAPI = { + list: (params: string) => api.get(`/admin-api/reviews?${params}`), +} diff --git a/src/api/secrets.ts b/src/api/secrets.ts new file mode 100644 index 0000000..1feac90 --- /dev/null +++ b/src/api/secrets.ts @@ -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`) }, +} diff --git a/src/api/servers.ts b/src/api/servers.ts new file mode 100644 index 0000000..acce87a --- /dev/null +++ b/src/api/servers.ts @@ -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('/admin-api/servers/health') }, + workers() { return api.get<{ workers: WorkerInfo[] }>('/admin-api/servers/workers') }, +} diff --git a/src/api/throttle.ts b/src/api/throttle.ts new file mode 100644 index 0000000..325508a --- /dev/null +++ b/src/api/throttle.ts @@ -0,0 +1,5 @@ +import { api } from '@/lib/api-client' + +export const throttleAPI = { + status: () => api.get('/admin-api/throttle/status'), +} diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 0000000..53f9f5f --- /dev/null +++ b/src/api/users.ts @@ -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>( + `/admin-api/admin-users?${new URLSearchParams(params as Record).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(`/admin-api/admin-users/${id}`) }, + create(data: { email: string; password: string; displayName: string; role: string }) { return api.post('/admin-api/admin-users', data) }, + update(id: string, data: { role?: string; status?: string; displayName?: string }) { return api.put(`/admin-api/admin-users/${id}`, data) }, + remove(id: string) { return api.delete(`/admin-api/admin-users/${id}`) }, +} diff --git a/src/api/vector.ts b/src/api/vector.ts new file mode 100644 index 0000000..112f5c7 --- /dev/null +++ b/src/api/vector.ts @@ -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), +} diff --git a/src/config/menu.tsx b/src/config/menu.tsx index 9836137..24462b7 100644 --- a/src/config/menu.tsx +++ b/src/config/menu.tsx @@ -37,7 +37,7 @@ export const adminMenuItems: AdminMenuItem[] = [ { name: '学习数据', icon: , children: [ { path: '/learning-data', name: '学习数据总览', requiredRole: 'ADMIN' }, - { path: '/learning', name: '学习 Dashboard' }, + { path: '/learning/dashboard', name: '学习 Dashboard' }, { path: '/learning/events', name: '阅读事件' }, { path: '/learning/sessions', name: '学习会话' }, { path: '/learning/progress', name: '阅读进度' }, diff --git a/src/constants/labels.ts b/src/constants/labels.ts index 7d53f73..dc2d683 100644 --- a/src/constants/labels.ts +++ b/src/constants/labels.ts @@ -86,22 +86,34 @@ export const importStatusLabels: Record = { DOWNLOADING: '下载中', PARSING: '解析中', OCR_PROCESSING: 'OCR 处理中', + VISION_PROCESSING: '图像处理中', CLEANING: '清洗中', CHUNKING: '分段中', EMBEDDING: '向量化中', INDEXING: '索引中', GENERATING_CANDIDATES: '生成候选中', + COMPLETED: '已完成', + FAILED: '失败', + FAILED_RETRYABLE: '失败(可重试)', + FAILED_FINAL: '永久失败', + // 兼容小写 completed: '已完成', failed: '失败', - FAILED_FINAL: '永久失败', }; export const importStepLabels: Record = { - completed: '已完成', - failed: '失败', - DOWNLOADING: '下载中', - PARSING: '解析中', - CLEANING: '清洗中', - CHUNKING: '分段中', + QUEUED: '排队中', + CLAIMED: '已认领', + DOWNLOADING: '下载文件', + PARSING: '文档解析', + CLEANING: '文本清洗', + CHUNKING: '文本分段', + EMBEDDING: '向量化', + INDEXING: '写入索引', + GENERATING_CANDIDATES: 'AI 生成候选', + COMPLETED: '已完成', + FAILED: '失败', + FAILED_RETRYABLE: '失败(可重试)', + FAILED_FINAL: '永久失败', }; // ── Admin / User ── diff --git a/src/hooks/use-auth-query.ts b/src/hooks/use-auth-query.ts index f64ef39..76238c8 100644 --- a/src/hooks/use-auth-query.ts +++ b/src/hooks/use-auth-query.ts @@ -1,10 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { - loginAdmin, - logoutAdmin, - getCurrentAdmin, -} from '@/services/admin-api' -import { getAccessToken, setTokens, clearTokens, setStoredAdminUser } from '@/services/token-store' +import { authAPI } from '@/api' +import { getAccessToken, setTokens, clearTokens, setStoredAdminUser } from '@/lib/token' import type { AdminUser } from '@/types/admin' export function useAdminUserQuery() { @@ -13,7 +9,7 @@ export function useAdminUserQuery() { queryFn: async () => { const token = getAccessToken() if (!token) return null - const user = await getCurrentAdmin() + const user = await authAPI.me() setStoredAdminUser(user) return user }, @@ -26,7 +22,7 @@ export function useLoginMutation() { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => - loginAdmin(email, password), + authAPI.login(email, password), onSuccess: (data) => { setTokens(data.accessToken, data.refreshToken) setStoredAdminUser(data.adminUser) @@ -41,7 +37,7 @@ export function useLogoutMutation() { mutationFn: () => { const token = localStorage.getItem('admin_refresh_token') if (token) { - return logoutAdmin(token).catch(() => {}) + return authAPI.logout(token).catch(() => {}) } return Promise.resolve() }, diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 8739dc0..bb36c3d 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -23,7 +23,7 @@ const breadcrumbMap: Record = { '/files': '文件与 COS', '/reviews': '复习数据', '/learning-data': '学习数据总览', - '/learning': '学习数据', + '/learning/dashboard': '学习数据', '/learning/events': '阅读事件', '/learning/sessions': '学习会话', '/learning/progress': '阅读进度', diff --git a/src/lib/antd-global.ts b/src/lib/antd-global.ts new file mode 100644 index 0000000..2af2e4b --- /dev/null +++ b/src/lib/antd-global.ts @@ -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 +} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..308f48c --- /dev/null +++ b/src/lib/api-client.ts @@ -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 { + 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 | null = null + +async function tryRefresh(): Promise { + const token = getRefreshToken() + if (!token) return false + + if (!refreshPromise) { + refreshPromise = (async () => { + try { + const res = await axios.post>( + '/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) => { + 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(path: string, params?: Record) { + return apiClient.get>(path, { params }).then(r => r.data) + }, + post(path: string, body?: unknown) { + return apiClient.post>(path, body).then(r => r.data) + }, + put(path: string, body?: unknown) { + return apiClient.put>(path, body).then(r => r.data) + }, + patch(path: string, body?: unknown) { + return apiClient.patch>(path, body).then(r => r.data) + }, + delete(path: string, body?: unknown) { + return apiClient.delete>(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 diff --git a/src/lib/token.ts b/src/lib/token.ts new file mode 100644 index 0000000..8b7695c --- /dev/null +++ b/src/lib/token.ts @@ -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 | null { + try { + const raw = localStorage.getItem(ADMIN_USER_KEY) + return raw ? (JSON.parse(raw) as T) : null + } catch { + return null + } +} + +export function setStoredAdminUser>(user: T): void { + localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user)) +} diff --git a/src/pages/AiGateway.tsx b/src/pages/AiGateway.tsx index c4cf967..12df719 100644 --- a/src/pages/AiGateway.tsx +++ b/src/pages/AiGateway.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' 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 { ReloadOutlined, CloudOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { aiGatewayAPI } from '@/api' const { Title, Text } = Typography @@ -15,41 +15,41 @@ export default function AiGatewayPage() { const { data: status } = useQuery({ queryKey: ['ai-gateway', 'status'], - queryFn: (): Promise => api.get('/admin-api/ai-gateway/status'), + queryFn: (): Promise => aiGatewayAPI.status(), staleTime: 30_000, }) const { data: routes, refetch: refetchRoutes } = useQuery({ queryKey: ['ai-gateway', 'routes'], - queryFn: (): Promise => api.get('/admin-api/ai-gateway/routes'), + queryFn: (): Promise => aiGatewayAPI.routes(), enabled: activeTab === 'routes', }) const { data: providers, refetch: refetchProviders } = useQuery({ queryKey: ['ai-gateway', 'providers'], - queryFn: (): Promise => api.get('/admin-api/ai-gateway/providers'), + queryFn: (): Promise => aiGatewayAPI.providers(), enabled: activeTab === 'providers', }) const { data: fallbackEvents } = useQuery({ queryKey: ['ai-gateway', 'fallback-events'], - queryFn: (): Promise => api.get('/admin-api/ai-gateway/fallback-events'), + queryFn: (): Promise => aiGatewayAPI.fallbackEvents(), enabled: activeTab === 'fallback', refetchInterval: 30_000, }) 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() }, }) const deleteRoute = useMutation({ - mutationFn: (id: string) => api.delete(`/admin-api/ai-gateway/routes/${id}`), + mutationFn: (id: string) => aiGatewayAPI.deleteRoute(id), onSuccess: () => { message.success('路由已删除'); refetchRoutes() }, }) 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() }, }) diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 259ca3c..2e0475f 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import AuditLogTable from '@/components/AuditLogTable' -import { getAuditLogs } from '@/services/admin-api' +import { auditAPI } from '@/api' export default function AuditLogPage() { const [page, setPage] = useState(1) @@ -9,7 +9,7 @@ export default function AuditLogPage() { const { data, isLoading } = useQuery({ queryKey: ['audit-logs', page, pageSize], - queryFn: () => getAuditLogs({ page, limit: pageSize }), + queryFn: () => auditAPI.list({ page, limit: pageSize }), }) return ( diff --git a/src/pages/BackupAdmin.tsx b/src/pages/BackupAdmin.tsx index f6904e3..e88d1bc 100644 --- a/src/pages/BackupAdmin.tsx +++ b/src/pages/BackupAdmin.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Table, Button, Tag, Space, Typography, Tabs, message } from 'antd' import { CloudUploadOutlined, DeleteOutlined } from '@ant-design/icons' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { backupAPI } from '@/api' import { backupStatusLabels } from '@/constants/labels' const { Title } = Typography @@ -15,23 +15,23 @@ export default function BackupAdmin() { const { data: backups, isLoading: bLoading } = useQuery({ queryKey: ['admin', 'backup-jobs'], - queryFn: () => api.get('/admin-api/backup/jobs'), + queryFn: () => backupAPI.jobs(), enabled: tab === 'backups', }) const { data: cleanups, isLoading: cLoading } = useQuery({ queryKey: ['admin', 'cleanup-jobs'], - queryFn: () => api.get('/admin-api/backup/cleanup'), + queryFn: () => backupAPI.cleanups(), enabled: tab === 'cleanup', }) 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'] }) }, }) 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'] }) }, }) diff --git a/src/pages/Billing.tsx b/src/pages/Billing.tsx index b8cd207..da34cc0 100644 --- a/src/pages/Billing.tsx +++ b/src/pages/Billing.tsx @@ -2,9 +2,7 @@ import { useState } from 'react' 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 { DollarOutlined, ReloadOutlined, LinkOutlined, PlusOutlined, DeleteOutlined, EditOutlined, CloudOutlined, DownloadOutlined } from '@ant-design/icons' -import { getBilling, type BillingInfo } from '@/services/billing-api' -import { getCostSummary, createCost, updateCost, deleteCost as deleteCostApi, type CostSummary, type CostItem } from '@/services/costs-api' -import { api } from '@/services/http-client' +import { billingAPI, costsAPI, type BillingInfo } from '@/api' import dayjs from 'dayjs' const { Text, Title } = Typography @@ -25,21 +23,21 @@ function BillingContent() { const [editing, setEditing] = useState(null) const [form] = Form.useForm() - const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 60_000 }) - const { data: costs } = useQuery({ queryKey: ['costs', 'summary'], queryFn: getCostSummary, staleTime: 30_000 }) - const { data: aiReport } = useQuery({ queryKey: ['costs', 'report'], queryFn: (): Promise => api.get('/admin-api/costs/report?days=30'), staleTime: 30_000 }) - const { data: topUsers } = useQuery({ queryKey: ['costs', 'top-users'], queryFn: (): Promise => api.get('/admin-api/costs/top-users?days=30&limit=10'), staleTime: 30_000 }) + const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: billingAPI.get, staleTime: 60_000 }) + const { data: costs } = useQuery({ queryKey: ['costs', 'summary'], queryFn: costsAPI.summary, staleTime: 30_000 }) + const { data: aiReport } = useQuery({ queryKey: ['costs', 'report'], queryFn: (): Promise => costsAPI.report(), staleTime: 30_000 }) + const { data: topUsers } = useQuery({ queryKey: ['costs', 'top-users'], queryFn: (): Promise => 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 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 saveCost = async (values: any) => { const body = { ...values, purchaseDate: values.purchaseDate?.toISOString(), expiryDate: values.expiryDate?.toISOString() } try { - if (editing) await updateCost(editing.id, body) - else await createCost(body) + if (editing) await costsAPI.update(editing.id, body) + else await costsAPI.create(body) message.success(editing ? '已更新' : '已添加') setModalOpen(false); setEditing(null); form.resetFields(); qc.invalidateQueries({ queryKey: ['costs', 'summary'] }) } catch { message.error('操作失败') } @@ -47,7 +45,7 @@ function BillingContent() { const deleteCost = (item: CostItem) => modal.confirm({ 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) } diff --git a/src/pages/CacheAdmin.tsx b/src/pages/CacheAdmin.tsx index 28d031f..1ad619b 100644 --- a/src/pages/CacheAdmin.tsx +++ b/src/pages/CacheAdmin.tsx @@ -1,7 +1,7 @@ import { Card, Button, Statistic, Row, Col, Space, Typography, message } from 'antd' import { ClearOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icons' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { cacheAPI } from '@/api' import type { CacheStats } from '@/types/api' const { Title } = Typography @@ -11,12 +11,12 @@ export default function CacheAdmin() { const { data: stats, isLoading } = useQuery({ queryKey: ['admin', 'cache-stats'], - queryFn: (): Promise => api.get('/admin-api/cache/stats'), + queryFn: (): Promise => cacheAPI.stats(), refetchInterval: 10_000, }) const flushModule = useMutation({ - mutationFn: (module: string) => api.post(`/admin-api/cache/flush/${module}`), + mutationFn: (module: string) => cacheAPI.flushModule(module), onSuccess: (_, mod) => { message.success(`已清除 ${mod} 模块缓存`) qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] }) @@ -24,7 +24,7 @@ export default function CacheAdmin() { }) const flushAll = useMutation({ - mutationFn: () => api.post('/admin-api/cache/flush-all'), + mutationFn: () => cacheAPI.flushAll(), onSuccess: () => { message.success('已清除所有缓存') qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] }) @@ -32,7 +32,7 @@ export default function CacheAdmin() { }) const resetStats = useMutation({ - mutationFn: () => api.post('/admin-api/cache/reset-stats'), + mutationFn: () => cacheAPI.resetStats(), onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'cache-stats'] }), }) diff --git a/src/pages/ChatLogs.tsx b/src/pages/ChatLogs.tsx index 6a79cf6..4b36680 100644 --- a/src/pages/ChatLogs.tsx +++ b/src/pages/ChatLogs.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Tag, Typography, Button, Drawer } from 'antd' import { ReloadOutlined, MessageOutlined, EyeOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { chatLogsAPI } from '@/api' import dayjs from 'dayjs' const { Title, Text } = Typography @@ -14,13 +14,13 @@ export default function ChatLogsPage() { const { data: sessions, isLoading } = useQuery({ queryKey: ['chat-logs', 'sessions'], - queryFn: (): Promise => api.get('/admin-api/rag-chat/sessions'), + queryFn: (): Promise => chatLogsAPI.sessions(), staleTime: 10_000, }) const { data: messages } = useQuery({ queryKey: ['chat-logs', 'messages', selectedSession?.id], - queryFn: (): Promise => api.get(`/admin-api/rag-chat/sessions/${selectedSession?.id}/messages`), + queryFn: (): Promise => chatLogsAPI.sessionMessages(selectedSession?.id || ''), enabled: !!selectedSession?.id, }) diff --git a/src/pages/ComplianceAdmin.tsx b/src/pages/ComplianceAdmin.tsx index 99398cb..2095283 100644 --- a/src/pages/ComplianceAdmin.tsx +++ b/src/pages/ComplianceAdmin.tsx @@ -1,10 +1,9 @@ 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { complianceAPI } from '@/api' import { complianceStatusLabels } from '@/constants/labels' -import { message } from 'antd' import dayjs from 'dayjs' const { Title } = Typography @@ -17,47 +16,48 @@ export default function ComplianceAdmin() { const [modalType, setModalType] = useState('') const [editing, setEditing] = useState(null) const [form] = Form.useForm() + const { message } = App.useApp() const qc = useQueryClient() const { data: policies, isLoading: pLoading } = useQuery({ queryKey: ['compliance', 'policies'], - queryFn: () => api.get('/admin-api/compliance/privacy-policies').then(d => d ?? []), + queryFn: () => complianceAPI.privacyPolicies().then(d => d ?? []), }) const { data: agreements, isLoading: aLoading } = useQuery({ queryKey: ['compliance', 'agreements'], - queryFn: () => api.get('/admin-api/compliance/user-agreements').then(d => d ?? []), + queryFn: () => complianceAPI.userAgreements().then(d => d ?? []), }) const { data: filings, isLoading: fLoading } = useQuery({ queryKey: ['compliance', 'filings'], - queryFn: () => api.get('/admin-api/compliance/filings').then(d => d ?? []), + queryFn: () => complianceAPI.filings().then(d => d ?? []), }) const { data: deletions, isLoading: dLoading } = useQuery({ queryKey: ['compliance', 'deletions'], - queryFn: () => api.get('/admin-api/compliance/deletion-requests').then(d => d ?? []), + queryFn: () => complianceAPI.deletionRequests().then(d => d ?? []), }) const { data: exports_, isLoading: eLoading } = useQuery({ queryKey: ['compliance', 'exports'], - queryFn: () => api.get('/admin-api/compliance/export-requests').then(d => d ?? []), + queryFn: () => complianceAPI.exportRequests().then(d => d ?? []), }) const { data: securityEvents, isLoading: sLoading } = useQuery({ queryKey: ['compliance', 'security-events'], - queryFn: () => api.get('/admin-api/compliance/security-events').then(d => d ?? []), + queryFn: () => complianceAPI.securityEvents().then(d => d ?? []), }) const saveMutation = useMutation({ mutationFn: (values: any) => { if (modalType === 'policy') return editing - ? api.patch(`/admin-api/compliance/privacy-policies/${editing.id}`, values) - : api.post('/admin-api/compliance/privacy-policies', values) + ? complianceAPI.updatePrivacy(editing.id, values) + : complianceAPI.createPrivacy(values) if (modalType === 'agreement') return editing - ? api.patch(`/admin-api/compliance/user-agreements/${editing.id}`, values) - : api.post('/admin-api/compliance/user-agreements', values) - return api.post('/admin-api/compliance/filings', values) + ? complianceAPI.updateAgreement(editing.id, values) + : complianceAPI.createAgreement(values) + return complianceAPI.createFiling(values) }, onSuccess: () => { message.success('已保存') @@ -67,7 +67,7 @@ export default function ComplianceAdmin() { }) 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'] }) }, }) diff --git a/src/pages/Config.tsx b/src/pages/Config.tsx index e8b0a86..bb7487a 100644 --- a/src/pages/Config.tsx +++ b/src/pages/Config.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' 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 { getConfig, setConfig, updateConfig, deleteConfig, toggleFlag, getChangeLog } from '@/services/config-api' +import { configAPI } from '@/api' import dayjs from 'dayjs' const { Title, Text, Paragraph } = Typography @@ -28,19 +28,19 @@ function ConfigPage() { const [newKey, setNewKey] = useState('') const [newValue, setNewValue] = useState('') - const { data } = useQuery({ queryKey: ['config'], queryFn: getConfig, staleTime: 30_000 }) - const { data: changelog } = useQuery({ queryKey: ['config', 'changelog'], queryFn: getChangeLog, staleTime: 10_000 }) + const { data } = useQuery({ queryKey: ['config'], queryFn: configAPI.get, staleTime: 30_000 }) + const { data: changelog } = useQuery({ queryKey: ['config', 'changelog'], queryFn: configAPI.changeLog, staleTime: 10_000 }) const handleSave = async () => { - if (editKey) { await updateConfig(editKey, newValue); message.success('已更新') } - else { await setConfig(newKey, newValue); message.success('已添加') } + if (editKey) { await configAPI.update(editKey, newValue); message.success('已更新') } + else { await configAPI.set(newKey, newValue); message.success('已添加') } setAddOpen(false); setEditKey(''); setNewKey(''); setNewValue('') qc.invalidateQueries({ queryKey: ['config'] }) } const handleDelete = (key: string) => modal.confirm({ 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 = [ @@ -68,7 +68,7 @@ function ConfigPage() { { title: '开关名', dataIndex: 'name', width: 180 }, { title: '说明', dataIndex: 'description', ellipsis: true }, { title: '状态', dataIndex: 'enabled', width: 80, render: (v: boolean, r: any) => ( - { toggleFlag(r.name, checked); message.success('已切换'); qc.invalidateQueries({ queryKey: ['config'] }) }} /> + { configAPI.toggleFlag(r.name, checked); message.success('已切换'); qc.invalidateQueries({ queryKey: ['config'] }) }} /> )}, ] diff --git a/src/pages/ContentSafety.tsx b/src/pages/ContentSafety.tsx index d3cca04..1f50c83 100644 --- a/src/pages/ContentSafety.tsx +++ b/src/pages/ContentSafety.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Button, Typography, App, Modal, Input, Select, Tag, Space, Tabs } from 'antd' import { ReloadOutlined, PlusOutlined, DeleteOutlined, SafetyOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { contentSafetyAPI } from '@/api' import dayjs from 'dayjs' const { Title, Text } = Typography @@ -15,27 +15,27 @@ function CSPage() { const [category, setCategory] = useState('general') const [risk, setRisk] = useState('medium') - const { data: words } = useQuery({ queryKey: ['safety', 'words'], queryFn: (): Promise => api.get('/admin-api/content-safety/words') }) - const { data: checks } = useQuery({ queryKey: ['safety', 'checks'], queryFn: (): Promise => api.get('/admin-api/content-safety/checks') }) - const { data: reports } = useQuery({ queryKey: ['safety', 'reports'], queryFn: (): Promise => api.get('/admin-api/content-safety/reports') }) - const { data: violations } = useQuery({ queryKey: ['safety', 'violations'], queryFn: (): Promise => api.get('/admin-api/content-safety/violations') }) + const { data: words } = useQuery({ queryKey: ['safety', 'words'], queryFn: (): Promise => contentSafetyAPI.words() }) + const { data: checks } = useQuery({ queryKey: ['safety', 'checks'], queryFn: (): Promise => contentSafetyAPI.checks() }) + const { data: reports } = useQuery({ queryKey: ['safety', 'reports'], queryFn: (): Promise => contentSafetyAPI.reports() }) + const { data: violations } = useQuery({ queryKey: ['safety', 'violations'], queryFn: (): Promise => contentSafetyAPI.violations() }) 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('') qc.invalidateQueries({ queryKey: ['safety'] }) } const removeWord = (id: string) => modal.confirm({ 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({ title: action === 'confirmed' ? '确认违规' : '驳回举报', content: action === 'confirmed' ? '确认后将创建违规记录' : '驳回后将关闭此举报', 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'] }) }, }) @@ -44,7 +44,7 @@ function CSPage() { title: '应用处罚', content: `确认对违规记录应用「${penalty}」处罚?`, onOk: async () => { - await api.post(`/admin-api/content-safety/violations/${id}/penalty`, { penalty }) + await contentSafetyAPI.penaltyViolation(id, { penalty }) message.success('处罚已应用'); qc.invalidateQueries({ queryKey: ['safety'] }) }, }) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index f29bb30..8135c7d 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -12,8 +12,7 @@ import MetricCard from '@/components/MetricCard' import { Progress, Space } from 'antd' const DText = Typography.Text import EChartsChartContainer from '@/components/EChartsChartContainer' -import { getDashboardStats } from '@/services/admin-api' -import { getServerMetrics } from '@/services/server-api' +import { dashboardAPI, serversAPI } from '@/api' echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, TitleComponent, LegendComponent, CanvasRenderer]) @@ -26,13 +25,13 @@ function formatStorage(bytes: number): string { export default function Dashboard() { const { data: stats, isLoading: statsLoading } = useQuery({ queryKey: ['dashboard', 'stats'], - queryFn: getDashboardStats, + queryFn: dashboardAPI.getStats, staleTime: 60_000, }) const { data: serverData } = useQuery({ queryKey: ['servers', 'metrics'], - queryFn: getServerMetrics, + queryFn: serversAPI.metrics, staleTime: 30_000, }) diff --git a/src/pages/Events.tsx b/src/pages/Events.tsx index 2859b72..68ac39a 100644 --- a/src/pages/Events.tsx +++ b/src/pages/Events.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' 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 { api } from '@/services/http-client' +import { eventsQueueAPI } from '@/api' const { Title, Text } = Typography @@ -14,37 +14,37 @@ function EventsPage() { const { data: overview } = useQuery({ queryKey: ['events', 'overview'], - queryFn: (): Promise => api.get('/admin-api/events'), + queryFn: (): Promise => eventsQueueAPI.overview(), staleTime: 10_000, }) const { data: stats } = useQuery({ queryKey: ['events', 'stats'], - queryFn: (): Promise => api.get('/admin-api/events/stats'), + queryFn: (): Promise => eventsQueueAPI.stats(), enabled: activeTab === 'stats', }) const { data: workers } = useQuery({ queryKey: ['events', 'workers'], - queryFn: (): Promise => api.get('/admin-api/events/workers'), + queryFn: (): Promise => eventsQueueAPI.workers(), enabled: activeTab === 'stats', refetchInterval: 15_000, }) const { data: failed } = useQuery({ queryKey: ['events', 'failed', selectedQueue], - queryFn: (): Promise => selectedQueue ? api.get(`/admin-api/events/${selectedQueue}/failed`) : Promise.resolve(null), + queryFn: (): Promise => selectedQueue ? eventsQueueAPI.failedJobs(selectedQueue) : Promise.resolve(null), enabled: !!selectedQueue, }) const handleRetry = async (queue: string, jobId: string) => { - await api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`) + await eventsQueueAPI.retry(queue, jobId) message.success('已重试') qc.invalidateQueries({ queryKey: ['events'] }) } 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}`) qc.invalidateQueries({ queryKey: ['events'] }) } diff --git a/src/pages/FilesAdmin.tsx b/src/pages/FilesAdmin.tsx index c6bff0c..52d364d 100644 --- a/src/pages/FilesAdmin.tsx +++ b/src/pages/FilesAdmin.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Button, Typography, App, Tag } from 'antd' import { ReloadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { filesAdminAPI } from '@/api' import dayjs from 'dayjs' const mimeLabels: Record = { @@ -26,12 +26,12 @@ export default function FilesAdminPage() { const { data, isLoading } = useQuery({ queryKey: ['admin-files', page], - queryFn: (): Promise => api.get(`/admin-api/files?page=${page}&limit=20`), + queryFn: () => filesAdminAPI.list(page), }) const handleDelete = (file: any) => modal.confirm({ 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 = [ diff --git a/src/pages/HermesSettings.tsx b/src/pages/HermesSettings.tsx index 6e0a2e0..1a0ef27 100644 --- a/src/pages/HermesSettings.tsx +++ b/src/pages/HermesSettings.tsx @@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { hermesAPI } from '@/api' import { agentTaskStatusLabels, artifactStatusLabels } from '@/constants/labels' -import { message } from 'antd' const { Title } = Typography @@ -11,25 +10,26 @@ const statusColors: Record = { pending: 'blue', approved: 'green const artifactTypeLabels: Record = { issue_draft: 'Issue 草稿', milestone_summary: '里程碑总结', release_notes: '发布说明', decision_record: '决策记录' } export default function HermesSettings() { + const { message } = App.useApp() const qc = useQueryClient() const { data: tasks, isLoading: tLoading } = useQuery({ queryKey: ['hermes', 'tasks'], - queryFn: () => api.get('/admin-api/hermes/tasks').then(d => d ?? []), + queryFn: () => hermesAPI.tasks().then(d => d ?? []), }) const { data: artifacts, isLoading: aLoading } = useQuery({ queryKey: ['hermes', 'artifacts'], - queryFn: () => api.get('/admin-api/hermes/artifacts').then(d => d ?? []), + queryFn: () => hermesAPI.artifacts().then(d => d ?? []), }) 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'] }) }, }) 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'] }) }, }) diff --git a/src/pages/ImportMonitor.tsx b/src/pages/ImportMonitor.tsx index 85c24da..021a49d 100644 --- a/src/pages/ImportMonitor.tsx +++ b/src/pages/ImportMonitor.tsx @@ -1,76 +1,271 @@ -import { useState } from 'react' +import { useState, useCallback, useRef } from 'react' 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 { api } from '@/services/http-client' +import { importsAdminAPI, knowledgeAPI } from '@/api' import { importStatusLabels, importStepLabels } from '@/constants/labels' import dayjs from 'dayjs' 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() { const { message } = App.useApp() const qc = useQueryClient() const [drawerOpen, setDrawerOpen] = useState(false) const [selectedJob, setSelectedJob] = useState(null) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + const [kbFilter, setKbFilter] = useState(undefined) + const [retryAllOpen, setRetryAllOpen] = useState(false) + const [showDeleted, setShowDeleted] = useState(false) + const [sortBy, setSortBy] = useState('createdAt') + const [sortOrder, setSortOrder] = useState('descend') - const { data, isLoading } = useQuery({ - queryKey: ['admin-imports'], - queryFn: (): Promise => api.get('/admin-api/imports?limit=50'), - staleTime: 10_000, + // 知识库远程搜索(防抖) + const [kbSearch, setKbSearch] = useState('') + const kbSearchTimer = useRef>() + + 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 => { + 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 => importsAdminAPI.list(queryParams.toString()), + }) + + // 默认过滤已删除 source 的记录 + const tableData = showDeleted + ? (data?.items || []) + : (data?.items || []).filter((r: any) => !r.sourceDeleted) + const { data: detail } = useQuery({ - queryKey: ['admin-imports', selectedJob?.id], - queryFn: (): Promise => api.get(`/admin-api/imports/${selectedJob?.id}`), + queryKey: ['admin-import-detail', selectedJob?.id], + queryFn: (): Promise => importsAdminAPI.detail(selectedJob?.id || ''), enabled: !!selectedJob?.id, }) - const handleRetry = async (id: string) => { - await api.post(`/admin-api/imports/${id}/retry`) - message.success('已重新入队') - qc.invalidateQueries({ queryKey: ['admin-imports'] }) + const refreshList = () => { + qc.invalidateQueries({ queryKey: ['admin-imports', 'list'] }) + } + + 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 = { - 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 = [ - { title: '名称', dataIndex: 'sourceName', width: 180, ellipsis: true }, - { title: '类型', dataIndex: 'sourceType', width: 80, render: (s: string) => (s === 'file' ? '文件' : s === 'url' ? '链接' : s === 'text' ? '文本' : s) }, - { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {importStatusLabels[s] || s} }, - { title: '进度', dataIndex: 'progress', width: 70, render: (v: number) => `${v || 0}%` }, - { title: '重试', dataIndex: 'retryCount', width: 60, align: 'center' as const }, - { title: '步骤', dataIndex: 'step', width: 100, ellipsis: true }, - { title: '错误', dataIndex: 'errorMessage', width: 150, ellipsis: true, render: (v: string) => v ? {v} : '-' }, - { title: '时间', dataIndex: 'createdAt', width: 130, render: (d: string) => dayjs(d).format('MM-DD HH:mm') }, + { title: '名称', dataIndex: 'sourceName', width: 180, ellipsis: true, sorter: true, sortOrder: sortBy === 'sourceName' ? (sortOrder as any) : undefined, + render: (v: string, r: any) => ( + + {v} + {r.sourceDeleted && 源已删} + + ), + }, + { 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) => {importStatusLabels[s] || s} }, + { 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 ? {v} : '-' }, + { 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, render: (_: any, r: any) => ( - {r.status === 'FAILED' && } + {(r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED') && } ), }, ] const stepCols = [ - { title: '步骤', dataIndex: 'step', width: 120 }, - { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => {importStepLabels[s] || s} }, + { title: '步骤', dataIndex: 'step', width: 120, render: (s: string) => importStepLabels[s] || importStatusLabels[s] || s }, + { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => {importStepLabels[s] || importStatusLabels[s] || s} }, { title: '详情', dataIndex: 'detail', ellipsis: true }, { 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') : '-' }, ] + const kbOptions = (kbData?.items || []).map((kb: any) => ({ + value: kb.id, + label: formatKbLabel(kb), + })) + return (
<ImportOutlined /> 文档导入 - + + +
- + + + +
setSelectedRowKeys(keys), + }} + onChange={(_pagination, _filters, sorter: any) => { + if (sorter.order) { + setSortBy(sorter.field || 'createdAt') + setSortOrder(sorter.order) + } else { + setSortBy('createdAt') + setSortOrder('descend') + } + }} + /> setDrawerOpen(false)} width={700}> {detail && ( diff --git a/src/pages/KnowledgeBases.tsx b/src/pages/KnowledgeBases.tsx index b74b27e..ca1089e 100644 --- a/src/pages/KnowledgeBases.tsx +++ b/src/pages/KnowledgeBases.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Tag, Typography, Button, App, Drawer, Space, Descriptions } from 'antd' 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 dayjs from 'dayjs' @@ -18,25 +18,25 @@ function KBPage() { const { data, isLoading } = useQuery({ queryKey: ['knowledge-bases', page, pageSize], - queryFn: (): Promise => api.get(`/admin-api/knowledge-bases?page=${page}&limit=${pageSize}`), + queryFn: () => knowledgeAPI.list(page, pageSize), }) const { data: sources } = useQuery({ queryKey: ['kb-sources', selectedKb?.id], - queryFn: (): Promise => api.get(`/admin-api/knowledge-bases/${selectedKb?.id}/sources`), + queryFn: (): Promise => knowledgeAPI.sources(selectedKb?.id || ''), enabled: !!selectedKb?.id, }) const [refSourceId, setRefSourceId] = useState(null) const { data: references } = useQuery({ queryKey: ['source-refs', refSourceId], - queryFn: (): Promise => api.get(`/admin-api/knowledge-bases/sources/${refSourceId}/references`), + queryFn: (): Promise => knowledgeAPI.sourceReferences(refSourceId), enabled: !!refSourceId, }) const handleDelete = (kb: any) => modal.confirm({ 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 = [ diff --git a/src/pages/KnowledgeOps.tsx b/src/pages/KnowledgeOps.tsx index 0a10073..53e3a2a 100644 --- a/src/pages/KnowledgeOps.tsx +++ b/src/pages/KnowledgeOps.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Tag, Typography, Button, App, Tabs, Input, Card, Space } from 'antd' 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 dayjs from 'dayjs' @@ -16,18 +16,18 @@ export default function KnowledgeOpsPage() { const { data: candidates } = useQuery({ queryKey: ['ops', 'candidates'], - queryFn: (): Promise => api.get('/admin-api/knowledge-bases/candidates?limit=50'), + queryFn: (): Promise => knowledgeAPI.candidates(), }) const { data: chunks } = useQuery({ queryKey: ['ops', 'chunks', sourceId], - queryFn: (): Promise => api.get(`/admin-api/knowledge-bases/chunks?sourceId=${sourceId}&limit=50`), + queryFn: (): Promise => knowledgeAPI.chunks(sourceId), enabled: !!sourceId, }) const handleDebugSearch = async () => { 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)) } diff --git a/src/pages/KnowledgeSources.tsx b/src/pages/KnowledgeSources.tsx index fc603f0..c77194b 100644 --- a/src/pages/KnowledgeSources.tsx +++ b/src/pages/KnowledgeSources.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Table, Tag, Space, Input, Select, Typography } from 'antd' import { SearchOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { knowledgeAPI } from '@/api' import { parseStatusLabels } from '@/constants/labels' import dayjs from 'dayjs' @@ -77,14 +77,14 @@ export default function KnowledgeSourcesPage() { if (kbFilter) p.set('knowledgeBaseId', kbFilter) if (statusFilter) p.set('parseStatus', statusFilter) if (search) p.set('search', search) - return api.get(`/admin-api/knowledge-bases/all-sources?${p.toString()}`) + return knowledgeAPI.allSources(p.toString()) }, staleTime: 10_000, }) const { data: kbList } = useQuery({ queryKey: ['admin', 'kb-list'], - queryFn: () => api.get('/admin-api/knowledge-bases?limit=200').then(d => d.items), + queryFn: () => knowledgeAPI.list(1, 200).then(d => d.items), staleTime: 60_000, }) diff --git a/src/pages/LearningData.tsx b/src/pages/LearningData.tsx index 5e17971..b750cd4 100644 --- a/src/pages/LearningData.tsx +++ b/src/pages/LearningData.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Table, Tabs, Tag, Space, Input, Typography } from 'antd' import { SearchOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { learningAPI } from '@/api' import { sessionStatusLabels, sessionModeLabels } from '@/constants/labels' const { Title } = Typography @@ -18,7 +18,7 @@ export default function LearningData() { queryFn: () => { const p = new URLSearchParams() if (search) p.set('userId', search) - return api.get(`/admin-api/learning/sessions?${p.toString()}`) + return learningAPI.getSessionsRaw(p.toString()) }, enabled: tab === 'sessions', }) @@ -28,7 +28,7 @@ export default function LearningData() { queryFn: () => { const p = new URLSearchParams() if (search) p.set('userId', search) - return api.get(`/admin-api/learning/analysis?${p.toString()}`) + return learningAPI.getAnalysis(p.toString()) }, enabled: tab === 'analysis', }) @@ -38,7 +38,7 @@ export default function LearningData() { queryFn: () => { const p = new URLSearchParams() if (search) p.set('userId', search) - return api.get(`/admin-api/learning/ai-usage?${p.toString()}`) + return learningAPI.getAiUsage(p.toString()) }, enabled: tab === 'ai-usage', }) diff --git a/src/pages/MemberManagement.tsx b/src/pages/MemberManagement.tsx index 5658505..45e2893 100644 --- a/src/pages/MemberManagement.tsx +++ b/src/pages/MemberManagement.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Button, Typography, App, Tabs, Tag, Space, Modal, Form, Input, Select, DatePicker } from 'antd' import { ReloadOutlined, UserOutlined, PlusOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { membersAPI } from '@/api' import dayjs from 'dayjs' const { Title, Text } = Typography @@ -13,12 +13,12 @@ export default function MemberManagement() { const [addOpen, setAddOpen] = useState(false) const [form] = Form.useForm() - const { data: usersData } = useQuery({ queryKey: ['users', 'list'], queryFn: (): Promise => api.get('/admin-api/users') }) - const { data: memberships } = useQuery({ queryKey: ['users', 'memberships'], queryFn: (): Promise => api.get('/admin-api/users/memberships') }) - const { data: deletions } = useQuery({ queryKey: ['users', 'deletions'], queryFn: (): Promise => api.get('/admin-api/users/deletion-requests') }) + const { data: usersData } = useQuery({ queryKey: ['users', 'list'], queryFn: (): Promise => membersAPI.list() }) + const { data: memberships } = useQuery({ queryKey: ['users', 'memberships'], queryFn: (): Promise => membersAPI.memberships() }) + const { data: deletions } = useQuery({ queryKey: ['users', 'deletions'], queryFn: (): Promise => membersAPI.deletionRequests() }) 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'] }) } @@ -28,7 +28,7 @@ export default function MemberManagement() { content: action === 'approve' ? '批准后将立即执行账号注销' : '驳回后用户可正常使用', okType: action === 'approve' ? 'danger' : undefined, onOk: async () => { - await api.post(`/admin-api/users/deletion-requests/${id}/${action}`) + await membersAPI.processDeletion(id, action) message.success(action === 'approve' ? '已批准注销' : '已驳回') qc.invalidateQueries({ queryKey: ['users'] }) }, diff --git a/src/pages/Membership.tsx b/src/pages/Membership.tsx index 741666e..e19f19d 100644 --- a/src/pages/Membership.tsx +++ b/src/pages/Membership.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Button, Typography, App, Modal, Input, InputNumber, Tag, Tabs, Space } from 'antd' import { ReloadOutlined, PlusOutlined, DollarOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { quotaAPI } from '@/api' import dayjs from 'dayjs' const { Title } = Typography @@ -13,12 +13,12 @@ function MembershipPage() { const [addOpen, setAddOpen] = useState(false) const [newPlan, setNewPlan] = useState({ name: '', code: '', priceMonthly: 0, monthlyChatCount: 10 }) - const { data: plans } = useQuery({ queryKey: ['quota', 'plans'], queryFn: (): Promise => api.get('/admin-api/quota/plans') }) - const { data: memberships } = useQuery({ queryKey: ['quota', 'memberships'], queryFn: (): Promise => api.get('/admin-api/quota/memberships') }) - const { data: costs } = useQuery({ queryKey: ['quota', 'costs'], queryFn: (): Promise => api.get('/admin-api/quota/costs') }) + const { data: plans } = useQuery({ queryKey: ['quota', 'plans'], queryFn: (): Promise => quotaAPI.plans() }) + const { data: memberships } = useQuery({ queryKey: ['quota', 'memberships'], queryFn: (): Promise => quotaAPI.memberships() }) + const { data: costs } = useQuery({ queryKey: ['quota', 'costs'], queryFn: (): Promise => quotaAPI.costs() }) const addPlan = async () => { - await api.post('/admin-api/quota/plans', newPlan) + await quotaAPI.createPlan(newPlan) message.success('已添加') setAddOpen(false) qc.invalidateQueries({ queryKey: ['quota'] }) diff --git a/src/pages/Metrics.tsx b/src/pages/Metrics.tsx index 2800571..0b6810b 100644 --- a/src/pages/Metrics.tsx +++ b/src/pages/Metrics.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Card, Row, Col, Statistic, Button, Typography, Tabs } from 'antd' import { ReloadOutlined, DashboardOutlined, CloudOutlined, NodeIndexOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { metricsAPI } from '@/api' import dayjs from 'dayjs' const { Title } = Typography @@ -9,11 +9,11 @@ const { Title } = Typography function MetricsPage() { const qc = useQueryClient() - const { data: overview } = useQuery({ queryKey: ['metrics', 'overview'], queryFn: (): Promise => api.get('/admin-api/metrics/overview'), staleTime: 10_000 }) - const { data: top } = useQuery({ queryKey: ['metrics', 'top'], queryFn: (): Promise => api.get('/admin-api/metrics/top?limit=15'), staleTime: 10_000 }) - const { data: recent } = useQuery({ queryKey: ['metrics', 'recent'], queryFn: (): Promise => api.get('/admin-api/metrics/recent?limit=30'), staleTime: 5_000 }) - const { data: ai } = useQuery({ queryKey: ['metrics', 'ai'], queryFn: (): Promise => api.get('/admin-api/metrics/ai?days=7'), staleTime: 10_000 }) - const { data: worker } = useQuery({ queryKey: ['metrics', 'worker'], queryFn: (): Promise => api.get('/admin-api/metrics/worker?days=7'), staleTime: 10_000 }) + const { data: overview } = useQuery({ queryKey: ['metrics', 'overview'], queryFn: (): Promise => metricsAPI.overview(), staleTime: 10_000 }) + const { data: top } = useQuery({ queryKey: ['metrics', 'top'], queryFn: (): Promise => metricsAPI.top(), staleTime: 10_000 }) + const { data: recent } = useQuery({ queryKey: ['metrics', 'recent'], queryFn: (): Promise => metricsAPI.recent(), staleTime: 5_000 }) + const { data: ai } = useQuery({ queryKey: ['metrics', 'ai'], queryFn: (): Promise => metricsAPI.ai(), staleTime: 10_000 }) + const { data: worker } = useQuery({ queryKey: ['metrics', 'worker'], queryFn: (): Promise => metricsAPI.worker(), staleTime: 10_000 }) const topCols = [ { title: '接口', dataIndex: 'path', width: 300, ellipsis: true }, diff --git a/src/pages/NotificationAdmin.tsx b/src/pages/NotificationAdmin.tsx index 6218bea..f3ab641 100644 --- a/src/pages/NotificationAdmin.tsx +++ b/src/pages/NotificationAdmin.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Card, Table, Button, Modal, Form, Input, Select, Tag, Space, Typography, message } from 'antd' import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons' 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 { BellOutlined, SendOutlined, ExclamationCircleOutlined } from '@ant-design/icons' @@ -21,20 +21,20 @@ export default function NotificationAdmin() { const { data: templates, isLoading } = useQuery({ queryKey: ['admin', 'notification-templates'], queryFn: (): Promise => - api.get('/admin-api/notifications/templates').then(d => d ?? []), + notificationsAPI.templates().then(d => d ?? []), }) const { data: sendLogs } = useQuery({ queryKey: ['admin', 'notification-logs'], queryFn: (): Promise => - api.get('/admin-api/notifications/send-log?limit=50').then(d => d ?? []), + notificationsAPI.sendLog().then(d => d ?? []), }) const saveMutation = useMutation({ mutationFn: (values: any) => editing - ? api.patch(`/admin-api/notifications/templates/${editing.id}`, values) - : api.post('/admin-api/notifications/templates', values), + ? notificationsAPI.updateTemplate(editing.id, values) + : notificationsAPI.createTemplate(values), onSuccess: () => { message.success(editing ? '模板已更新' : '模板已创建') qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] }) @@ -45,7 +45,7 @@ export default function NotificationAdmin() { }) const deleteMutation = useMutation({ - mutationFn: (id: string) => api.delete(`/admin-api/notifications/templates/${id}`), + mutationFn: (id: string) => notificationsAPI.deleteTemplate(id), onSuccess: () => { message.success('模板已删除') qc.invalidateQueries({ queryKey: ['admin', 'notification-templates'] }) @@ -165,18 +165,18 @@ function AdminNotificationsCard() { const { data: adminNotifs } = useQuery({ queryKey: ['admin', 'admin-notifications'], - queryFn: () => api.get('/admin-api/admin-notifications'), + queryFn: () => notificationsAPI.adminNotifications(), refetchInterval: 30_000, }) const sendAlert = useMutation({ 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'] }) }, }) 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'] }), }) diff --git a/src/pages/ReleaseAdmin.tsx b/src/pages/ReleaseAdmin.tsx index 1310554..d3f1673 100644 --- a/src/pages/ReleaseAdmin.tsx +++ b/src/pages/ReleaseAdmin.tsx @@ -1,10 +1,9 @@ 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { releaseAPI } from '@/api' import { releaseStatusLabels } from '@/constants/labels' -import { message } from 'antd' const { Title } = Typography const { TextArea } = Input @@ -15,23 +14,24 @@ export default function ReleaseAdmin() { const [editing, setEditing] = useState(null) const [checklistVer, setChecklistVer] = useState('1.0') const [form] = Form.useForm() + const { message } = App.useApp() const qc = useQueryClient() const { data: changelogs, isLoading: cLoading } = useQuery({ queryKey: ['release', 'changelogs'], - queryFn: () => api.get('/admin-api/release/changelogs').then(d => d ?? []), + queryFn: () => releaseAPI.changelogs().then(d => d ?? []), enabled: tab === 'changelogs', }) const { data: decisions, isLoading: dLoading } = useQuery({ queryKey: ['release', 'decisions'], - queryFn: () => api.get('/admin-api/release/decisions').then(d => d ?? []), + queryFn: () => releaseAPI.decisions().then(d => d ?? []), enabled: tab === 'decisions', }) const { data: checklist } = useQuery({ queryKey: ['release', 'checklist'], - queryFn: () => api.get(`/admin-api/release/checklists/${checklistVer}`).then(d => d ?? []), + queryFn: () => releaseAPI.checklists(checklistVer).then(d => d ?? []), enabled: tab === 'checklist', }) @@ -39,8 +39,8 @@ export default function ReleaseAdmin() { mutationFn: (values: any) => { const endpoint = tab === 'changelogs' ? 'changelogs' : 'decisions' return editing - ? api.patch(`/admin-api/release/${endpoint}/${editing.id}`, values) - : api.post(`/admin-api/release/${endpoint}`, values) + ? releaseAPI.update(endpoint, editing.id, values) + : releaseAPI.create(endpoint, values) }, onSuccess: () => { message.success(editing ? '已更新' : '已创建') @@ -50,13 +50,13 @@ export default function ReleaseAdmin() { }) 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'] }) }, }) const toggleCheck = useMutation({ 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'] }), }) diff --git a/src/pages/ReportingAdmin.tsx b/src/pages/ReportingAdmin.tsx index 56851f7..564783e 100644 --- a/src/pages/ReportingAdmin.tsx +++ b/src/pages/ReportingAdmin.tsx @@ -1,8 +1,7 @@ import { Card, Button, Space, Typography, Table, message, Select } from 'antd' import { DownloadOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' -import { api } from '@/services/http-client' -import { getAccessToken } from '@/services/token-store' +import { reportingAPI } from '@/api' import { useState } from 'react' const { Title } = Typography @@ -20,17 +19,13 @@ export default function ReportingAdmin() { const { data: jobs } = useQuery({ queryKey: ['admin', 'export-jobs'], - queryFn: () => api.get('/admin-api/reporting/jobs'), + queryFn: () => reportingAPI.jobs(), }) const download = async (type: string) => { try { - const res = await fetch(`/admin-api/reporting/export/${type}?days=${days}`, { - headers: { Authorization: `Bearer ${getAccessToken() || ''}` }, - }) - if (!res.ok) throw new Error('下载失败') - const text = await res.text() - downloadBlob(text, `${type}-report-${days}d.csv`) + const text = await reportingAPI.exportCSV(type, days) + downloadBlob(text as string, `${type}-report-${days}d.csv`) } catch { message.error('导出失败') } diff --git a/src/pages/ReviewAdmin.tsx b/src/pages/ReviewAdmin.tsx index bd38e35..542f5ef 100644 --- a/src/pages/ReviewAdmin.tsx +++ b/src/pages/ReviewAdmin.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Table, Tag, Space, Input, Select, Typography } from 'antd' import { SearchOutlined } from '@ant-design/icons' import { useQuery } from '@tanstack/react-query' -import { api } from '@/services/http-client' +import { reviewsAPI } from '@/api' import type { ReviewCardItem, PaginatedResult } from '@/types/api' import { reviewStatusLabels, scheduleStateLabels, difficultyLabels } from '@/constants/labels' @@ -22,7 +22,7 @@ export default function ReviewAdmin() { const params = new URLSearchParams() if (search) params.set('search', search) if (statusFilter) params.set('status', statusFilter); else params.delete('status') - return api.get>(`/admin-api/reviews?${params.toString()}`) + return reviewsAPI.list(params.toString()) }, staleTime: 60_000, refetchInterval: 30_000, diff --git a/src/pages/Secrets.tsx b/src/pages/Secrets.tsx index 1f5d1ed..e0325a7 100644 --- a/src/pages/Secrets.tsx +++ b/src/pages/Secrets.tsx @@ -2,7 +2,7 @@ import { PlusOutlined, DeleteOutlined, KeyOutlined, RotateRightOutlined, StopOut import { useState } from 'react' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' 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 dayjs from 'dayjs' @@ -16,40 +16,40 @@ function SecretsPage() { const [form, setForm] = useState({ name: '', provider: 'deepseek', value: '', expiresAt: '' }) const [billForm, setBillForm] = useState({ provider: 'deepseek', billMonth: dayjs().format('YYYY-MM'), amount: 0, notes: '' }) - const { data: secrets } = useQuery({ queryKey: ['secrets'], queryFn: (): Promise => api.get('/admin-api/secrets') }) - const { data: logs } = useQuery({ queryKey: ['secrets', 'logs'], queryFn: (): Promise => api.get('/admin-api/secrets/logs') }) - const { data: bills } = useQuery({ queryKey: ['vendor', 'bills'], queryFn: (): Promise => api.get('/admin-api/vendor/bills') }) + const { data: secrets } = useQuery({ queryKey: ['secrets'], queryFn: (): Promise => secretsAPI.list() }) + const { data: logs } = useQuery({ queryKey: ['secrets', 'logs'], queryFn: (): Promise => secretsAPI.logs() }) + const { data: bills } = useQuery({ queryKey: ['vendor', 'bills'], queryFn: (): Promise => secretsAPI.vendorBills() }) const addSecret = async () => { - await api.post('/admin-api/secrets', form) + await secretsAPI.create(form) message.success('已添加') setAddOpen(false); qc.invalidateQueries({ queryKey: ['secrets'] }) } 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'] }) }, }) 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'] }) }, }) const deleteSecret = (id: string) => modal.confirm({ 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 () => { - await api.post('/admin-api/vendor/bills', billForm) + await secretsAPI.createVendorBill(billForm) message.success('账单已录入') setBillOpen(false); qc.invalidateQueries({ queryKey: ['vendor', 'bills'] }) } const deleteBill = (id: string) => modal.confirm({ 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 = [ diff --git a/src/pages/SecurityEvents.tsx b/src/pages/SecurityEvents.tsx index 05429f4..7b69f6f 100644 --- a/src/pages/SecurityEvents.tsx +++ b/src/pages/SecurityEvents.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Table, Button, Typography, Tag } from 'antd' import { ReloadOutlined, SafetyOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { contentSafetyAPI } from '@/api' import dayjs from 'dayjs' const contentTypeLabels: Record = { @@ -14,7 +14,7 @@ const { Title } = Typography export default function SecurityEventsPage() { const qc = useQueryClient() - const { data } = useQuery({ queryKey: ['security-events'], queryFn: (): Promise => api.get('/admin-api/content-safety/checks'), staleTime: 10_000 }) + const { data } = useQuery({ queryKey: ['security-events'], queryFn: () => contentSafetyAPI.checks(), staleTime: 10_000 }) const cols = [ { title: '时间', dataIndex: 'createdAt', width: 140, render: (d: string) => dayjs(d).format('MM-DD HH:mm:ss') }, diff --git a/src/pages/Servers.tsx b/src/pages/Servers.tsx index 965c4d6..d8ee90f 100644 --- a/src/pages/Servers.tsx +++ b/src/pages/Servers.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Card, Row, Col, Progress, Table, Tag, Typography, Button, Space, Tooltip, App } from 'antd' 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 @@ -89,7 +89,7 @@ function ServerCard({ server }: { server: ServerInfo }) { function WorkerStatusSection() { const { data, isLoading } = useQuery({ queryKey: ['servers', 'workers'], - queryFn: getWorkerStatus, + queryFn: serversAPI.workers, staleTime: 15_000, refetchInterval: 15_000, }) @@ -146,13 +146,13 @@ function ServersContent() { const { data } = useQuery({ queryKey: ['servers', 'metrics'], - queryFn: getServerMetrics, + queryFn: serversAPI.metrics, staleTime: 30_000, }) const { data: health } = useQuery({ queryKey: ['servers', 'health'], - queryFn: getServerHealth, + queryFn: serversAPI.health, staleTime: 60_000, }) diff --git a/src/pages/TaskAssistant.tsx b/src/pages/TaskAssistant.tsx index 0b6b975..78eddd3 100644 --- a/src/pages/TaskAssistant.tsx +++ b/src/pages/TaskAssistant.tsx @@ -1,8 +1,8 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { Input, Button, Typography, App, message as antMsg } from 'antd' import { PlusOutlined, ToolOutlined, DeleteOutlined, MessageOutlined, ArrowUpOutlined, CopyOutlined } from '@ant-design/icons' -import { streamChat, resolveApproval, type StreamEvent } from '@/services/ai-chat' -import { listConversations, createConversation, deleteConversation, getMessages, updateConversation, type Conversation } from '@/services/conversation-api' +import { aiAPI, conversationsAPI } from '@/api' +import { streamChat, type StreamEvent } from '@/services/ai-chat' import Markdown from '@/components/Markdown' const { Text } = Typography @@ -29,7 +29,7 @@ function ChatPage() { const messagesEndRef = useRef(null) const editInputRef = useRef(null) - const loadConversations = useCallback(async () => { try { setConversations(await listConversations()) } catch {} }, []) + const loadConversations = useCallback(async () => { try { setConversations(await conversationsAPI.list()) } catch {} }, []) useEffect(() => { loadConversations() }, [loadConversations]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations]) @@ -37,30 +37,30 @@ function ChatPage() { const switchConversation = useCallback(async (id: string) => { if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) } 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]) const handleNew = async () => { 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({ 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 saveTitle = async (id: string) => { 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) } const handleApprove = async (approvalMsg: Message, choice: string) => { setMessages(prev => prev.map(m => m.id === approvalMsg.id ? { ...m, approval: { ...m.approval!, resolved: true } } : m)) setWaitingApproval(false) - await resolveApproval(approvalMsg.approval!.runId, choice) + await aiAPI.resolveApproval(approvalMsg.approval!.runId, choice) antMsg.success(choice === 'deny' ? '已拒绝' : '已批准') } diff --git a/src/pages/Throttle.tsx b/src/pages/Throttle.tsx index b0fd897..c8e0d2f 100644 --- a/src/pages/Throttle.tsx +++ b/src/pages/Throttle.tsx @@ -1,13 +1,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Card, Row, Col, Statistic, Table, Button, Typography, Tag } from 'antd' import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { throttleAPI } from '@/api' const { Title } = Typography export default function ThrottlePage() { const qc = useQueryClient() - const { data } = useQuery({ queryKey: ['throttle'], queryFn: (): Promise => 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 cols = [ diff --git a/src/pages/UserManagement.tsx b/src/pages/UserManagement.tsx index a6c53e2..a77db5b 100644 --- a/src/pages/UserManagement.tsx +++ b/src/pages/UserManagement.tsx @@ -6,7 +6,7 @@ import { PlusOutlined, ReloadOutlined } from '@ant-design/icons' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { AdminUser } from '@/types/admin' 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 ConfirmDangerModal from '@/components/ConfirmDangerModal' @@ -29,11 +29,11 @@ export default function UserManagement() { const { data, isLoading } = useQuery({ queryKey: ['admin-users'], - queryFn: () => getAdminUsers({ page: 1, limit: 20 }), + queryFn: () => usersAPI.list({ page: 1, limit: 20 }), }) const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteAdminUser(id), + mutationFn: (id: string) => usersAPI.remove(id), onSuccess: () => { message.success('已删除') queryClient.invalidateQueries({ queryKey: ['admin-users'] }) diff --git a/src/pages/VectorAdmin.tsx b/src/pages/VectorAdmin.tsx index 1653b02..1f66dc6 100644 --- a/src/pages/VectorAdmin.tsx +++ b/src/pages/VectorAdmin.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd' import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons' -import { api } from '@/services/http-client' +import { vectorAPI } from '@/api' const { Title } = Typography @@ -11,19 +11,19 @@ export default function VectorAdminPage() { const { data: coll, isLoading: collLoading } = useQuery({ queryKey: ['vector', 'collection'], - queryFn: (): Promise => api.get('/admin-api/vector/collection'), + queryFn: (): Promise => vectorAPI.collection(), staleTime: 30_000, }) const { data: count } = useQuery({ queryKey: ['vector', 'count'], - queryFn: (): Promise => api.get('/admin-api/vector/count'), + queryFn: (): Promise => vectorAPI.count(), staleTime: 30_000, }) const handleReindex = async () => { try { - await api.post('/admin-api/vector/reindex') + await vectorAPI.reindex() message.success('索引重建已提交') qc.invalidateQueries({ queryKey: ['vector'] }) } catch { diff --git a/src/pages/learning/AnomalyPage.tsx b/src/pages/learning/AnomalyPage.tsx new file mode 100644 index 0000000..83ea882 --- /dev/null +++ b/src/pages/learning/AnomalyPage.tsx @@ -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('failed'); + const [page, setPage] = useState(1); + const [errorCodeFilter, setErrorCodeFilter] = useState(''); + const [detailId, setDetailId] = useState(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) => {eventTypeLabels[t] || t} }, + { title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140, render: (c?: string) => c ? {c} : '-' }, + { 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 ( + + + { setErrorCodeFilter(e.target.value); setPage(1); }} style={{ width: 180 }} /> + + { setActiveTab(k); setPage(1); }} + items={[ + { key: 'failed', label: 失败事件 {data?.total || 0}, children:
({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> }, + { key: 'warning', label: 警告事件 {data?.total || 0}, children:
({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> }, + { key: 'duplicate', label: 重复事件 {data?.total || 0}, children:
({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> }, + ]} /> + setDetailId(null)} width={640} loading={detailLoading}> + {detail && ( + + {detail.eventId} + {detail.userId} + {detail.materialId} + {detail.eventType} + {detail.status} + {detail.errorCode || '-'} + {detail.errorMessage || '-'} + {detail.retryCount ?? 0} + {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + + )} + + + ); +} diff --git a/src/pages/learning/DailyActivityPage.tsx b/src/pages/learning/DailyActivityPage.tsx new file mode 100644 index 0000000..925f6ad --- /dev/null +++ b/src/pages/learning/DailyActivityPage.tsx @@ -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>({}); + const [detail, setDetail] = useState(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) => 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v} }, + { 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 ( + + + setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> + { + 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; }); + }} /> + + +
({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} /> + setDetail(null)} width={520}> + {detail && ( + + {detail.userId} + {detail.activityDate ? dayjs(detail.activityDate).format('YYYY-MM-DD') : '-'} + {detail.readingSeconds ?? 0} + {detail.focusMinutes ?? '-'} + {detail.materialsReadCount ?? 0} + {detail.markedReadCount ?? 0} + {detail.sessionCount ?? '-'} + {detail.knowledgeBaseId || '-'} + {detail.eventCount ?? '-'} + {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + + )} + + + ); +} diff --git a/src/pages/learning/Dashboard.tsx b/src/pages/learning/Dashboard.tsx index 228407b..e76ddcc 100644 --- a/src/pages/learning/Dashboard.tsx +++ b/src/pages/learning/Dashboard.tsx @@ -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 { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { learningAdminAPI } from '../../services/learningAdmin'; +import { learningAPI } from '@/api'; import { eventTypeLabels } from '../../constants/labels'; const { RangePicker } = DatePicker; @@ -14,13 +14,13 @@ export default function DashboardPage() { const { data: kbList = [] } = useQuery({ queryKey: ['admin-kb-list'], - queryFn: () => learningAdminAPI.getKnowledgeBases(), + queryFn: () => learningAPI.getKnowledgeBases(), staleTime: 5 * 60 * 1000, }); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['admin-dashboard', kbId, dateRange], - queryFn: () => learningAdminAPI.getDashboard({ + queryFn: () => learningAPI.getDashboard({ knowledgeBaseId: kbId, startDate: dateRange?.[0], endDate: dateRange?.[1], diff --git a/src/pages/learning/DataPages.tsx b/src/pages/learning/DataPages.tsx deleted file mode 100644 index 00992f1..0000000 --- a/src/pages/learning/DataPages.tsx +++ /dev/null @@ -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

{title}

{children}
; -} - -// ── Reading Events ── - -export function ReadingEventPage() { - const [page, setPage] = useState(1); - const [filters, setFilters] = useState>(() => { - const params = new URLSearchParams(window.location.search); - const init: Record = {}; - const csid = params.get('clientSessionId'); - if (csid) init.clientSessionId = csid; - return init; - }); - const [detailId, setDetailId] = useState(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) => {eventTypeLabels[t] || t} }, - { title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 }, - { title: '状态', dataIndex: 'status', key: 'status', width: 90, - render: (s: string) => {eventStatusLabels[s] || s} }, - { title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, - render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' }, - ]; - - return ( - - - setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> - setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> - setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} /> - setFilters(f => ({ ...f, status: v || '' }))} - options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }, { value: 'pending', label: '待处理' }]} /> - { - 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; }); - } - }} - /> - - -
({ onClick: () => setDetailId(record.eventId), style: { cursor: 'pointer' } })} /> - - setDetailId(null)} width={640} loading={detailLoading}> - {detail && ( - - {detail.eventId} - {detail.userId} - {detail.materialId} - {detail.eventType} - {detail.status} - {detail.activeSecondsDelta ?? '-'} - {detail.sequence ?? '-'} - {detail.clientSessionId ?? '-'} - {detail.readingTargetType ?? '-'} - {detail.platform ?? '-'} - {detail.appVersion ?? '-'} - {detail.clientTimezoneOffsetMinutes ?? '-'} - {detail.errorCode || '-'} - {detail.retryCount ?? 0} - {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.knowledgeBaseId || '-'} - - )} - - - ); -} - -// ── Sessions ── - -export function SessionPage() { - const [page, setPage] = useState(1); - const [filters, setFilters] = useState>({}); - const [detailId, setDetailId] = useState(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 ? {targetTypeLabels[v] || v} : '-' }, - { 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) => {sessionStatusLabels[s] || s} }, - { 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 ( - - - setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> - setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> - setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} /> -
({ onClick: () => setDetailId(r.id), style: { cursor: 'pointer' } })} /> - - setDetailId(null)} width={680} loading={detailLoading}> - {detail && ( - - {detail.id} - {detail.clientSessionId || '-'} - {detail.userId} - {detail.materialId} - {detail.targetType || '-'} - {detail.mode || '-'} - {detail.knowledgeBaseId || '-'} - {detail.status} - {detail.totalActiveSeconds ?? '-'} - {detail.focusMinutes ?? '-'} - {detail.interrupted ? : '否'} - {detail.missingMaterialClosed ? : '否'} - {detail.abnormalDelta ? {detail.abnormalDelta} : '-'} - {detail.startedAt ? dayjs(detail.startedAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.endedAt ? dayjs(detail.endedAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.lastEventAt ? dayjs(detail.lastEventAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.eventCount ?? '-'} - {detail.durationBreakdown || '-'} - - )} - {detail?.clientSessionId && ( -
- -
- )} -
- - ); -} - -// ── Progress ── - -export function ProgressPage() { - const [page, setPage] = useState(1); - const [filters, setFilters] = useState>({}); - const [detail, setDetail] = useState(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 ? {targetTypeLabels[v] || v} : '-' }, - { 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) => {progressStatusLabels[s] || s} }, - { 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 ( - - - setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> - setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> -
({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} /> - - setDetail(null)} width={600}> - {detail && ( - <> - - {detail.userId} - {detail.materialId} - {detail.readingTargetType || '-'} - {detail.knowledgeBaseId || '-'} - {detail.status} - {detail.isMarkedRead ? '✅' : '否'} - {detail.lastProgress != null ? `${Math.round(detail.lastProgress * 100)}%` : '-'} - {detail.totalActiveSeconds ?? '-'} - {detail.lastClientSessionId || '-'} -
{detail.lastPosition ? JSON.stringify(detail.lastPosition, null, 2) : '-'}
- {detail.firstOpenedAt ? dayjs(detail.firstOpenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.lastReadAt ? dayjs(detail.lastReadAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.relatedMaterials?.join(', ') || '-'} - {detail.eventCount ?? '-'} - {detail.sessionCount ?? '-'} -
-
- -
- - )} -
- - ); -} - -// ── Daily Activities ── - -export function DailyActivityPage() { - const [page, setPage] = useState(1); - const [filters, setFilters] = useState>({}); - const [detail, setDetail] = useState(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) => 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v} }, - { 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 ( - - - setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> -
({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} /> - - setDetail(null)} width={520}> - {detail && ( - - {detail.userId} - {detail.activityDate ? dayjs(detail.activityDate).format('YYYY-MM-DD') : '-'} - {detail.readingSeconds ?? 0} - {detail.focusMinutes ?? '-'} - {detail.materialsReadCount ?? 0} - {detail.markedReadCount ?? 0} - {detail.sessionCount ?? '-'} - {detail.knowledgeBaseId || '-'} - {detail.eventCount ?? '-'} - {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - - )} - - - ); -} - -// ── Records ── - -export function RecordPage() { - const [page, setPage] = useState(1); - const [filters, setFilters] = useState>({}); - const [detail, setDetail] = useState(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) => {recordTypeLabels[t] || t} }, - { 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 ( - - - setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> - setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> - - -
({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} /> - - setDetail(null)} width={500}> - {detail && ( - - {detail.userId} - {recordTypeLabels[detail.recordType] || detail.recordType} - {recordTitleLabels[detail.title] || detail.title || '-'} - {detail.durationSeconds ?? '-'} - {detail.materialId || '-'} - {detail.occurredAt ? dayjs(detail.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - - )} - - - ); -} - -// ── Failed / Warning / Duplicate Events ── - -export function AnomalyPage() { - const [activeTab, setActiveTab] = useState('failed'); - const [page, setPage] = useState(1); - const [errorCodeFilter, setErrorCodeFilter] = useState(''); - const [detailId, setDetailId] = useState(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) => {eventTypeLabels[t] || t} }, - { title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140, - render: (c?: string) => c ? {c} : '-' }, - { 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 ( - - - { setErrorCodeFilter(e.target.value); setPage(1); }} style={{ width: 180 }} /> - - { setActiveTab(k); setPage(1); }} - items={[ - { key: 'failed', label: 失败事件 {data?.total || 0}, - children:
({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> }, - { key: 'warning', label: 警告事件 {data?.total || 0}, - children:
({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> }, - { key: 'duplicate', label: 重复事件 {data?.total || 0}, - children:
({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> }, - ]} /> - - setDetailId(null)} width={640} loading={detailLoading}> - {detail && ( - - {detail.eventId} - {detail.userId} - {detail.materialId} - {detail.eventType} - {detail.status} - {detail.errorCode || '-'} - {detail.errorMessage || '-'} - {detail.retryCount ?? 0} - {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} - - )} - - - ); -} - -// ── 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: ( -
- {entry.occurredAt ? dayjs(entry.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'} -
{recordTypeLabels[entry.recordType] || entry.recordType}
- {entry.title || '-'} - {entry.durationSeconds != null &&
时长 {entry.durationSeconds}s
} - {entry.materialId &&
资料 {entry.materialId}
} -
- ), - })) - : []; - - return ( - - - setUserId(e.target.value)} style={{ width: 300 }} /> - - - {data === undefined && !isFetching && 输入用户 ID 后点击查询} - {isFetching && 加载中...} - {data && !Array.isArray(data) ?
{JSON.stringify(data as any, null, 2)}
: null} - {Array.isArray(data) && data.length === 0 && !isFetching && 该用户暂无学习记录} - {items.length > 0 && } -
- ); -} - -// ── 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 ( - - - setUserId(e.target.value)} style={{ width: 300 }} /> - - - {data ?
{JSON.stringify(data, null, 2)}
: null} -
- ); -} - -// ── 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 ( - - - setMaterialId(e.target.value)} style={{ width: 300 }} /> - - - {data ?
{JSON.stringify(data, null, 2)}
: null} -
- ); -} diff --git a/src/pages/learning/MaterialDiagnosePage.tsx b/src/pages/learning/MaterialDiagnosePage.tsx new file mode 100644 index 0000000..ce34c0b --- /dev/null +++ b/src/pages/learning/MaterialDiagnosePage.tsx @@ -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 ( + + + setMaterialId(e.target.value)} style={{ width: 300 }} /> + + + {data ?
{JSON.stringify(data, null, 2)}
: null} +
+ ); +} diff --git a/src/pages/learning/PageWrapper.tsx b/src/pages/learning/PageWrapper.tsx new file mode 100644 index 0000000..90c83dd --- /dev/null +++ b/src/pages/learning/PageWrapper.tsx @@ -0,0 +1,3 @@ +export function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) { + return

{title}

{children}
; +} diff --git a/src/pages/learning/ProgressPage.tsx b/src/pages/learning/ProgressPage.tsx new file mode 100644 index 0000000..9efc257 --- /dev/null +++ b/src/pages/learning/ProgressPage.tsx @@ -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>({}); + const [detail, setDetail] = useState(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 ? {targetTypeLabels[v] || v} : '-' }, + { 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) => {progressStatusLabels[s] || s} }, + { 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 ( + + + setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> + setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> +
({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} /> + setDetail(null)} width={600}> + {detail && ( + <> + + {detail.userId} + {detail.materialId} + {detail.readingTargetType || '-'} + {detail.knowledgeBaseId || '-'} + {detail.status} + {detail.isMarkedRead ? '✅' : '否'} + {detail.lastProgress != null ? `${Math.round(detail.lastProgress * 100)}%` : '-'} + {detail.totalActiveSeconds ?? '-'} + {detail.lastClientSessionId || '-'} +
{detail.lastPosition ? JSON.stringify(detail.lastPosition, null, 2) : '-'}
+ {detail.firstOpenedAt ? dayjs(detail.firstOpenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.lastReadAt ? dayjs(detail.lastReadAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.relatedMaterials?.join(', ') || '-'} + {detail.eventCount ?? '-'} + {detail.sessionCount ?? '-'} +
+ + )} +
+ + ); +} diff --git a/src/pages/learning/ReadingEventPage.tsx b/src/pages/learning/ReadingEventPage.tsx new file mode 100644 index 0000000..9150464 --- /dev/null +++ b/src/pages/learning/ReadingEventPage.tsx @@ -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>(() => { + const params = new URLSearchParams(window.location.search); + const init: Record = {}; + const csid = params.get('clientSessionId'); + if (csid) init.clientSessionId = csid; + return init; + }); + const [detailId, setDetailId] = useState(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) => {eventTypeLabels[t] || t} }, + { title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 }, + { title: '状态', dataIndex: 'status', key: 'status', width: 90, + render: (s: string) => {eventStatusLabels[s] || s} }, + { title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160, + render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' }, + ]; + + return ( + + + setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> + setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> + setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} /> + setFilters(f => ({ ...f, status: v || '' }))} + options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }, { value: 'pending', label: '待处理' }]} /> + { + 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; }); + } + }} /> + + +
({ onClick: () => setDetailId(record.eventId), style: { cursor: 'pointer' } })} /> + setDetailId(null)} width={640} loading={detailLoading}> + {detail && ( + + {detail.eventId} + {detail.userId} + {detail.materialId} + {detail.eventType} + {detail.status} + {detail.activeSecondsDelta ?? '-'} + {detail.sequence ?? '-'} + {detail.clientSessionId ?? '-'} + {detail.readingTargetType ?? '-'} + {detail.platform ?? '-'} + {detail.appVersion ?? '-'} + {detail.clientTimezoneOffsetMinutes ?? '-'} + {detail.errorCode || '-'} + {detail.retryCount ?? 0} + {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.knowledgeBaseId || '-'} + + )} + + + ); +} diff --git a/src/pages/learning/RecordPage.tsx b/src/pages/learning/RecordPage.tsx new file mode 100644 index 0000000..25d115c --- /dev/null +++ b/src/pages/learning/RecordPage.tsx @@ -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>({}); + const [detail, setDetail] = useState(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) => {recordTypeLabels[t] || t} }, + { 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 ( + + + setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> + setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> + + +
({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} /> + setDetail(null)} width={500}> + {detail && ( + + {detail.userId} + {recordTypeLabels[detail.recordType] || detail.recordType} + {recordTitleLabels[detail.title] || detail.title || '-'} + {detail.durationSeconds ?? '-'} + {detail.materialId || '-'} + {detail.occurredAt ? dayjs(detail.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + + )} + + + ); +} diff --git a/src/pages/learning/ReplayPage.tsx b/src/pages/learning/ReplayPage.tsx index a6c1b08..da2bb0b 100644 --- a/src/pages/learning/ReplayPage.tsx +++ b/src/pages/learning/ReplayPage.tsx @@ -3,7 +3,7 @@ import { Card, Input, Button, Space, message, Modal, Form, DatePicker, Select, D import { ThunderboltOutlined, WarningOutlined } from '@ant-design/icons'; import { useMutation } from '@tanstack/react-query'; -import { learningAdminAPI } from '../../services/learningAdmin'; +import { learningAPI } from '@/api'; const { RangePicker } = DatePicker; @@ -12,13 +12,13 @@ export default function ReplayPage() { const [batchForm] = Form.useForm(); const singleMutation = useMutation({ - mutationFn: (id: string) => learningAdminAPI.replayEvent(id), + mutationFn: (id: string) => learningAPI.replayEvent(id), onSuccess: (data) => message.success(data.message || '重放成功'), onError: (e: Error) => message.error(`重放失败: ${e.message}`), }); const batchMutation = useMutation({ - mutationFn: (params: Record) => learningAdminAPI.replayEventsBatch(params), + mutationFn: (params: Record) => learningAPI.replayEventsBatch(params), onSuccess: (data) => message.success(`完成: ${data.replayed} 成功, ${data.failed} 失败`), onError: (e: Error) => message.error(`批量重放失败: ${e.message}`), }); diff --git a/src/pages/learning/SessionPage.tsx b/src/pages/learning/SessionPage.tsx new file mode 100644 index 0000000..7a485fe --- /dev/null +++ b/src/pages/learning/SessionPage.tsx @@ -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>({}); + const [detailId, setDetailId] = useState(null); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + 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 ? {targetTypeLabels[v] || v} : '-' }, + { 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) => {sessionStatusLabels[s] || s} }, + { 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 ( + + + setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} /> + setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} /> +
setSelectedRowKeys(keys) }} + onRow={(r: any) => ({ onClick: () => setDetailId(r.id), style: { cursor: 'pointer' } })} /> + setDetailId(null)} width={680} loading={detailLoading}> + {detail && ( + + {detail.id} + {detail.clientSessionId || '-'} + {detail.userId} + {detail.userName || '-'} + {detail.materialId} + {detail.materialName || '-'} + {detail.targetType || '-'} + {detail.mode || '-'} + {detail.knowledgeBaseId || '-'} + {detail.kbName || '-'} + {detail.status} + {detail.totalActiveSeconds ?? '-'} + {detail.focusMinutes ?? '-'} + {detail.interrupted ? : '否'} + {detail.missingMaterialClosed ? : '否'} + {detail.abnormalDelta ? {detail.abnormalDelta} : '-'} + {detail.startedAt ? dayjs(detail.startedAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.endedAt ? dayjs(detail.endedAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.lastEventAt ? dayjs(detail.lastEventAt).format('YYYY-MM-DD HH:mm:ss') : '-'} + {detail.eventCount ?? '-'} + {detail.durationBreakdown || '-'} + + )} + {detail?.clientSessionId && ( +
+ +
+ )} +
+ + ); +} diff --git a/src/pages/learning/UserDiagnosePage.tsx b/src/pages/learning/UserDiagnosePage.tsx new file mode 100644 index 0000000..0b21f1b --- /dev/null +++ b/src/pages/learning/UserDiagnosePage.tsx @@ -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 ( + + + setUserId(e.target.value)} style={{ width: 300 }} /> + + + {data ?
{JSON.stringify(data, null, 2)}
: null} +
+ ); +} diff --git a/src/pages/learning/UserTimelinePage.tsx b/src/pages/learning/UserTimelinePage.tsx new file mode 100644 index 0000000..50b859a --- /dev/null +++ b/src/pages/learning/UserTimelinePage.tsx @@ -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: ( +
+ {entry.occurredAt ? dayjs(entry.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'} +
{recordTypeLabels[entry.recordType] || entry.recordType}
+ {entry.title || '-'} + {entry.durationSeconds != null &&
时长 {entry.durationSeconds}s
} + {entry.materialId &&
资料 {entry.materialId}
} +
+ ), + })) + : []; + + return ( + + + setUserId(e.target.value)} style={{ width: 300 }} /> + + + {data === undefined && !isFetching && 输入用户 ID 后点击查询} + {isFetching && 加载中...} + {data && !Array.isArray(data) ?
{JSON.stringify(data as any, null, 2)}
: null} + {Array.isArray(data) && data.length === 0 && !isFetching && 该用户暂无学习记录} + {items.length > 0 && } +
+ ); +} diff --git a/src/services/admin-api.ts b/src/services/admin-api.ts deleted file mode 100644 index 766923f..0000000 --- a/src/services/admin-api.ts +++ /dev/null @@ -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 { - return api.post('/admin-api/auth/login', { email, password }) -} - -interface TokenPair { - accessToken: string - refreshToken: string - adminUser: AdminUser -} - -export function refreshAdminToken(refreshToken: string): Promise { - return api.post('/admin-api/auth/refresh', { refreshToken }) -} - -export function logoutAdmin(refreshToken: string): Promise { - return api.post('/admin-api/auth/logout', { refreshToken }) -} - -export function getCurrentAdmin(): Promise { - return api.get('/admin-api/auth/me') -} - -// ── Dashboard ───────────────────────────────────────── - -export async function getDashboardStats(): Promise { - if (import.meta.env.DEV) return MOCK_DASHBOARD_STATS - return api.get('/admin-api/dashboard/stats') -} - -// ── Admin Users ─────────────────────────────────────── - -export interface AdminUsersQuery extends PaginationParams { - search?: string - role?: string - status?: string -} - -export async function getAdminUsers( - params: AdminUsersQuery, -): Promise> { - try { - return await api.get>( - `/admin-api/admin-users?${new URLSearchParams(params as Record).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 { - return api.get(`/admin-api/admin-users/${id}`) -} - -export function createAdminUser(data: { - email: string - password: string - displayName: string - role: string -}): Promise { - return api.post('/admin-api/admin-users', data) -} - -export function updateAdminUser( - id: string, - data: { role?: string; status?: string; displayName?: string }, -): Promise { - return api.put(`/admin-api/admin-users/${id}`, data) -} - -export function deleteAdminUser(id: string): Promise { - return api.delete(`/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> { - try { - return await api.get>( - `/admin-api/audit-logs?${new URLSearchParams(params as Record).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 { - return api.get(`/admin-api/audit-logs/${id}`) -} diff --git a/src/services/api.ts b/src/services/api.ts deleted file mode 100644 index 4a8cc25..0000000 --- a/src/services/api.ts +++ /dev/null @@ -1,93 +0,0 @@ -const BASE_URL = '' - -interface ApiResponse { - success: boolean - data: T - message?: string - statusCode?: number -} - -function getToken(): string | null { - return localStorage.getItem('admin_access_token') -} - -async function request( - path: string, - options: RequestInit = {}, -): Promise { - const token = getToken() - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - } - 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 = await res.json() - if (!json.success) { - throw new Error(json.message || '请求失败') - } - return json.data -} - -async function tryRefresh(): Promise { - 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(path: string) { - return request(path) - }, - post(path: string, body?: unknown) { - return request(path, { - method: 'POST', - body: body ? JSON.stringify(body) : undefined, - }) - }, - put(path: string, body?: unknown) { - return request(path, { - method: 'PUT', - body: body ? JSON.stringify(body) : undefined, - }) - }, - delete(path: string, body?: unknown) { - return request(path, { - method: 'DELETE', - body: body ? JSON.stringify(body) : undefined, - }) - }, -} diff --git a/src/services/billing-api.ts b/src/services/billing-api.ts deleted file mode 100644 index 79790ab..0000000 --- a/src/services/billing-api.ts +++ /dev/null @@ -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') } diff --git a/src/services/config-api.ts b/src/services/config-api.ts deleted file mode 100644 index 0c97275..0000000 --- a/src/services/config-api.ts +++ /dev/null @@ -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 { return api.post('/admin-api/config', { key, value }) } -export function updateConfig(key: string, value: string): Promise { return api.patch(`/admin-api/config/${key}`, { value }) } -export function deleteConfig(key: string): Promise { return api.delete(`/admin-api/config/${key}`) } -export function toggleFlag(name: string, enabled: boolean): Promise { return api.post(`/admin-api/config/flags/${name}`, { enabled }) } -export function setFlagWhitelist(name: string, whitelist: string): Promise { return api.post(`/admin-api/config/flags/${name}`, { whitelist }) } -export function getChangeLog(): Promise { return api.get('/admin-api/config/changelog') } diff --git a/src/services/conversation-api.ts b/src/services/conversation-api.ts deleted file mode 100644 index 0751323..0000000 --- a/src/services/conversation-api.ts +++ /dev/null @@ -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 { - return api.get('/admin-api/conversations') -} - -export function getMessages(conversationId: string): Promise { - return api.get(`/admin-api/conversations/${conversationId}/messages`) -} - -export function createConversation(title?: string): Promise { - return api.post('/admin-api/conversations', { title }) -} - -export function updateConversation(id: string, title: string): Promise { - return api.patch(`/admin-api/conversations/${id}`, { title }) -} - -export function deleteConversation(id: string): Promise { - return api.delete(`/admin-api/conversations/${id}`) -} diff --git a/src/services/costs-api.ts b/src/services/costs-api.ts deleted file mode 100644 index ef9a1b8..0000000 --- a/src/services/costs-api.ts +++ /dev/null @@ -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 { return api.get('/admin-api/costs/summary') } -export function listCosts(): Promise { return api.get('/admin-api/costs') } -export function createCost(data: Omit & { currency?: string }): Promise { return api.post('/admin-api/costs', data) } -export function updateCost(id: string, data: Partial): Promise { return api.patch(`/admin-api/costs/${id}`, data) } -export function deleteCost(id: string): Promise { return api.delete(`/admin-api/costs/${id}`) } diff --git a/src/services/events-api.ts b/src/services/events-api.ts deleted file mode 100644 index 43e2152..0000000 --- a/src/services/events-api.ts +++ /dev/null @@ -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 { return api.get(`/admin-api/events/${queue}/jobs/${jobId}`) } -export function retryJob(queue: string, jobId: string): Promise { return api.post(`/admin-api/events/${queue}/jobs/${jobId}/retry`) } diff --git a/src/services/http-client.ts b/src/services/http-client.ts index 2df3695..fcc1452 100644 --- a/src/services/http-client.ts +++ b/src/services/http-client.ts @@ -1,121 +1,3 @@ -import { - getAccessToken, - getRefreshToken, - setTokens, - clearTokens, -} from './token-store' - -interface ApiResponse { - 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 | null = null - -async function tryRefresh(): Promise { - 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( - path: string, - options: RequestInit = {}, - retried = false, -): Promise { - const token = getAccessToken() - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - } - 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(path, options, true) - } - clearTokens() - window.location.href = '/login' - throw new ApiError('登录已过期', 401) - } - - const json: ApiResponse = await res.json() - if (!json.success) { - throw new ApiError(json.message || '请求失败', res.status, json.statusCode) - } - return json.data -} - -export const api = { - get(path: string) { - return request(path) - }, - post(path: string, body?: unknown, extra?: RequestInit & { signal?: AbortSignal }) { - return request(path, { - method: 'POST', - body: body != null ? JSON.stringify(body) : undefined, - ...extra, - }) - }, - put(path: string, body?: unknown) { - return request(path, { - method: 'PUT', - body: body != null ? JSON.stringify(body) : undefined, - }) - }, - patch(path: string, body?: unknown) { - return request(path, { - method: 'PATCH', - body: body != null ? JSON.stringify(body) : undefined, - }) - }, - delete(path: string, body?: unknown) { - return request(path, { - method: 'DELETE', - body: body != null ? JSON.stringify(body) : undefined, - }) - }, -} +// Re-export from centralized lib — backward compatible for existing pages +export { api, ApiError } from '../lib/api-client' +export { default } from '../lib/api-client' diff --git a/src/services/knowledge-api.ts b/src/services/knowledge-api.ts deleted file mode 100644 index 3ec0e5c..0000000 --- a/src/services/knowledge-api.ts +++ /dev/null @@ -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 { return api.get(`/admin-api/knowledge-bases/${id}`) } -export function deleteKnowledgeBase(id: string): Promise { return api.delete(`/admin-api/knowledge-bases/${id}`) } diff --git a/src/services/learningAdmin.ts b/src/services/learningAdmin.ts deleted file mode 100644 index f418821..0000000 --- a/src/services/learningAdmin.ts +++ /dev/null @@ -1,69 +0,0 @@ -const BASE = '/admin-api/learning'; - -export interface PaginatedResponse { - items: T[]; - total: number; -} - -interface AnomalyData { - deltaOutliers: unknown[]; - futureEvents: unknown[]; -} - -async function getApi(path: string, params?: Record): Promise { - 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(path: string, body?: any): Promise { - 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('/dashboard', params), - getKnowledgeBases: () => getApi>('/knowledge-bases'), - getReadingEvents: (params?: Record) => getApi>('/reading-events', params), - getReadingEventDetail: (id: string) => getApi(`/reading-events/${id}`), - getFailedEvents: (params?: Record) => getApi>('/reading-events/failed', params), - getSessions: (params?: Record) => getApi>('/sessions', params), - getSessionDetail: (id: string) => getApi(`/sessions/${id}`), - getProgress: (params?: Record) => getApi>('/progress', params), - getDailyActivities: (params?: Record) => getApi>('/daily-activities', params), - getRecords: (params?: Record) => getApi>('/records', params), - getUserTimeline: (userId: string) => getApi('/user-timeline', { userId }), - getUserDiagnose: (userId: string) => getApi('/user-diagnose', { userId }), - getMaterialDiagnose: (materialId: string) => getApi('/material-diagnose', { materialId }), - getAnomalies: () => getApi('/anomalies'), - getTemporaryMaterials: (params?: Record) => getApi>('/temporary-materials', params), - recalculate: () => postApi('/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('/export', { type }), -}; diff --git a/src/services/server-api.ts b/src/services/server-api.ts deleted file mode 100644 index 63baa71..0000000 --- a/src/services/server-api.ts +++ /dev/null @@ -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 { - 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') -} diff --git a/src/services/token-store.ts b/src/services/token-store.ts index 4786d15..52bdee7 100644 --- a/src/services/token-store.ts +++ b/src/services/token-store.ts @@ -1,35 +1,9 @@ -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 | null { - try { - const raw = localStorage.getItem(ADMIN_USER_KEY) - return raw ? (JSON.parse(raw) as T) : null - } catch { - return null - } -} - -export function setStoredAdminUser(user: T): void { - localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user)) -} +// Re-export from centralized lib — backward compatible for existing imports +export { + getAccessToken, + getRefreshToken, + setTokens, + clearTokens, + getStoredAdminUser, + setStoredAdminUser, +} from '../lib/token'