diff --git a/admin-v2/index.html b/admin-v2/index.html index 1a5696b..a547578 100644 --- a/admin-v2/index.html +++ b/admin-v2/index.html @@ -1,12 +1,16 @@ - + - admin-v2 + ZCLAW Admin + + + + 跳转到主要内容
diff --git a/admin-v2/package.json b/admin-v2/package.json index 15b495c..b344dd9 100644 --- a/admin-v2/package.json +++ b/admin-v2/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -38,6 +39,7 @@ "globals": "^17.4.0", "jsdom": "^29.0.1", "msw": "^2.12.14", + "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", diff --git a/admin-v2/pnpm-lock.yaml b/admin-v2/pnpm-lock.yaml index b6584fa..1a6613b 100644 --- a/admin-v2/pnpm-lock.yaml +++ b/admin-v2/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@eslint/js': specifier: ^9.39.4 version: 9.39.4 + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -62,16 +65,16 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1)) + version: 6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1)) eslint: specifier: ^9.39.4 - version: 9.39.4 + version: 9.39.4(jiti@2.6.1) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4) + version: 7.0.1(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@9.39.4) + version: 0.5.2(eslint@9.39.4(jiti@2.6.1)) globals: specifier: ^17.4.0 version: 17.4.0 @@ -81,18 +84,21 @@ importers: msw: specifier: ^2.12.14 version: 2.12.14(@types/node@24.12.0)(typescript@5.9.3) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 typescript: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: specifier: ^8.57.0 - version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + version: 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^8.0.1 - version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1) + version: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1) vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@24.12.0)(jsdom@29.0.1)(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1)) + version: 4.1.2(@types/node@24.12.0)(jsdom@29.0.1)(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1)) packages: @@ -930,6 +936,100 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.95.2': resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} @@ -1317,6 +1417,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1498,6 +1602,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.13.2: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -1575,6 +1682,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2060,6 +2171,13 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + terser@5.46.1: resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} engines: {node: '>=10'} @@ -2807,9 +2925,9 @@ snapshots: '@emotion/unitless@0.7.5': {} - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3348,6 +3466,74 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1) + '@tanstack/query-core@5.95.2': {} '@tanstack/react-query@5.95.2(react@19.2.4)': @@ -3421,15 +3607,15 @@ snapshots: '@types/statuses@2.0.6': {} - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -3437,14 +3623,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3467,13 +3653,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -3496,13 +3682,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.57.2 '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3518,10 +3704,10 @@ snapshots: dependencies: react: 19.2.4 - '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1))': + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1) '@vitest/expect@4.1.2': dependencies: @@ -3532,14 +3718,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1))': + '@vitest/mocker@4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1))': dependencies: '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.14(@types/node@24.12.0)(typescript@5.9.3) - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1) '@vitest/pretty-format@4.1.2': dependencies: @@ -3798,6 +3984,11 @@ snapshots: emoji-regex@8.0.0: {} + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + entities@6.0.1: {} es-define-property@1.0.1: {} @@ -3821,20 +4012,20 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@9.39.4): + eslint-plugin-react-refresh@0.5.2(eslint@9.39.4(jiti@2.6.1)): dependencies: - eslint: 9.39.4 + eslint: 9.39.4(jiti@2.6.1) eslint-scope@8.4.0: dependencies: @@ -3847,9 +4038,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4: + eslint@9.39.4(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -3883,6 +4074,8 @@ snapshots: minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 transitivePeerDependencies: - supports-color @@ -3983,6 +4176,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphql@16.13.2: {} has-flag@4.0.0: {} @@ -4040,6 +4235,8 @@ snapshots: isexe@2.0.0: {} + jiti@2.6.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -4491,6 +4688,10 @@ snapshots: tagged-tag@1.0.0: {} + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + terser@5.46.1: dependencies: '@jridgewell/source-map': 0.3.11 @@ -4542,13 +4743,13 @@ snapshots: dependencies: tagged-tag: 1.0.0 - typescript-eslint@8.57.2(eslint@9.39.4)(typescript@5.9.3): + typescript-eslint@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4575,7 +4776,7 @@ snapshots: dependencies: react: 19.2.4 - vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1): + vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4585,15 +4786,16 @@ snapshots: optionalDependencies: '@types/node': 24.12.0 fsevents: 2.3.3 + jiti: 2.6.1 terser: 5.46.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.2(@types/node@24.12.0)(jsdom@29.0.1)(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1)): + vitest@4.1.2(@types/node@24.12.0)(jsdom@29.0.1)(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1)): dependencies: '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1)) + '@vitest/mocker': 4.1.2(msw@2.12.14(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1)) '@vitest/pretty-format': 4.1.2 '@vitest/runner': 4.1.2 '@vitest/snapshot': 4.1.2 @@ -4610,7 +4812,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(terser@5.46.1) + vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 diff --git a/admin-v2/src/components/ErrorState.tsx b/admin-v2/src/components/ErrorState.tsx new file mode 100644 index 0000000..3bfb3a1 --- /dev/null +++ b/admin-v2/src/components/ErrorState.tsx @@ -0,0 +1,51 @@ +import { Button, Result } from 'antd' +import type { FallbackProps } from 'react-error-boundary' + +interface ErrorStateProps { + title?: string + message?: string + onRetry?: () => void +} + +export function ErrorState({ + title = '加载失败', + message, + onRetry, +}: ErrorStateProps) { + return ( +
+ + 重试 + + ) : undefined + } + /> +
+ ) +} + +export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+ + + +
+ } + /> + + ) +} diff --git a/admin-v2/src/components/PageHeader.tsx b/admin-v2/src/components/PageHeader.tsx new file mode 100644 index 0000000..22cadd4 --- /dev/null +++ b/admin-v2/src/components/PageHeader.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' + +interface PageHeaderProps { + title: string + description?: string + actions?: ReactNode +} + +export function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {actions &&
{actions}
} +
+ ) +} diff --git a/admin-v2/src/components/StatusTag.tsx b/admin-v2/src/components/StatusTag.tsx new file mode 100644 index 0000000..8773882 --- /dev/null +++ b/admin-v2/src/components/StatusTag.tsx @@ -0,0 +1,15 @@ +import { Tag } from 'antd' + +interface StatusTagProps { + status: string + labels: Record + colors: Record +} + +export function StatusTag({ status, labels, colors }: StatusTagProps) { + return ( + + {labels[status] || status} + + ) +} diff --git a/admin-v2/src/constants/status.ts b/admin-v2/src/constants/status.ts new file mode 100644 index 0000000..5175276 --- /dev/null +++ b/admin-v2/src/constants/status.ts @@ -0,0 +1,45 @@ +// ============================================================ +// 操作日志状态映射 — Dashboard 与 Logs 共用 +// ============================================================ + +export const actionLabels: Record = { + login: '登录', + logout: '登出', + create_account: '创建账号', + update_account: '更新账号', + delete_account: '删除账号', + create_provider: '创建服务商', + update_provider: '更新服务商', + delete_provider: '删除服务商', + create_model: '创建模型', + update_model: '更新模型', + delete_model: '删除模型', + create_token: '创建密钥', + revoke_token: '撤销密钥', + update_config: '更新配置', + create_prompt: '创建提示词', + update_prompt: '更新提示词', + archive_prompt: '归档提示词', + desktop_audit: '桌面端审计', +} + +export const actionColors: Record = { + login: 'green', + logout: 'default', + create_account: 'blue', + update_account: 'orange', + delete_account: 'red', + create_provider: 'blue', + update_provider: 'orange', + delete_provider: 'red', + create_model: 'blue', + update_model: 'orange', + delete_model: 'red', + create_token: 'blue', + revoke_token: 'red', + update_config: 'orange', + create_prompt: 'blue', + update_prompt: 'orange', + archive_prompt: 'red', + desktop_audit: 'default', +} diff --git a/admin-v2/src/layouts/AdminLayout.tsx b/admin-v2/src/layouts/AdminLayout.tsx index b7dfe2c..a2d065c 100644 --- a/admin-v2/src/layouts/AdminLayout.tsx +++ b/admin-v2/src/layouts/AdminLayout.tsx @@ -1,15 +1,9 @@ -// ============================================================ -// AdminLayout — ProLayout 管理后台布局 -// ============================================================ - +import { useState, useCallback, useEffect, useMemo } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router-dom' -import ProLayout from '@ant-design/pro-layout' import { DashboardOutlined, TeamOutlined, CloudServerOutlined, - ApiOutlined, - KeyOutlined, BarChartOutlined, SwapOutlined, SettingOutlined, @@ -17,87 +11,375 @@ import { MessageOutlined, RobotOutlined, LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + SunOutlined, + MoonOutlined, + ApiOutlined, } from '@ant-design/icons' +import { Avatar, Dropdown, Tooltip, Drawer } from 'antd' import { useAuthStore } from '@/stores/authStore' -import { Avatar, Dropdown, message } from 'antd' -import type { MenuDataItem } from '@ant-design/pro-layout' +import { useThemeStore, setThemeMode } from '@/stores/themeStore' +import type { ReactNode } from 'react' -const menuConfig: MenuDataItem[] = [ - { path: '/', name: '仪表盘', icon: }, - { path: '/accounts', name: '账号管理', icon: , permission: 'account:admin' }, - { path: '/providers', name: '服务商', icon: , permission: 'provider:manage' }, - { path: '/models', name: '模型管理', icon: , permission: 'model:read' }, - { path: '/agent-templates', name: 'Agent 模板', icon: , permission: 'model:read' }, - { path: '/api-keys', name: 'API 密钥', icon: , permission: 'admin:full' }, - { path: '/usage', name: '用量统计', icon: , permission: 'admin:full' }, - { path: '/relay', name: '中转任务', icon: , permission: 'relay:use' }, - { path: '/config', name: '系统配置', icon: , permission: 'config:read' }, - { path: '/prompts', name: '提示词管理', icon: , permission: 'prompt:read' }, - { path: '/logs', name: '操作日志', icon: , permission: 'admin:full' }, +// ============================================================ +// Navigation Configuration +// ============================================================ + +interface NavItem { + path: string + name: string + icon: ReactNode + permission?: string + group: string +} + +const navItems: NavItem[] = [ + { path: '/', name: '仪表盘', icon: , group: '核心' }, + { path: '/accounts', name: '账号管理', icon: , permission: 'account:admin', group: '资源管理' }, + { path: '/model-services', name: '模型服务', icon: , permission: 'provider:manage', group: '资源管理' }, + { path: '/agent-templates', name: 'Agent 模板', icon: , permission: 'model:read', group: '资源管理' }, + { path: '/api-keys', name: 'API 密钥', icon: , permission: 'provider:manage', group: '资源管理' }, + { path: '/usage', name: '用量统计', icon: , permission: 'admin:full', group: '运维' }, + { path: '/relay', name: '中转任务', icon: , permission: 'relay:use', group: '运维' }, + { path: '/logs', name: '操作日志', icon: , permission: 'admin:full', group: '运维' }, + { path: '/config', name: '系统配置', icon: , permission: 'config:read', group: '系统' }, + { path: '/prompts', name: '提示词管理', icon: , permission: 'prompt:read', group: '系统' }, ] -function filterMenuByPermission( - items: MenuDataItem[], - hasPermission: (p: string) => boolean, -): MenuDataItem[] { - return items - .filter((item) => !item.permission || hasPermission(item.permission as string)) - .map(({ permission, ...rest }) => ({ - ...rest, - children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined, - })) +// ============================================================ +// Sidebar Component +// ============================================================ + +function Sidebar({ + collapsed, + onNavigate, + activePath, +}: { + collapsed: boolean + onNavigate: (path: string) => void + activePath: string +}) { + const { hasPermission } = useAuthStore() + const visibleItems = navItems.filter( + (item) => !item.permission || hasPermission(item.permission), + ) + + const groups = useMemo(() => { + const map = new Map() + for (const item of visibleItems) { + const list = map.get(item.group) || [] + list.push(item) + map.set(item.group, list) + } + return map + }, [visibleItems]) + + return ( + + ) } +// ============================================================ +// Mobile Drawer Sidebar +// ============================================================ + +function MobileDrawer({ + open, + onClose, + onNavigate, + activePath, +}: { + open: boolean + onClose: () => void + onNavigate: (path: string) => void + activePath: string +}) { + return ( + + + + ) +} + +// ============================================================ +// Breadcrumb +// ============================================================ + +const breadcrumbMap: Record = { + '/': '仪表盘', + '/accounts': '账号管理', + '/model-services': '模型服务', + '/providers': '模型服务', + '/models': '模型服务', + '/api-keys': 'API 密钥', + '/agent-templates': 'Agent 模板', + '/usage': '用量统计', + '/relay': '中转任务', + '/config': '系统配置', + '/prompts': '提示词管理', + '/logs': '操作日志', +} + +// ============================================================ +// Main Layout +// ============================================================ + export default function AdminLayout() { const navigate = useNavigate() const location = useLocation() - const { account, hasPermission, logout } = useAuthStore() + const { account, logout } = useAuthStore() + const themeState = useThemeStore() + const [collapsed, setCollapsed] = useState(false) + const [mobileOpen, setMobileOpen] = useState(false) + const [isMobile, setIsMobile] = useState(false) - const menuData = filterMenuByPermission(menuConfig, hasPermission) + // Responsive detection + useEffect(() => { + const mq = window.matchMedia('(max-width: 768px)') + setIsMobile(mq.matches) + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) - const handleLogout = () => { + const handleNavigate = useCallback( + (path: string) => { + navigate(path) + setMobileOpen(false) + }, + [navigate], + ) + + const handleLogout = useCallback(() => { logout() - message.success('已退出登录') navigate('/login', { replace: true }) - } + }, [logout, navigate]) + + const toggleTheme = useCallback(() => { + setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark') + }, [themeState.resolved]) + + const currentPage = breadcrumbMap[location.pathname] || '页面' return ( - Z} - layout="mix" - fixSiderbar - fixedHeader - location={{ pathname: location.pathname }} - menuDataRender={() => menuData} - menuItemRender={(item, dom) => ( -
item.path && navigate(item.path)}>{dom}
+
+ {/* Desktop Sidebar */} + {!isMobile && ( + )} - avatarProps={{ - src: undefined, - title: account?.display_name || account?.username || 'Admin', - size: 'small', - render: (_, dom) => ( - , - label: '退出登录', - onClick: handleLogout, - }, - ], - }} - > - {dom} - - ), - }} - suppressSiderWhenMenuEmpty - contentStyle={{ padding: 24 }} - > - - + + {/* Mobile Drawer */} + {isMobile && ( + setMobileOpen(false)} + onNavigate={handleNavigate} + activePath={location.pathname} + /> + )} + + {/* Main Area */} +
+ {/* Header */} +
+
+ {/* Mobile menu button */} + {isMobile && ( + + )} + + {/* Collapse toggle (desktop) */} + {!isMobile && ( + + )} + + {/* Breadcrumb */} +
+ ZCLAW + / + + {currentPage} + +
+
+ + {/* Right actions */} +
+ {/* Theme toggle */} + + + + + {/* User avatar */} + , + label: '退出登录', + onClick: handleLogout, + }, + ], + }} + > + + +
+
+ + {/* Content */} +
+ +
+
+
) } diff --git a/admin-v2/src/main.tsx b/admin-v2/src/main.tsx index 20210ff..ba494e2 100644 --- a/admin-v2/src/main.tsx +++ b/admin-v2/src/main.tsx @@ -1,10 +1,12 @@ import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider } from 'react-router-dom' -import { ConfigProvider, App as AntApp } from 'antd' +import { ConfigProvider, App as AntApp, theme } from 'antd' import zhCN from 'antd/locale/zh_CN' import { router } from './router' import { ErrorBoundary } from './components/ErrorBoundary' +import { useThemeStore } from './stores/themeStore' +import './styles/globals.css' const queryClient = new QueryClient({ defaultOptions: { @@ -16,14 +18,71 @@ const queryClient = new QueryClient({ }, }) -createRoot(document.getElementById('root')!).render( - - +function ThemedApp() { + const resolved = useThemeStore((s) => s.resolved) + + return ( + + ) +} + +createRoot(document.getElementById('root')!).render( + + , ) diff --git a/admin-v2/src/pages/Accounts.tsx b/admin-v2/src/pages/Accounts.tsx index b8c5250..9362a30 100644 --- a/admin-v2/src/pages/Accounts.tsx +++ b/admin-v2/src/pages/Accounts.tsx @@ -5,10 +5,10 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd' -import { PlusOutlined } from '@ant-design/icons' import type { ProColumns } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components' import { accountService } from '@/services/accounts' +import { PageHeader } from '@/components/PageHeader' import type { AccountPublic } from '@/types' const roleLabels: Record = { @@ -116,7 +116,10 @@ export default function Accounts() { hideInSearch: true, render: (_, record) => ( - {record.status === 'active' ? ( @@ -142,6 +145,8 @@ export default function Accounts() { return (
+ + columns={columns} dataSource={data?.items ?? []} @@ -158,13 +163,13 @@ export default function Accounts() { /> 编辑账号} open={modalOpen} onOk={handleSave} onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }} confirmLoading={updateMutation.isPending} > -
+ diff --git a/admin-v2/src/pages/AgentTemplates.tsx b/admin-v2/src/pages/AgentTemplates.tsx index 11d80ff..b4c353e 100644 --- a/admin-v2/src/pages/AgentTemplates.tsx +++ b/admin-v2/src/pages/AgentTemplates.tsx @@ -4,8 +4,8 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions, MinusCircleOutlined } from 'antd' -import { PlusOutlined } from '@ant-design/icons' +import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd' +import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons' import type { ProColumns } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components' import { agentTemplateService } from '@/services/agent-templates' diff --git a/admin-v2/src/pages/ApiKeys.tsx b/admin-v2/src/pages/ApiKeys.tsx deleted file mode 100644 index 58f20c9..0000000 --- a/admin-v2/src/pages/ApiKeys.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// ============================================================ -// API 密钥管理 -// ============================================================ - -import { useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd' -import { PlusOutlined, CopyOutlined } from '@ant-design/icons' -import type { ProColumns } from '@ant-design/pro-components' -import { ProTable } from '@ant-design/pro-components' -import { apiKeyService } from '@/services/api-keys' -import type { TokenInfo } from '@/types' - -const { Text } = Typography - -export default function ApiKeys() { - const queryClient = useQueryClient() - const [form] = Form.useForm() - const [modalOpen, setModalOpen] = useState(false) - const [newToken, setNewToken] = useState(null) - - const { data, isLoading } = useQuery({ - queryKey: ['api-keys'], - queryFn: ({ signal }) => apiKeyService.list(signal), - }) - - const createMutation = useMutation({ - mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) => - apiKeyService.create(data), - onSuccess: (result: TokenInfo) => { - message.success('创建成功') - queryClient.invalidateQueries({ queryKey: ['api-keys'] }) - if (result.token) { - setNewToken(result.token) - } - setModalOpen(false) - form.resetFields() - }, - onError: (err: Error) => message.error(err.message || '创建失败'), - }) - - const revokeMutation = useMutation({ - mutationFn: (id: string) => apiKeyService.revoke(id), - onSuccess: () => { - message.success('已撤销') - queryClient.invalidateQueries({ queryKey: ['api-keys'] }) - }, - onError: (err: Error) => message.error(err.message || '撤销失败'), - }) - - const columns: ProColumns[] = [ - { title: '名称', dataIndex: 'name', width: 160 }, - { title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => {r.token_prefix}... }, - { - title: '权限', - dataIndex: 'permissions', - width: 200, - render: (_, r) => r.permissions?.map((p) => {p}), - }, - { - title: '过期时间', - dataIndex: 'expires_at', - width: 180, - render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期', - }, - { - title: '最后使用', - dataIndex: 'last_used_at', - width: 180, - render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-', - }, - { - title: '创建时间', - dataIndex: 'created_at', - width: 180, - render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'), - }, - { - title: '操作', - width: 100, - render: (_, record) => ( - revokeMutation.mutate(record.id)}> - - - ), - }, - ] - - const handleCreate = async () => { - const values = await form.validateFields() - createMutation.mutate(values) - } - - const copyToken = () => { - if (newToken) { - navigator.clipboard.writeText(newToken) - message.success('已复制到剪贴板') - } - } - - return ( -
- - columns={columns} - dataSource={data?.items ?? []} - loading={isLoading} - rowKey="id" - search={false} - toolBarRender={() => [ - , - ]} - pagination={{ - total: data?.total ?? 0, - pageSize: data?.page_size ?? 20, - current: data?.page ?? 1, - showSizeChanger: false, - }} - /> - - { setModalOpen(false); form.resetFields() }} - confirmLoading={createMutation.isPending} - > - - - - - - - - - + + + + +
+ + + + + + +
+
+ + + + + + + + + +
+
+ + + + + + +
+ +
+
+ ) +} + +// ============================================================ +// 子组件: Key Pool 表格 +// ============================================================ +function ProviderKeysTable({ providerId }: { providerId: string }) { + const queryClient = useQueryClient() + const [addKeyForm] = Form.useForm() + const [addKeyOpen, setAddKeyOpen] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['provider-keys', providerId], + queryFn: ({ signal }) => providerService.listKeys(providerId!, signal), + }) + + const addKeyMutation = useMutation({ + mutationFn: (data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number }) => + providerService.addKey(providerId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] }) + message.success('密钥已添加') + setAddKeyOpen(false) + addKeyForm.resetFields() + }, + onError: () => message.error('添加失败'), + }) + + const toggleKeyMutation = useMutation({ + mutationFn: ({ keyId, active }: { keyId: string; active: boolean }) => + providerService.toggleKey(providerId, keyId, active), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] }) + message.success('状态已切换') + }, + onError: () => message.error('切换失败'), + }) + + const deleteKeyMutation = useMutation({ + mutationFn: (keyId: string) => providerService.deleteKey(providerId, keyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] }) + message.success('密钥已删除') + }, + onError: () => message.error('删除失败'), + }) + + const keyColumns: ProColumns[] = [ + { title: '标签', dataIndex: 'key_label', width: 120 }, + { title: '优先级', dataIndex: 'priority', width: 70 }, + { title: '请求数', dataIndex: 'total_requests', width: 80 }, + { title: 'Token 数', dataIndex: 'total_tokens', width: 90 }, + { + title: '状态', dataIndex: 'is_active', width: 70, + render: (_, r) => r.is_active ? 活跃 : 冷却, + }, + { + title: '操作', width: 120, + render: (_, record) => ( + + toggleKeyMutation.mutate({ keyId: record.id, active: !record.is_active })} + > + + + deleteKeyMutation.mutate(record.id)}> + + + + ), + }, + ] + + const keys = data ?? [] + + return ( +
+
+ +
+ + columns={keyColumns} + dataSource={keys} + loading={isLoading} + rowKey="id" + size="small" + pagination={false} + /> + { + addKeyForm.validateFields().then((v) => addKeyMutation.mutate(v)) + }} + onCancel={() => setAddKeyOpen(false)} + confirmLoading={addKeyMutation.isPending} + > +
+ + + + + + +
+ + + + + + + + + +
+
+
+
+ ) +} + +// ============================================================ +// 主页面: 模型服务 +// ============================================================ +export default function ModelServices() { + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [modalOpen, setModalOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + + const { data, isLoading } = useQuery({ + queryKey: ['providers'], + queryFn: ({ signal }) => providerService.list(signal), + }) + + const createMutation = useMutation({ + mutationFn: (data: Partial>) => + providerService.create(data), + onSuccess: () => { + message.success('服务商已创建') + queryClient.invalidateQueries({ queryKey: ['providers'] }) + setModalOpen(false) + form.resetFields() + }, + onError: (err: Error) => message.error(err.message || '创建失败'), + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Partial> }) => + providerService.update(id, data), + onSuccess: () => { + message.success('服务商已更新') + queryClient.invalidateQueries({ queryKey: ['providers'] }) + setModalOpen(false) + }, + onError: (err: Error) => message.error(err.message || '更新失败'), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => providerService.delete(id), + onSuccess: () => { + message.success('服务商已删除') + queryClient.invalidateQueries({ queryKey: ['providers'] }) + }, + onError: (err: Error) => message.error(err.message || '删除失败'), + }) + + const handleSave = async () => { + const values = await form.validateFields() + if (editingId) { + updateMutation.mutate({ id: editingId, data: values }) + } else { + createMutation.mutate(values) + } + } + + const columns: ProColumns[] = [ + { title: '名称', dataIndex: 'display_name', width: 150 }, + { title: '标识', dataIndex: 'name', width: 120, render: (_, r) => {r.name} }, + { title: 'Base URL', dataIndex: 'base_url', width: 260, ellipsis: true }, + { title: '协议', dataIndex: 'api_protocol', width: 90, hideInSearch: true }, + { title: 'RPM', dataIndex: 'rate_limit_rpm', width: 80, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' }, + { + title: '状态', dataIndex: 'enabled', width: 70, hideInSearch: true, + render: (_, r) => r.enabled ? 启用 : 禁用, + }, + { + title: '操作', width: 140, hideInSearch: true, + render: (_, record) => ( + + + deleteMutation.mutate(record.id)}> + + + + ), + }, + ] + + return ( +
+ + columns={columns} + dataSource={data?.items ?? []} + loading={isLoading} + rowKey="id" + search={{}} + toolBarRender={() => [ + , + ]} + pagination={{ + total: data?.total ?? 0, + pageSize: data?.page_size ?? 20, + current: data?.page ?? 1, + showSizeChanger: false, + }} + expandable={{ + expandedRowRender: (record) => ( + , + }, + { + key: 'keys', + label: 'Key Pool', + children: , + }, + ]} + /> + ), + }} + /> + + { setModalOpen(false); setEditingId(null); form.resetFields() }} + confirmLoading={createMutation.isPending || updateMutation.isPending} + width={560} + > +
+ + + + + + + + + + + + +
+ + + + + + +
+
+
+
+ ) +} diff --git a/admin-v2/src/pages/Models.tsx b/admin-v2/src/pages/Models.tsx deleted file mode 100644 index 2cfe426..0000000 --- a/admin-v2/src/pages/Models.tsx +++ /dev/null @@ -1,191 +0,0 @@ -// ============================================================ -// 模型管理 -// ============================================================ - -import { useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd' -import { PlusOutlined } from '@ant-design/icons' -import type { ProColumns } from '@ant-design/pro-components' -import { ProTable } from '@ant-design/pro-components' -import { modelService } from '@/services/models' -import { providerService } from '@/services/providers' -import type { Model } from '@/types' - -export default function Models() { - const queryClient = useQueryClient() - const [form] = Form.useForm() - const [modalOpen, setModalOpen] = useState(false) - const [editingId, setEditingId] = useState(null) - - const { data, isLoading } = useQuery({ - queryKey: ['models'], - queryFn: ({ signal }) => modelService.list(signal), - }) - - const { data: providersData } = useQuery({ - queryKey: ['providers-for-select'], - queryFn: ({ signal }) => providerService.list(signal), - }) - - const createMutation = useMutation({ - mutationFn: (data: Partial>) => modelService.create(data), - onSuccess: () => { - message.success('创建成功') - queryClient.invalidateQueries({ queryKey: ['models'] }) - setModalOpen(false) - form.resetFields() - }, - onError: (err: Error) => message.error(err.message || '创建失败'), - }) - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial> }) => - modelService.update(id, data), - onSuccess: () => { - message.success('更新成功') - queryClient.invalidateQueries({ queryKey: ['models'] }) - setModalOpen(false) - }, - onError: (err: Error) => message.error(err.message || '更新失败'), - }) - - const deleteMutation = useMutation({ - mutationFn: (id: string) => modelService.delete(id), - onSuccess: () => { - message.success('删除成功') - queryClient.invalidateQueries({ queryKey: ['models'] }) - }, - onError: (err: Error) => message.error(err.message || '删除失败'), - }) - - const columns: ProColumns[] = [ - { title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => {r.model_id} }, - { title: '别名', dataIndex: 'alias', width: 140 }, - { - title: '服务商', - dataIndex: 'provider_id', - width: 140, - hideInSearch: true, - render: (_, r) => { - const provider = providersData?.items?.find((p) => p.id === r.provider_id) - return provider?.display_name || r.provider_id.substring(0, 8) - }, - }, - { title: '上下文窗口', dataIndex: 'context_window', width: 110, hideInSearch: true, render: (_, r) => r.context_window?.toLocaleString() }, - { title: '最大输出', dataIndex: 'max_output_tokens', width: 100, hideInSearch: true, render: (_, r) => r.max_output_tokens?.toLocaleString() }, - { - title: '流式', - dataIndex: 'supports_streaming', - width: 70, - hideInSearch: true, - render: (_, r) => r.supports_streaming ? : , - }, - { - title: '视觉', - dataIndex: 'supports_vision', - width: 70, - hideInSearch: true, - render: (_, r) => r.supports_vision ? : , - }, - { - title: '状态', - dataIndex: 'enabled', - width: 70, - hideInSearch: true, - render: (_, r) => r.enabled ? 启用 : 禁用, - }, - { - title: '操作', - width: 160, - hideInSearch: true, - render: (_, record) => ( - - - deleteMutation.mutate(record.id)}> - - - - ), - }, - ] - - const handleSave = async () => { - const values = await form.validateFields() - if (editingId) { - updateMutation.mutate({ id: editingId, data: values }) - } else { - createMutation.mutate(values) - } - } - - return ( -
- - columns={columns} - dataSource={data?.items ?? []} - loading={isLoading} - rowKey="id" - search={{}} - toolBarRender={() => [ - , - ]} - pagination={{ - total: data?.total ?? 0, - pageSize: data?.page_size ?? 20, - current: data?.page ?? 1, - showSizeChanger: false, - }} - /> - - { setModalOpen(false); setEditingId(null); form.resetFields() }} - confirmLoading={createMutation.isPending || updateMutation.isPending} - width={600} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- ) -} diff --git a/admin-v2/src/pages/Providers.tsx b/admin-v2/src/pages/Providers.tsx deleted file mode 100644 index 0f48da4..0000000 --- a/admin-v2/src/pages/Providers.tsx +++ /dev/null @@ -1,290 +0,0 @@ -// ============================================================ -// 服务商管理 -// ============================================================ - -import { useState } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Typography } from 'antd' -import { PlusOutlined } from '@ant-design/icons' -import type { ProColumns } from '@ant-design/pro-components' -import { ProTable } from '@ant-design/pro-components' -import { providerService } from '@/services/providers' -import type { Provider, ProviderKey } from '@/types' - -const { Text } = Typography - -export default function Providers() { - const queryClient = useQueryClient() - const [form] = Form.useForm() - const [modalOpen, setModalOpen] = useState(false) - const [editingId, setEditingId] = useState(null) - const [keyModalProviderId, setKeyModalProviderId] = useState(null) - const [addKeyOpen, setAddKeyOpen] = useState(false) - const [addKeyForm] = Form.useForm() - - const { data, isLoading } = useQuery({ - queryKey: ['providers'], - queryFn: ({ signal }) => providerService.list(signal), - }) - - const { data: keysData, isLoading: keysLoading } = useQuery({ - queryKey: ['provider-keys', keyModalProviderId], - queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal), - enabled: !!keyModalProviderId, - }) - - const createMutation = useMutation({ - mutationFn: (data: Partial>) => - providerService.create(data), - onSuccess: () => { - message.success('创建成功') - queryClient.invalidateQueries({ queryKey: ['providers'] }) - setModalOpen(false) - form.resetFields() - }, - onError: (err: Error) => message.error(err.message || '创建失败'), - }) - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial> }) => - providerService.update(id, data), - onSuccess: () => { - message.success('更新成功') - queryClient.invalidateQueries({ queryKey: ['providers'] }) - setModalOpen(false) - }, - onError: (err: Error) => message.error(err.message || '更新失败'), - }) - - const deleteMutation = useMutation({ - mutationFn: (id: string) => providerService.delete(id), - onSuccess: () => { - message.success('删除成功') - queryClient.invalidateQueries({ queryKey: ['providers'] }) - }, - onError: (err: Error) => message.error(err.message || '删除失败'), - }) - - const addKeyMutation = useMutation({ - mutationFn: ({ providerId, data }: { providerId: string; data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number } }) => - providerService.addKey(providerId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] }) - message.success('密钥已添加') - setAddKeyOpen(false) - addKeyForm.resetFields() - }, - onError: () => message.error('添加失败'), - }) - - const toggleKeyMutation = useMutation({ - mutationFn: ({ providerId, keyId, active }: { providerId: string; keyId: string; active: boolean }) => - providerService.toggleKey(providerId, keyId, active), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] }) - message.success('状态已切换') - }, - onError: () => message.error('切换失败'), - }) - - const deleteKeyMutation = useMutation({ - mutationFn: ({ providerId, keyId }: { providerId: string; keyId: string }) => - providerService.deleteKey(providerId, keyId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] }) - message.success('密钥已删除') - }, - onError: () => message.error('删除失败'), - }) - - const columns: ProColumns[] = [ - { title: '名称', dataIndex: 'display_name', width: 140 }, - { title: '标识', dataIndex: 'name', width: 120, render: (_, r) => {r.name} }, - { title: '协议', dataIndex: 'api_protocol', width: 100, hideInSearch: true }, - { title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' }, - { - title: '状态', - dataIndex: 'enabled', - width: 80, - hideInSearch: true, - render: (_, r) => r.enabled ? 启用 : 禁用, - }, - { - title: '操作', - width: 260, - hideInSearch: true, - render: (_, record) => ( - - - - deleteMutation.mutate(record.id)}> - - - - ), - }, - ] - - const keyColumns: ProColumns[] = [ - { title: '标签', dataIndex: 'key_label', width: 120 }, - { title: '优先级', dataIndex: 'priority', width: 80 }, - { title: '请求数', dataIndex: 'total_requests', width: 80 }, - { title: 'Token 数', dataIndex: 'total_tokens', width: 100 }, - { - title: '状态', - dataIndex: 'is_active', - width: 80, - render: (_, r) => r.is_active ? 活跃 : 冷却, - }, - { - title: '操作', - width: 160, - render: (_, record) => ( - - toggleKeyMutation.mutate({ - providerId: keyModalProviderId!, - keyId: record.id, - active: !record.is_active, - })} - > - - - deleteKeyMutation.mutate({ - providerId: keyModalProviderId!, - keyId: record.id, - })} - > - - - - ), - }, - ] - - const handleSave = async () => { - const values = await form.validateFields() - if (editingId) { - updateMutation.mutate({ id: editingId, data: values }) - } else { - createMutation.mutate(values) - } - } - - return ( -
- - columns={columns} - dataSource={data?.items ?? []} - loading={isLoading} - rowKey="id" - search={{}} - toolBarRender={() => [ - , - ]} - pagination={{ - total: data?.total ?? 0, - pageSize: data?.page_size ?? 20, - current: data?.page ?? 1, - showSizeChanger: false, - }} - /> - - { setModalOpen(false); setEditingId(null); form.resetFields() }} - confirmLoading={createMutation.isPending || updateMutation.isPending} - > -
- - - - - - - - - - - - - - - - - - -
-
- - setKeyModalProviderId(null)} - footer={(_, { OkBtn, CancelBtn }) => ( - - - - - )} - width={700} - > - - columns={keyColumns} - dataSource={keysData ?? []} - loading={keysLoading} - rowKey="id" - search={false} - toolBarRender={false} - pagination={false} - size="small" - /> - - - { - addKeyForm.validateFields().then((v) => - addKeyMutation.mutate({ providerId: keyModalProviderId!, data: v }) - ) - }} - onCancel={() => setAddKeyOpen(false)} - confirmLoading={addKeyMutation.isPending} - > -
- - - - - - - - - - - - - - - -
-
-
- ) -} diff --git a/admin-v2/src/pages/Relay.tsx b/admin-v2/src/pages/Relay.tsx index 0bf066b..9c7511e 100644 --- a/admin-v2/src/pages/Relay.tsx +++ b/admin-v2/src/pages/Relay.tsx @@ -2,16 +2,16 @@ // 中转任务 // ============================================================ +import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Tag, Select, Typography } from 'antd' +import { Tag, Select } from 'antd' import type { ProColumns } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components' import { relayService } from '@/services/relay' -import { useState } from 'react' +import { PageHeader } from '@/components/PageHeader' +import { ErrorState } from '@/components/ErrorState' import type { RelayTask } from '@/types' -const { Title } = Typography - const statusLabels: Record = { queued: '排队中', running: '运行中', @@ -32,26 +32,57 @@ export default function Relay() { const [statusFilter, setStatusFilter] = useState(undefined) const [page, setPage] = useState(1) - const { data, isLoading } = useQuery({ + const { + data, + isLoading, + error, + refetch, + } = useQuery({ queryKey: ['relay-tasks', page, statusFilter], queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal), }) + if (error) { + return ( + <> + + refetch()} /> + + ) + } + const columns: ProColumns[] = [ - { title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => {r.id.substring(0, 8)}... }, + { + title: 'ID', + dataIndex: 'id', + width: 120, + render: (_, r) => ( + + {r.id.substring(0, 8)}... + + ), + }, { title: '状态', dataIndex: 'status', width: 100, - render: (_, r) => {statusLabels[r.status] || r.status}, + render: (_, r) => ( + + {statusLabels[r.status] || r.status} + + ), }, { title: '模型', dataIndex: 'model_id', width: 160 }, { title: '优先级', dataIndex: 'priority', width: 70 }, { title: '尝试次数', dataIndex: 'attempt_count', width: 80 }, { - title: 'Token', + title: 'Token (入/出)', width: 140, - render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`, + render: (_, r) => ( + + {r.input_tokens.toLocaleString()} / {r.output_tokens.toLocaleString()} + + ), }, { title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true }, { @@ -64,30 +95,36 @@ export default function Relay() { title: '完成时间', dataIndex: 'completed_at', width: 180, - render: (_, r) => r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-', + render: (_, r) => (r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-'), }, ] return (
-
- 中转任务 - -
+ + } + /> - - - - } /> + {/* Summary Cards */} + + + + + 总请求数 + + } + value={totalRequests} + prefix={} + valueStyle={{ fontWeight: 600, color: '#863bff' }} + /> - - - } /> + + + + 总 Token 数 + + } + value={totalTokens} + prefix={} + valueStyle={{ fontWeight: 600, color: '#47bfff' }} + /> - + {/* Daily Stats */} + + 每日统计 + + } + className="mb-6" + size="small" + styles={{ body: { padding: 0 } }} + > columns={dailyColumns} dataSource={dailyData ?? []} @@ -103,7 +174,16 @@ export default function Usage() { /> - + {/* Model Stats */} + + 按模型统计 + + } + size="small" + styles={{ body: { padding: 0 } }} + > columns={modelColumns} dataSource={modelData ?? []} diff --git a/admin-v2/src/router/index.tsx b/admin-v2/src/router/index.tsx index ecf1b0a..b7c65d3 100644 --- a/admin-v2/src/router/index.tsx +++ b/admin-v2/src/router/index.tsx @@ -21,10 +21,11 @@ export const router = createBrowserRouter([ children: [ { index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) }, { path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) }, - { path: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) }, - { path: 'models', lazy: () => import('@/pages/Models').then((m) => ({ Component: m.default })) }, + { path: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) }, + { path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) }, + { path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) }, { path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) }, - { path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) }, + { path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) }, { path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) }, { path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) }, { path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) }, diff --git a/admin-v2/src/stores/themeStore.ts b/admin-v2/src/stores/themeStore.ts new file mode 100644 index 0000000..5a435b0 --- /dev/null +++ b/admin-v2/src/stores/themeStore.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand' + +type ThemeMode = 'light' | 'dark' | 'system' + +interface ThemeState { + mode: ThemeMode + resolved: 'light' | 'dark' +} + +function getSystemTheme(): 'light' | 'dark' { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +function resolveTheme(mode: ThemeMode): 'light' | 'dark' { + return mode === 'system' ? getSystemTheme() : mode +} + +function applyTheme(resolved: 'light' | 'dark') { + const html = document.documentElement + html.classList.toggle('dark', resolved === 'dark') + html.setAttribute('data-theme', resolved) +} + +function getInitialMode(): ThemeMode { + const stored = localStorage.getItem('zclaw_admin_theme') + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored + return 'system' +} + +const initialMode = getInitialMode() +const initialResolved = resolveTheme(initialMode) +applyTheme(initialResolved) + +export const useThemeStore = create(() => ({ + mode: initialMode, + resolved: initialResolved, +})) + +export function setThemeMode(mode: ThemeMode) { + const resolved = resolveTheme(mode) + localStorage.setItem('zclaw_admin_theme', mode) + applyTheme(resolved) + useThemeStore.setState({ mode, resolved }) +} + +// Listen for system theme changes +if (typeof window !== 'undefined') { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const { mode } = useThemeStore.getState() + if (mode === 'system') { + const resolved = getSystemTheme() + applyTheme(resolved) + useThemeStore.setState({ resolved }) + } + }) +} diff --git a/admin-v2/src/styles/globals.css b/admin-v2/src/styles/globals.css new file mode 100644 index 0000000..7e637a4 --- /dev/null +++ b/admin-v2/src/styles/globals.css @@ -0,0 +1,235 @@ +@import "tailwindcss"; + +/* ============================================================ + ZCLAW Admin Design Tokens + DeerFlow-inspired warm neutral palette with brand accents + ============================================================ */ + +@theme { + /* Brand Colors */ + --color-brand-purple: #863bff; + --color-brand-blue: #47bfff; + --color-brand-gradient: linear-gradient(135deg, #863bff, #47bfff); + + /* Neutral (warm stone palette) */ + --color-neutral-50: #fafaf9; + --color-neutral-100: #f5f5f4; + --color-neutral-200: #e7e5e4; + --color-neutral-300: #d6d3d1; + --color-neutral-400: #a8a29e; + --color-neutral-500: #78716c; + --color-neutral-600: #57534e; + --color-neutral-700: #44403c; + --color-neutral-800: #292524; + --color-neutral-900: #1c1917; + --color-neutral-950: #0c0a09; + + /* Semantic Colors */ + --color-success: #22c55e; + --color-success-soft: #dcfce7; + --color-warning: #f59e0b; + --color-warning-soft: #fef3c7; + --color-error: #ef4444; + --color-error-soft: #fee2e2; + --color-info: #3b82f6; + --color-info-soft: #dbeafe; + + /* Dark mode neutrals */ + --color-dark-bg: #0c0a09; + --color-dark-surface: #1c1917; + --color-dark-card: #292524; + --color-dark-border: #44403c; + --color-dark-text: #fafaf9; + --color-dark-text-secondary: #a8a29e; + + /* Spacing */ + --spacing-sidebar-expanded: 16rem; + --spacing-sidebar-collapsed: 3rem; + --spacing-header-height: 3.5rem; + + /* Border Radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* Shadows */ + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-dropdown: 0 4px 16px rgba(0, 0, 0, 0.12); + --shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.16); + + /* Typography */ + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ============================================================ + Base Styles + ============================================================ */ + +html { + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + background-color: var(--color-neutral-50); + color: var(--color-neutral-900); + transition: background-color var(--transition-normal), color var(--transition-normal); +} + +/* Dark mode overrides */ +html.dark body { + background-color: var(--color-dark-bg); + color: var(--color-dark-text); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--color-neutral-300); + border-radius: 3px; +} + +html.dark ::-webkit-scrollbar-thumb { + background-color: var(--color-dark-border); +} + +/* Focus visible */ +:focus-visible { + outline: 2px solid var(--color-brand-purple); + outline-offset: 2px; + border-radius: 4px; +} + +/* Skip to content (accessibility) */ +.skip-to-content { + position: absolute; + top: -100%; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + padding: 8px 16px; + background: var(--color-brand-purple); + color: white; + border-radius: var(--radius-md); + font-size: 14px; + text-decoration: none; + transition: top var(--transition-fast); +} + +.skip-to-content:focus { + top: 8px; +} + +/* ============================================================ + Ant Design Overrides (Light Mode) + ============================================================ */ + +/* ProTable search area */ +.ant-pro-table-search { + background-color: var(--color-neutral-50) !important; + border-bottom: 1px solid var(--color-neutral-200) !important; +} + +/* Card styling */ +.ant-card { + border-radius: var(--radius-lg) !important; + border: 1px solid var(--color-neutral-200) !important; + box-shadow: var(--shadow-card) !important; +} + +.ant-card:hover { + box-shadow: var(--shadow-card-hover) !important; +} + +/* Table styling */ +.ant-table-wrapper .ant-table-thead > tr > th { + background-color: var(--color-neutral-50) !important; + font-weight: 600 !important; + color: var(--color-neutral-600) !important; +} + +/* Modal styling */ +.ant-modal .ant-modal-content { + border-radius: var(--radius-lg) !important; +} + +/* Tag pill style */ +.ant-tag { + border-radius: 9999px !important; + padding: 0 8px !important; +} + +/* Form item */ +.ant-form-item-label > label { + font-weight: 500 !important; + color: var(--color-neutral-700) !important; +} + +/* ============================================================ + Dark Mode — Ant Design Overrides + ============================================================ */ + +html.dark .ant-card { + background-color: var(--color-dark-card) !important; + border-color: var(--color-dark-border) !important; +} + +html.dark .ant-table-wrapper .ant-table-thead > tr > th { + background-color: var(--color-dark-surface) !important; + color: var(--color-dark-text-secondary) !important; +} + +html.dark .ant-table-wrapper .ant-table-tbody > tr > td { + border-color: var(--color-dark-border) !important; +} + +html.dark .ant-table-wrapper .ant-table-tbody > tr:hover > td { + background-color: rgba(134, 59, 255, 0.06) !important; +} + +html.dark .ant-modal .ant-modal-content { + background-color: var(--color-dark-card) !important; +} + +html.dark .ant-modal .ant-modal-header { + background-color: var(--color-dark-card) !important; +} + +html.dark .ant-drawer .ant-drawer-content { + background-color: var(--color-dark-surface) !important; +} + +html.dark .ant-form-item-label > label { + color: var(--color-dark-text-secondary) !important; +} + +html.dark .ant-select-selector, +html.dark .ant-input, +html.dark .ant-input-number { + background-color: var(--color-dark-card) !important; + border-color: var(--color-dark-border) !important; + color: var(--color-dark-text) !important; +} + +html.dark .ant-pro-table-search { + background-color: var(--color-dark-surface) !important; + border-color: var(--color-dark-border) !important; +} diff --git a/admin-v2/vite.config.ts b/admin-v2/vite.config.ts index 5dc8934..bb3474e 100644 --- a/admin-v2/vite.config.ts +++ b/admin-v2/vite.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' import path from 'path' export default defineConfig({ - plugins: [react()], + plugins: [tailwindcss(), react()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), diff --git a/crates/zclaw-kernel/src/kernel.rs b/crates/zclaw-kernel/src/kernel.rs index 763fd2a..189b5a4 100644 --- a/crates/zclaw-kernel/src/kernel.rs +++ b/crates/zclaw-kernel/src/kernel.rs @@ -645,15 +645,11 @@ impl Kernel { // Reuse existing session or create new one let session_id = match session_id_override { Some(id) => { - // Verify the session exists; if not, create a new one - let existing = self.memory.get_messages(&id).await; - match existing { - Ok(msgs) if !msgs.is_empty() => id, - _ => { - tracing::debug!("Session {} not found or empty, creating new session", id); - self.memory.create_session(agent_id).await? - } - } + // Use get_or_create to ensure the frontend's session ID is persisted. + // This is the critical bridge: without it, the kernel generates a + // different UUID each turn, so conversation history is never found. + tracing::debug!("Reusing frontend session ID: {}", id); + self.memory.get_or_create_session(&id, agent_id).await? } None => self.memory.create_session(agent_id).await?, }; diff --git a/crates/zclaw-memory/src/store.rs b/crates/zclaw-memory/src/store.rs index 632e981..d506670 100644 --- a/crates/zclaw-memory/src/store.rs +++ b/crates/zclaw-memory/src/store.rs @@ -173,6 +173,49 @@ impl MemoryStore { Ok(session_id) } + /// Get an existing session or create it with the given ID. + /// + /// This is the critical bridge between frontend session IDs and the database. + /// The frontend generates a UUID (`sessionKey`) and sends it with each message. + /// Without this method, the kernel would create a *different* session ID on + /// every call, so conversation history would never be found. + pub async fn get_or_create_session( + &self, + session_id: &SessionId, + agent_id: &AgentId, + ) -> Result { + let session_str = session_id.to_string(); + let agent_str = agent_id.to_string(); + + // Check if session already exists + let exists: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM sessions WHERE id = ?", + ) + .bind(&session_str) + .fetch_one(&self.pool) + .await + .unwrap_or(false); + + if exists { + return Ok(session_id.clone()); + } + + // Create session with the frontend-provided ID + sqlx::query( + r#" + INSERT INTO sessions (id, agent_id, created_at, updated_at) + VALUES (?, ?, datetime('now'), datetime('now')) + "#, + ) + .bind(&session_str) + .bind(&agent_str) + .execute(&self.pool) + .await + .map_err(|e| ZclawError::StorageError(e.to_string()))?; + + Ok(session_id.clone()) + } + /// Append a message to a session pub async fn append_message(&self, session_id: &SessionId, message: &Message) -> Result<()> { let session_str = session_id.to_string(); diff --git a/crates/zclaw-runtime/src/driver/openai.rs b/crates/zclaw-runtime/src/driver/openai.rs index e3a4f4f..7aa6330 100644 --- a/crates/zclaw-runtime/src/driver/openai.rs +++ b/crates/zclaw-runtime/src/driver/openai.rs @@ -360,10 +360,14 @@ impl OpenAiDriver { if let Some(calls) = calls { if !calls.is_empty() { - // Merge assistant content + reasoning into the tool call message + // Merge assistant content + reasoning into the tool call message. + // IMPORTANT: Some APIs (Kimi, Qwen) require `content` to be non-empty + // even when tool_calls is set. Use a meaningful placeholder if content is empty. + let content_value = content.filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "正在调用工具...".to_string()); out.push(OpenAiMessage { role: "assistant".to_string(), - content: content.filter(|s| !s.is_empty()), + content: Some(content_value), reasoning_content: reasoning.filter(|s| !s.is_empty()), tool_calls: Some(calls), tool_call_id: None, @@ -371,11 +375,14 @@ impl OpenAiDriver { return; } } - // No tool calls — emit a plain assistant message + // No tool calls — emit a plain assistant message. + // Ensure content is always Some() and non-empty to satisfy API requirements. if content.is_some() || reasoning.is_some() { + let content_value = content.filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "正在思考...".to_string()); out.push(OpenAiMessage { role: "assistant".to_string(), - content: content.filter(|s| !s.is_empty()), + content: Some(content_value), reasoning_content: reasoning.filter(|s| !s.is_empty()), tool_calls: None, tool_call_id: None, diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index c806b37..dc4d6a2 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -88,6 +88,20 @@ function App() { document.title = 'ZCLAW'; }, []); + // Restore SaaS session from OS keyring on startup (before auth gate) + const [isRestoring, setIsRestoring] = useState(true); + useEffect(() => { + const restore = async () => { + try { + await useSaaSStore.getState().restoreSession(); + } catch { + // Session restore failed — user will see login page + } + setIsRestoring(false); + }; + restore(); + }, []); + // Watch for Hands that need approval useEffect(() => { const handsNeedingApproval = hands.filter(h => h.status === 'needs_approval'); @@ -345,6 +359,9 @@ function App() { }; // 登录门禁 — 必须登录才能使用 + if (isRestoring) { + return ; + } if (!isLoggedIn) { return ; } diff --git a/desktop/src/components/CloneManager.tsx b/desktop/src/components/CloneManager.tsx index c2c68c7..b3db54d 100644 --- a/desktop/src/components/CloneManager.tsx +++ b/desktop/src/components/CloneManager.tsx @@ -20,7 +20,11 @@ export function CloneManager() { useEffect(() => { if (connected) { - loadClones(); + loadClones().then(() => { + // Sync agents in chatStore and restore currentAgent/conversation + const clones = useAgentStore.getState().clones; + useChatStore.getState().syncAgents(clones); + }); } }, [connected, loadClones]); diff --git a/docs/knowledge-base/troubleshooting.md b/docs/knowledge-base/troubleshooting.md index 7dd727d..66660e1 100644 --- a/docs/knowledge-base/troubleshooting.md +++ b/docs/knowledge-base/troubleshooting.md @@ -2084,7 +2084,58 @@ const adminRouting = storedAccount?.llm_routing; --- -## 16. 相关文档 +## 16. 桌面端会话持久化三连 Bug (2026-04-01) + +### 16.1 页面刷新跳回登录页 + +**症状**: F5 刷新页面后被踢回登录界面,必须重新输入密码。 + +**根本原因**: `saasStore.restoreSession()` 方法已实现(从 OS keyring 读取 token + 验证),但从未被调用。Zustand store 初始化 `isLoggedIn: false`,`App.tsx` 同步检查后立即渲染 ``。 + +**修复**: `desktop/src/App.tsx` — 添加 `isRestoring` 状态,启动时调用 `restoreSession()`,恢复完成前显示加载屏。 + +### 16.2 登录后空白对话页 + +**症状**: 登录后看到空白对话页面,必须手动点击 agent 才能看到之前的对话。 + +**根本原因**: `CloneManager` 在 `loadClones()` 后没有调用 `chatStore.syncAgents()`。`syncAgents` 包含恢复 `currentAgent` 和从 conversations 数组恢复 messages 的安全网逻辑。 + +**修复**: `desktop/src/components/CloneManager.tsx` — `loadClones().then()` 中调用 `syncAgents(clones)`。 + +### 16.3 Agent 无上下文记忆 — session ID 映射断裂 + +**症状**: 每条消息 Agent 都说"这是第一条消息",多轮对话上下文完全丢失。 + +**根本原因**: 前端生成的 `sessionKey` UUID 传到 kernel 后,`get_messages()` 查不到(因为该 ID 从未存入 sessions 表),于是 `create_session()` 生成全新的 UUID。下一轮前端再传原 UUID,又创建新 session。前端 session ID 和数据库 session ID 永远无法匹配。 + +``` +Turn 1: 前端 "abc-123" → DB 查不到 → 创建 "xyz-789" → 消息存到 "xyz-789" +Turn 2: 前端 "abc-123" → DB 查不到 → 创建 "def-456" → 消息存到 "def-456" +结果: 永远找不到历史消息 +``` + +**修复**: +1. `crates/zclaw-memory/src/store.rs` — 添加 `get_or_create_session()` 方法,直接用前端提供的 ID 创建 session +2. `crates/zclaw-kernel/src/kernel.rs` — 使用 `get_or_create_session` 替代 lookup-then-create + +### 16.4 `messages[N] must not be empty` — assistant 消息空 content + +**症状**: Bug 16.3 修复后暴露:`API error 400: messages[4] with role 'assistant' must not be empty` + +**根本原因**: Bug 16.3 修复后对话历史终于被正确发送给 LLM。但历史中的 `ToolUse` 消息对应的 assistant 消息 content 为空(只有 tool_calls 没有 text)。Kimi/Qwen API 要求 assistant 消息的 `content` 不能为空字符串。 + +**修复**: `crates/zclaw-runtime/src/driver/openai.rs` — `flush_pending` 中 assistant 消息的 `content` 字段:空字符串 → 有意义的占位符(`"正在思考..."` / `"正在调用工具..."`)。 + +**涉及文件**: +- `desktop/src/App.tsx` +- `desktop/src/components/CloneManager.tsx` +- `crates/zclaw-memory/src/store.rs` +- `crates/zclaw-kernel/src/kernel.rs` +- `crates/zclaw-runtime/src/driver/openai.rs` + +--- + +## 17. 相关文档 - [ZCLAW 配置指南](./zclaw-configuration.md) - 配置文件位置、格式和最佳实践 - [Agent 和 LLM 提供商配置](./agent-provider-config.md) - Agent 管理和 Provider 配置 @@ -2096,6 +2147,7 @@ const adminRouting = storedAccount?.llm_routing; | 日期 | 变更 | |------|------| +| 2026-04-01 | 添加第 16 节:桌面端会话持久化四连 Bug — 页面刷新跳登录 + 空白对话页 + Agent 无记忆(session ID 映射断裂) + assistant 空 content 400 | | 2026-03-31 | 添加第 14/15 节:llm_routing 读取路径 Bug + SaaS Relay 403 User-Agent 缺失 — relay 模式从未生效的根因分析 | | 2026-03-30 | 添加第 13 节:Admin 前端 ERR_ABORTED / 后端卡死 — Next.js SSR/hydration + SWR 根本冲突导致连接池耗尽,admin-v2 (Ant Design Pro 纯 SPA) 替代方案 | | 2026-03-28 | 添加 12.1-12.4 节:SaaS 后端问题 — Admin 登录无请求、SQLite→PostgreSQL 遗留语法、角色权限不足、usage 路由不匹配 |