fix(desktop): session persistence — refresh/login/context/empty-content 4-bug fix
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
1. App.tsx: add restoreSession() call on startup to prevent redirect to login page after refresh (isRestoring guard + BootstrapScreen) 2. CloneManager: call syncAgents() after loadClones() to restore currentAgent and conversation history on app load 3. zclaw-memory: add get_or_create_session() so frontend session UUID is persisted directly — kernel no longer creates mismatched IDs 4. openai.rs: assistant message content must be non-empty for Kimi/Qwen APIs — replace empty content with meaningful placeholders Also includes admin-v2 ModelServices unified page (merge providers + models + API keys into expandable row layout)
This commit is contained in:
@@ -1,12 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin-v2</title>
|
<title>ZCLAW Admin</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<a href="#main-content" class="skip-to-content">跳转到主要内容</a>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"msw": "^2.12.14",
|
"msw": "^2.12.14",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.57.0",
|
"typescript-eslint": "^8.57.0",
|
||||||
"vite": "^8.0.1",
|
"vite": "^8.0.1",
|
||||||
|
|||||||
284
admin-v2/pnpm-lock.yaml
generated
284
admin-v2/pnpm-lock.yaml
generated
@@ -42,6 +42,9 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.4
|
specifier: ^9.39.4
|
||||||
version: 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':
|
'@testing-library/jest-dom':
|
||||||
specifier: ^6.9.1
|
specifier: ^6.9.1
|
||||||
version: 6.9.1
|
version: 6.9.1
|
||||||
@@ -62,16 +65,16 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
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:
|
eslint:
|
||||||
specifier: ^9.39.4
|
specifier: ^9.39.4
|
||||||
version: 9.39.4
|
version: 9.39.4(jiti@2.6.1)
|
||||||
eslint-plugin-react-hooks:
|
eslint-plugin-react-hooks:
|
||||||
specifier: ^7.0.1
|
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:
|
eslint-plugin-react-refresh:
|
||||||
specifier: ^0.5.2
|
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:
|
globals:
|
||||||
specifier: ^17.4.0
|
specifier: ^17.4.0
|
||||||
version: 17.4.0
|
version: 17.4.0
|
||||||
@@ -81,18 +84,21 @@ importers:
|
|||||||
msw:
|
msw:
|
||||||
specifier: ^2.12.14
|
specifier: ^2.12.14
|
||||||
version: 2.12.14(@types/node@24.12.0)(typescript@5.9.3)
|
version: 2.12.14(@types/node@24.12.0)(typescript@5.9.3)
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.2.2
|
||||||
|
version: 4.2.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.9.3
|
specifier: ~5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.57.0
|
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:
|
vite:
|
||||||
specifier: ^8.0.1
|
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:
|
vitest:
|
||||||
specifier: ^4.1.2
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -930,6 +936,100 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
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':
|
'@tanstack/query-core@5.95.2':
|
||||||
resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==}
|
resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==}
|
||||||
|
|
||||||
@@ -1317,6 +1417,10 @@ packages:
|
|||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
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:
|
entities@6.0.1:
|
||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -1498,6 +1602,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
graphql@16.13.2:
|
||||||
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
|
resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==}
|
||||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||||
@@ -1575,6 +1682,10 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
jiti@2.6.1:
|
||||||
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -2060,6 +2171,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||||
engines: {node: '>=20'}
|
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:
|
terser@5.46.1:
|
||||||
resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
|
resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2807,9 +2925,9 @@ snapshots:
|
|||||||
|
|
||||||
'@emotion/unitless@0.7.5': {}
|
'@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:
|
dependencies:
|
||||||
eslint: 9.39.4
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.2': {}
|
'@eslint-community/regexpp@4.12.2': {}
|
||||||
@@ -3348,6 +3466,74 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@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/query-core@5.95.2': {}
|
||||||
|
|
||||||
'@tanstack/react-query@5.95.2(react@19.2.4)':
|
'@tanstack/react-query@5.95.2(react@19.2.4)':
|
||||||
@@ -3421,15 +3607,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@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:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@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/scope-manager': 8.57.2
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@typescript-eslint/visitor-keys': 8.57.2
|
'@typescript-eslint/visitor-keys': 8.57.2
|
||||||
eslint: 9.39.4
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
ignore: 7.0.5
|
ignore: 7.0.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||||
@@ -3437,14 +3623,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@typescript-eslint/scope-manager': 8.57.2
|
'@typescript-eslint/scope-manager': 8.57.2
|
||||||
'@typescript-eslint/types': 8.57.2
|
'@typescript-eslint/types': 8.57.2
|
||||||
'@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.57.2
|
'@typescript-eslint/visitor-keys': 8.57.2
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
eslint: 9.39.4
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -3467,13 +3653,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
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:
|
dependencies:
|
||||||
'@typescript-eslint/types': 8.57.2
|
'@typescript-eslint/types': 8.57.2
|
||||||
'@typescript-eslint/typescript-estree': 8.57.2(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)
|
'@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||||
debug: 4.4.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)
|
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -3496,13 +3682,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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/scope-manager': 8.57.2
|
||||||
'@typescript-eslint/types': 8.57.2
|
'@typescript-eslint/types': 8.57.2
|
||||||
'@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3)
|
'@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
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -3518,10 +3704,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
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:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@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':
|
'@vitest/expect@4.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3532,14 +3718,14 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
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:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.2
|
'@vitest/spy': 4.1.2
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
msw: 2.12.14(@types/node@24.12.0)(typescript@5.9.3)
|
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':
|
'@vitest/pretty-format@4.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3798,6 +3984,11 @@ snapshots:
|
|||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
|
enhanced-resolve@5.20.1:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
tapable: 2.3.2
|
||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
es-define-property@1.0.1: {}
|
es-define-property@1.0.1: {}
|
||||||
@@ -3821,20 +4012,20 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
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:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/parser': 7.29.2
|
'@babel/parser': 7.29.2
|
||||||
eslint: 9.39.4
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
hermes-parser: 0.25.1
|
hermes-parser: 0.25.1
|
||||||
zod: 4.3.6
|
zod: 4.3.6
|
||||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
eslint: 9.39.4
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
|
|
||||||
eslint-scope@8.4.0:
|
eslint-scope@8.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3847,9 +4038,9 @@ snapshots:
|
|||||||
|
|
||||||
eslint-visitor-keys@5.0.1: {}
|
eslint-visitor-keys@5.0.1: {}
|
||||||
|
|
||||||
eslint@9.39.4:
|
eslint@9.39.4(jiti@2.6.1):
|
||||||
dependencies:
|
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-community/regexpp': 4.12.2
|
||||||
'@eslint/config-array': 0.21.2
|
'@eslint/config-array': 0.21.2
|
||||||
'@eslint/config-helpers': 0.4.2
|
'@eslint/config-helpers': 0.4.2
|
||||||
@@ -3883,6 +4074,8 @@ snapshots:
|
|||||||
minimatch: 3.1.5
|
minimatch: 3.1.5
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
optionator: 0.9.4
|
optionator: 0.9.4
|
||||||
|
optionalDependencies:
|
||||||
|
jiti: 2.6.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -3983,6 +4176,8 @@ snapshots:
|
|||||||
|
|
||||||
gopd@1.2.0: {}
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
graphql@16.13.2: {}
|
graphql@16.13.2: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
@@ -4040,6 +4235,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -4491,6 +4688,10 @@ snapshots:
|
|||||||
|
|
||||||
tagged-tag@1.0.0: {}
|
tagged-tag@1.0.0: {}
|
||||||
|
|
||||||
|
tailwindcss@4.2.2: {}
|
||||||
|
|
||||||
|
tapable@2.3.2: {}
|
||||||
|
|
||||||
terser@5.46.1:
|
terser@5.46.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.11
|
'@jridgewell/source-map': 0.3.11
|
||||||
@@ -4542,13 +4743,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tagged-tag: 1.0.0
|
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:
|
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/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)(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/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)
|
||||||
eslint: 9.39.4
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -4575,7 +4776,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
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:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -4585,15 +4786,16 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.12.0
|
'@types/node': 24.12.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
jiti: 2.6.1
|
||||||
terser: 5.46.1
|
terser: 5.46.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@emnapi/core'
|
- '@emnapi/core'
|
||||||
- '@emnapi/runtime'
|
- '@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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.2
|
'@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/pretty-format': 4.1.2
|
||||||
'@vitest/runner': 4.1.2
|
'@vitest/runner': 4.1.2
|
||||||
'@vitest/snapshot': 4.1.2
|
'@vitest/snapshot': 4.1.2
|
||||||
@@ -4610,7 +4812,7 @@ snapshots:
|
|||||||
tinyexec: 1.0.4
|
tinyexec: 1.0.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinyrainbow: 3.1.0
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.12.0
|
'@types/node': 24.12.0
|
||||||
|
|||||||
51
admin-v2/src/components/ErrorState.tsx
Normal file
51
admin-v2/src/components/ErrorState.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] p-8">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={title}
|
||||||
|
subTitle={message}
|
||||||
|
extra={
|
||||||
|
onRetry ? (
|
||||||
|
<Button type="primary" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] p-8">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={error.message}
|
||||||
|
extra={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={resetErrorBoundary}>重试</Button>
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
admin-v2/src/components/PageHeader.tsx
Normal file
25
admin-v2/src/components/PageHeader.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
admin-v2/src/components/StatusTag.tsx
Normal file
15
admin-v2/src/components/StatusTag.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Tag } from 'antd'
|
||||||
|
|
||||||
|
interface StatusTagProps {
|
||||||
|
status: string
|
||||||
|
labels: Record<string, string>
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusTag({ status, labels, colors }: StatusTagProps) {
|
||||||
|
return (
|
||||||
|
<Tag color={colors[status] || 'default'}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
admin-v2/src/constants/status.ts
Normal file
45
admin-v2/src/constants/status.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 操作日志状态映射 — Dashboard 与 Logs 共用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const actionLabels: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
// ============================================================
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
// AdminLayout — ProLayout 管理后台布局
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import ProLayout from '@ant-design/pro-layout'
|
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ApiOutlined,
|
|
||||||
KeyOutlined,
|
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -17,87 +11,375 @@ import {
|
|||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
MoonOutlined,
|
||||||
|
ApiOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { Avatar, Dropdown, message } from 'antd'
|
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
|
||||||
import type { MenuDataItem } from '@ant-design/pro-layout'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
const menuConfig: MenuDataItem[] = [
|
// ============================================================
|
||||||
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
|
// Navigation Configuration
|
||||||
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
|
// ============================================================
|
||||||
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
|
|
||||||
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
|
interface NavItem {
|
||||||
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
|
path: string
|
||||||
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
|
name: string
|
||||||
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
|
icon: ReactNode
|
||||||
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
|
permission?: string
|
||||||
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
|
group: string
|
||||||
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
|
}
|
||||||
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
||||||
|
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', group: '资源管理' },
|
||||||
|
{ path: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
|
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
||||||
|
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
|
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
||||||
|
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
|
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||||
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function filterMenuByPermission(
|
// ============================================================
|
||||||
items: MenuDataItem[],
|
// Sidebar Component
|
||||||
hasPermission: (p: string) => boolean,
|
// ============================================================
|
||||||
): MenuDataItem[] {
|
|
||||||
return items
|
function Sidebar({
|
||||||
.filter((item) => !item.permission || hasPermission(item.permission as string))
|
collapsed,
|
||||||
.map(({ permission, ...rest }) => ({
|
onNavigate,
|
||||||
...rest,
|
activePath,
|
||||||
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
|
}: {
|
||||||
}))
|
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<string, NavItem[]>()
|
||||||
|
for (const item of visibleItems) {
|
||||||
|
const list = map.get(item.group) || []
|
||||||
|
list.push(item)
|
||||||
|
map.set(item.group, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [visibleItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex flex-col h-full" aria-label="主导航">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center h-14 px-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white font-bold text-sm">Z</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="ml-3 text-lg font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">
|
||||||
|
ZCLAW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-3 px-2">
|
||||||
|
{Array.from(groups.entries()).map(([groupName, items]) => (
|
||||||
|
<div key={groupName} className="mb-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-600">
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.path === '/'
|
||||||
|
? activePath === '/'
|
||||||
|
: activePath.startsWith(item.path)
|
||||||
|
|
||||||
|
const btn = (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => onNavigate(item.path)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
|
||||||
|
transition-all duration-150 ease-in-out cursor-pointer border-none bg-transparent
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? 'text-brand-purple bg-brand-purple/8 dark:text-brand-purple dark:bg-brand-purple/12'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
${collapsed ? 'justify-center' : ''}
|
||||||
|
`}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-base ${isActive ? 'text-brand-purple' : ''}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
{isActive && (
|
||||||
|
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-brand-purple" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={item.path} title={item.name} placement="right">
|
||||||
|
{btn}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div key={item.path}>{btn}</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<div className="border-t border-neutral-200 dark:border-neutral-800 p-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="text-[11px] text-neutral-400 dark:text-neutral-600 text-center">
|
||||||
|
ZCLAW Admin v2
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mobile Drawer Sidebar
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function MobileDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
activePath,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
activePath: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
placement="left"
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
width={280}
|
||||||
|
styles={{
|
||||||
|
body: { padding: 0 },
|
||||||
|
header: { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar collapsed={false} onNavigate={onNavigate} activePath={activePath} />
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Breadcrumb
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const breadcrumbMap: Record<string, string> = {
|
||||||
|
'/': '仪表盘',
|
||||||
|
'/accounts': '账号管理',
|
||||||
|
'/model-services': '模型服务',
|
||||||
|
'/providers': '模型服务',
|
||||||
|
'/models': '模型服务',
|
||||||
|
'/api-keys': 'API 密钥',
|
||||||
|
'/agent-templates': 'Agent 模板',
|
||||||
|
'/usage': '用量统计',
|
||||||
|
'/relay': '中转任务',
|
||||||
|
'/config': '系统配置',
|
||||||
|
'/prompts': '提示词管理',
|
||||||
|
'/logs': '操作日志',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Layout
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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()
|
logout()
|
||||||
message.success('已退出登录')
|
|
||||||
navigate('/login', { replace: true })
|
navigate('/login', { replace: true })
|
||||||
}
|
}, [logout, navigate])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
|
||||||
|
}, [themeState.resolved])
|
||||||
|
|
||||||
|
const currentPage = breadcrumbMap[location.pathname] || '页面'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProLayout
|
<div className="flex h-screen overflow-hidden bg-neutral-50 dark:bg-neutral-950">
|
||||||
title="ZCLAW"
|
{/* Desktop Sidebar */}
|
||||||
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
|
{!isMobile && (
|
||||||
layout="mix"
|
<aside
|
||||||
fixSiderbar
|
className={`
|
||||||
fixedHeader
|
shrink-0 border-r border-neutral-200 dark:border-neutral-800
|
||||||
location={{ pathname: location.pathname }}
|
bg-white dark:bg-neutral-900
|
||||||
menuDataRender={() => menuData}
|
transition-all duration-200 ease-in-out
|
||||||
menuItemRender={(item, dom) => (
|
${collapsed ? 'w-12' : 'w-64'}
|
||||||
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
|
`}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
activePath={location.pathname}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
)}
|
)}
|
||||||
avatarProps={{
|
|
||||||
src: undefined,
|
{/* Mobile Drawer */}
|
||||||
title: account?.display_name || account?.username || 'Admin',
|
{isMobile && (
|
||||||
size: 'small',
|
<MobileDrawer
|
||||||
render: (_, dom) => (
|
open={mobileOpen}
|
||||||
<Dropdown
|
onClose={() => setMobileOpen(false)}
|
||||||
menu={{
|
onNavigate={handleNavigate}
|
||||||
items: [
|
activePath={location.pathname}
|
||||||
{
|
/>
|
||||||
key: 'logout',
|
)}
|
||||||
icon: <LogoutOutlined />,
|
|
||||||
label: '退出登录',
|
{/* Main Area */}
|
||||||
onClick: handleLogout,
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
},
|
{/* Header */}
|
||||||
],
|
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||||
}}
|
<div className="flex items-center gap-3">
|
||||||
>
|
{/* Mobile menu button */}
|
||||||
{dom}
|
{isMobile && (
|
||||||
</Dropdown>
|
<button
|
||||||
),
|
onClick={() => setMobileOpen(true)}
|
||||||
}}
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
suppressSiderWhenMenuEmpty
|
aria-label="打开菜单"
|
||||||
contentStyle={{ padding: 24 }}
|
>
|
||||||
>
|
<MenuUnfoldOutlined className="text-lg" />
|
||||||
<Outlet />
|
</button>
|
||||||
</ProLayout>
|
)}
|
||||||
|
|
||||||
|
{/* Collapse toggle (desktop) */}
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<MenuUnfoldOutlined className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<MenuFoldOutlined className="text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-neutral-400 dark:text-neutral-600">ZCLAW</span>
|
||||||
|
<span className="text-neutral-300 dark:text-neutral-700">/</span>
|
||||||
|
<span className="text-neutral-900 dark:text-neutral-100 font-medium">
|
||||||
|
{currentPage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<Tooltip title={themeState.resolved === 'dark' ? '切换亮色' : '切换暗色'}>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label="切换主题"
|
||||||
|
>
|
||||||
|
{themeState.resolved === 'dark' ? (
|
||||||
|
<SunOutlined className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<MoonOutlined className="text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
|
||||||
|
<Avatar
|
||||||
|
size={28}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(account?.display_name || account?.username || 'A')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
{!isMobile && (
|
||||||
|
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-medium">
|
||||||
|
{account?.display_name || account?.username || 'Admin'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="flex-1 overflow-y-auto p-6"
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
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 zhCN from 'antd/locale/zh_CN'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import { useThemeStore } from './stores/themeStore'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -16,14 +18,71 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
function ThemedApp() {
|
||||||
<ErrorBoundary>
|
const resolved = useThemeStore((s) => s.resolved)
|
||||||
<ConfigProvider locale={zhCN}>
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#863bff',
|
||||||
|
colorBgContainer: resolved === 'dark' ? '#292524' : '#ffffff',
|
||||||
|
colorBgElevated: resolved === 'dark' ? '#1c1917' : '#ffffff',
|
||||||
|
colorBgLayout: resolved === 'dark' ? '#0c0a09' : '#fafaf9',
|
||||||
|
colorBorder: resolved === 'dark' ? '#44403c' : '#e7e5e4',
|
||||||
|
colorBorderSecondary: resolved === 'dark' ? '#44403c' : '#f5f5f4',
|
||||||
|
colorText: resolved === 'dark' ? '#fafaf9' : '#1c1917',
|
||||||
|
colorTextSecondary: resolved === 'dark' ? '#a8a29e' : '#78716c',
|
||||||
|
colorTextTertiary: resolved === 'dark' ? '#78716c' : '#a8a29e',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
fontFamily:
|
||||||
|
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: 14,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
algorithm: resolved === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
headerBg: resolved === 'dark' ? '#1c1917' : '#fafaf9',
|
||||||
|
headerColor: resolved === 'dark' ? '#a8a29e' : '#78716c',
|
||||||
|
rowHoverBg: resolved === 'dark' ? 'rgba(134,59,255,0.06)' : 'rgba(134,59,255,0.04)',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadius: 8,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Tag: {
|
||||||
|
borderRadiusSM: 9999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AntApp>
|
<AntApp>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AntApp>
|
</AntApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThemedApp />
|
||||||
</ErrorBoundary>,
|
</ErrorBoundary>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
|
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 type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { accountService } from '@/services/accounts'
|
import { accountService } from '@/services/accounts'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
import type { AccountPublic } from '@/types'
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
const roleLabels: Record<string, string> = {
|
const roleLabels: Record<string, string> = {
|
||||||
@@ -116,7 +116,10 @@ export default function Accounts() {
|
|||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}
|
||||||
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
{record.status === 'active' ? (
|
{record.status === 'active' ? (
|
||||||
@@ -142,6 +145,8 @@ export default function Accounts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<PageHeader title="账号管理" description="管理系统用户账号、角色与权限" />
|
||||||
|
|
||||||
<ProTable<AccountPublic>
|
<ProTable<AccountPublic>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
@@ -158,13 +163,13 @@ export default function Accounts() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="编辑账号"
|
title={<span className="text-base font-semibold">编辑账号</span>}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOk={handleSave}
|
onOk={handleSave}
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
confirmLoading={updateMutation.isPending}
|
confirmLoading={updateMutation.isPending}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
<Form.Item name="display_name" label="显示名">
|
<Form.Item name="display_name" label="显示名">
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions, MinusCircleOutlined } from 'antd'
|
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons'
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { agentTemplateService } from '@/services/agent-templates'
|
import { agentTemplateService } from '@/services/agent-templates'
|
||||||
|
|||||||
@@ -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<string | null>(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<TokenInfo>[] = [
|
|
||||||
{ title: '名称', dataIndex: 'name', width: 160 },
|
|
||||||
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
|
|
||||||
{
|
|
||||||
title: '权限',
|
|
||||||
dataIndex: 'permissions',
|
|
||||||
width: 200,
|
|
||||||
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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) => (
|
|
||||||
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
|
|
||||||
<Button size="small" danger>撤销</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
createMutation.mutate(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToken = () => {
|
|
||||||
if (newToken) {
|
|
||||||
navigator.clipboard.writeText(newToken)
|
|
||||||
message.success('已复制到剪贴板')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<TokenInfo>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={isLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={false}
|
|
||||||
toolBarRender={() => [
|
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
|
||||||
创建密钥
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
pagination={{
|
|
||||||
total: data?.total ?? 0,
|
|
||||||
pageSize: data?.page_size ?? 20,
|
|
||||||
current: data?.page ?? 1,
|
|
||||||
showSizeChanger: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="创建 API 密钥"
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleCreate}
|
|
||||||
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
|
||||||
confirmLoading={createMutation.isPending}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="给密钥起个名字" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="expires_days" label="有效期 (天)">
|
|
||||||
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
|
|
||||||
<Select mode="multiple" placeholder="选择权限" options={[
|
|
||||||
{ value: 'relay:use', label: '中转使用' },
|
|
||||||
{ value: 'model:read', label: '模型读取' },
|
|
||||||
{ value: 'config:read', label: '配置读取' },
|
|
||||||
]} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="密钥创建成功"
|
|
||||||
open={!!newToken}
|
|
||||||
onOk={() => setNewToken(null)}
|
|
||||||
onCancel={() => setNewToken(null)}
|
|
||||||
>
|
|
||||||
<p>请立即保存此密钥,关闭后将无法再次查看:</p>
|
|
||||||
<Input.TextArea
|
|
||||||
value={newToken || ''}
|
|
||||||
rows={3}
|
|
||||||
readOnly
|
|
||||||
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
|
|
||||||
/>
|
|
||||||
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
|
|
||||||
复制密钥
|
|
||||||
</Button>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
|
import { Card, Col, Row, Statistic, Table, Tag, Spin } from 'antd'
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
@@ -13,34 +13,18 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { statsService } from '@/services/stats'
|
import { statsService } from '@/services/stats'
|
||||||
import { logService } from '@/services/logs'
|
import { logService } from '@/services/logs'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import { actionLabels, actionColors } from '@/constants/status'
|
||||||
import type { OperationLog } from '@/types'
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
const actionLabels: Record<string, string> = {
|
|
||||||
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: '桌面端审计',
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionColors: Record<string, string> = {
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
error: statsError,
|
||||||
|
refetch: refetchStats,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['dashboard-stats'],
|
queryKey: ['dashboard-stats'],
|
||||||
queryFn: ({ signal }) => statsService.dashboard(signal),
|
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||||
})
|
})
|
||||||
@@ -51,15 +35,28 @@ export default function Dashboard() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (statsError) {
|
if (statsError) {
|
||||||
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="仪表盘" description="系统概览与最近活动" />
|
||||||
|
<ErrorState
|
||||||
|
message={(statsError as Error).message}
|
||||||
|
onRetry={() => refetchStats()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
|
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#863bff' },
|
||||||
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
|
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#47bfff' },
|
||||||
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
|
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#22c55e' },
|
||||||
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
|
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#f59e0b' },
|
||||||
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
|
{
|
||||||
|
title: '今日 Token',
|
||||||
|
value: (stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0),
|
||||||
|
icon: <ColumnWidthOutlined />,
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const logColumns = [
|
const logColumns = [
|
||||||
@@ -74,7 +71,13 @@ export default function Dashboard() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
|
{
|
||||||
|
title: '目标类型',
|
||||||
|
dataIndex: 'target_type',
|
||||||
|
key: 'target_type',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
@@ -86,19 +89,34 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} style={{ marginBottom: 24 }}>仪表盘</Title>
|
<PageHeader title="仪表盘" description="系统概览与最近活动" />
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
{/* Stat Cards */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
<Col span={24}><Spin /></Col>
|
<Col span={24}>
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
) : (
|
) : (
|
||||||
statCards.map((card) => (
|
statCards.map((card) => (
|
||||||
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
||||||
<Card>
|
<Card
|
||||||
|
className="hover:shadow-md transition-shadow duration-200"
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={card.title}
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
value={card.value}
|
value={card.value}
|
||||||
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
|
valueStyle={{ fontSize: 28, fontWeight: 600, color: card.color }}
|
||||||
|
prefix={
|
||||||
|
<span style={{ color: card.color, marginRight: 4 }}>{card.icon}</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -106,7 +124,16 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="最近操作日志" size="small">
|
{/* Recent Logs */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
最近操作日志
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
<Table<OperationLog>
|
<Table<OperationLog>
|
||||||
columns={logColumns}
|
columns={logColumns}
|
||||||
dataSource={logsData?.items ?? []}
|
dataSource={logsData?.items ?? []}
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import { useState } from 'react'
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { LoginForm, ProFormText } from '@ant-design/pro-components'
|
import { LoginForm, ProFormText } from '@ant-design/pro-components'
|
||||||
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
|
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
import { message, Divider, Typography } from 'antd'
|
import { message } from 'antd'
|
||||||
import { authService } from '@/services/auth'
|
import { authService } from '@/services/auth'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { LoginRequest } from '@/types'
|
import type { LoginRequest } from '@/types'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
@@ -50,51 +48,75 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex' }}>
|
<div className="min-h-screen flex">
|
||||||
{/* 左侧品牌区 */}
|
{/* Left Brand Panel — hidden on mobile */}
|
||||||
<div
|
<div className="hidden md:flex flex-1 flex-col items-center justify-center relative overflow-hidden"
|
||||||
style={{
|
style={{ background: 'linear-gradient(135deg, #0c0a09 0%, #1c1917 40%, #292524 100%)' }}
|
||||||
flex: '1 1 0',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
|
{/* Decorative gradient orb */}
|
||||||
ZCLAW
|
<div
|
||||||
</Title>
|
className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-3xl"
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent 管理平台</Text>
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)', top: '20%', left: '10%' }}
|
||||||
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
|
/>
|
||||||
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
|
<div
|
||||||
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
className="absolute w-[300px] h-[300px] rounded-full opacity-10 blur-3xl"
|
||||||
</Text>
|
style={{ background: 'linear-gradient(135deg, #47bfff, #863bff)', bottom: '10%', right: '15%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand content */}
|
||||||
|
<div className="relative z-10 text-center px-8">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-2xl font-bold">Z</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-3 tracking-tight">ZCLAW</h1>
|
||||||
|
<p className="text-white/50 text-base mb-8">AI Agent 管理平台</p>
|
||||||
|
<div className="w-16 h-px mx-auto mb-8" style={{ background: 'linear-gradient(90deg, transparent, #863bff, #47bfff, transparent)' }} />
|
||||||
|
<p className="text-white/30 text-sm max-w-sm mx-auto leading-relaxed">
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧登录表单 */}
|
{/* Right Login Form */}
|
||||||
<div
|
<div className="flex-1 md:flex-none md:w-[480px] flex items-center justify-center p-8 bg-white dark:bg-neutral-950">
|
||||||
style={{
|
<div className="w-full max-w-[360px]">
|
||||||
flex: '0 0 480px',
|
{/* Mobile logo (visible only on mobile) */}
|
||||||
display: 'flex',
|
<div className="md:hidden flex items-center gap-3 mb-10">
|
||||||
alignItems: 'center',
|
<div
|
||||||
justifyContent: 'center',
|
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||||
padding: 48,
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
}}
|
>
|
||||||
>
|
<span className="text-white font-bold">Z</span>
|
||||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
</div>
|
||||||
<Title level={3} style={{ marginBottom: 4 }}>登录</Title>
|
<span className="text-xl font-bold text-neutral-900 dark:text-white">ZCLAW</span>
|
||||||
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-1">
|
||||||
|
登录
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-8">
|
||||||
输入您的账号信息以继续
|
输入您的账号信息以继续
|
||||||
</Text>
|
</p>
|
||||||
|
|
||||||
<LoginForm
|
<LoginForm
|
||||||
onFinish={handleSubmit}
|
onFinish={handleSubmit}
|
||||||
submitter={{
|
submitter={{
|
||||||
searchConfig: { submitText: '登录' },
|
searchConfig: { submitText: '登录' },
|
||||||
submitButtonProps: { loading, block: true },
|
submitButtonProps: {
|
||||||
|
loading,
|
||||||
|
block: true,
|
||||||
|
style: {
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 15,
|
||||||
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProFormText
|
<ProFormText
|
||||||
|
|||||||
427
admin-v2/src/pages/ModelServices.tsx
Normal file
427
admin-v2/src/pages/ModelServices.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Tabs, Table, 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 { modelService } from '@/services/models'
|
||||||
|
import type { Provider, ProviderKey, Model } from '@/types'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 子组件: 模型表格
|
||||||
|
// ============================================================
|
||||||
|
function ProviderModelsTable({ providerId }: { providerId: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['provider-models', providerId],
|
||||||
|
queryFn: ({ signal }) => modelService.list({ provider_id: providerId! }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
|
||||||
|
modelService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => modelService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
},
|
||||||
|
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, provider_id: providerId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<Model>[] = [
|
||||||
|
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
|
||||||
|
{ title: '别名', dataIndex: 'alias', width: 120 },
|
||||||
|
{ title: '上下文窗口', dataIndex: 'context_window', width: 100, render: (_, r) => r.context_window?.toLocaleString() },
|
||||||
|
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 90, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||||
|
{ title: '流式', dataIndex: 'supports_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag> },
|
||||||
|
{ title: '视觉', dataIndex: 'supports_vision', width: 60, render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag> },
|
||||||
|
{ title: '状态', dataIndex: 'enabled', width: 60, render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag> },
|
||||||
|
{
|
||||||
|
title: '操作', width: 120, render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const models = data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
添加模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table<Model>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={models}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑模型' : '添加模型'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="alias" label="别名">
|
||||||
|
<Input placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="context_window" label="上下文窗口" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} placeholder="128000" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_output_tokens" label="最大输出 Token" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} placeholder="4096" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="pricing_input" label="输入价格 (每百万Token)" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pricing_output" label="输出价格 (每百万Token)" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 子组件: 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<ProviderKey>[] = [
|
||||||
|
{ 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 ? <Tag color="green">活跃</Tag> : <Tag color="orange">冷却</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 120,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Popconfirm
|
||||||
|
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
|
||||||
|
onConfirm={() => toggleKeyMutation.mutate({ keyId: record.id, active: !record.is_active })}
|
||||||
|
>
|
||||||
|
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
|
||||||
|
{record.is_active ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm title="确定删除此密钥?此操作不可恢复。" onConfirm={() => deleteKeyMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const keys = data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
|
||||||
|
添加密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table<ProviderKey>
|
||||||
|
columns={keyColumns}
|
||||||
|
dataSource={keys}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加密钥"
|
||||||
|
open={addKeyOpen}
|
||||||
|
onOk={() => {
|
||||||
|
addKeyForm.validateFields().then((v) => addKeyMutation.mutate(v))
|
||||||
|
}}
|
||||||
|
onCancel={() => setAddKeyOpen(false)}
|
||||||
|
confirmLoading={addKeyMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={addKeyForm} layout="vertical">
|
||||||
|
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如: my-openai-key" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
|
||||||
|
<Input.Password placeholder="sk-..." />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="priority" label="优先级" initialValue={0} style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_rpm" label="最大 RPM" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_tpm" label="最大 TPM" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主页面: 模型服务
|
||||||
|
// ============================================================
|
||||||
|
export default function ModelServices() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['providers'],
|
||||||
|
queryFn: ({ signal }) => providerService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
||||||
|
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<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
|
||||||
|
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<Provider>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'display_name', width: 150 },
|
||||||
|
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ 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 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 140, hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<Provider>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建服务商
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'models',
|
||||||
|
label: `模型`,
|
||||||
|
children: <ProviderModelsTable providerId={record.id} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keys',
|
||||||
|
label: 'Key Pool',
|
||||||
|
children: <ProviderKeysTable providerId={record.id} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingId? '编辑服务商' : '新建服务商'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} placeholder="如 openai, anthropic" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 OpenAI" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="https://api.openai.com/v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="api_protocol" label="API 协议">
|
||||||
|
<Input placeholder="openai" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="rate_limit_rpm" label="RPM 限制" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<Omit<Model, 'id'>>) => 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<Omit<Model, 'id'>> }) =>
|
|
||||||
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<Model>[] = [
|
|
||||||
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
|
|
||||||
{ 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 ? <Tag color="green">是</Tag> : <Tag>否</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '视觉',
|
|
||||||
dataIndex: 'supports_vision',
|
|
||||||
width: 70,
|
|
||||||
hideInSearch: true,
|
|
||||||
render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'enabled',
|
|
||||||
width: 70,
|
|
||||||
hideInSearch: true,
|
|
||||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 160,
|
|
||||||
hideInSearch: true,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
|
||||||
<Button size="small" danger>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
if (editingId) {
|
|
||||||
updateMutation.mutate({ id: editingId, data: values })
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<Model>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={isLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={{}}
|
|
||||||
toolBarRender={() => [
|
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
|
||||||
新建模型
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
pagination={{
|
|
||||||
total: data?.total ?? 0,
|
|
||||||
pageSize: data?.page_size ?? 20,
|
|
||||||
current: data?.page ?? 1,
|
|
||||||
showSizeChanger: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingId ? '编辑模型' : '新建模型'}
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
width={600}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
|
|
||||||
<Select
|
|
||||||
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
|
|
||||||
placeholder="选择服务商"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="如 gpt-4o" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="alias" label="别名">
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="context_window" label="上下文窗口">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="max_output_tokens" label="最大输出 Token">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
|
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
|
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<string | null>(null)
|
|
||||||
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(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<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
|
||||||
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<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
|
|
||||||
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<Provider>[] = [
|
|
||||||
{ title: '名称', dataIndex: 'display_name', width: 140 },
|
|
||||||
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
|
||||||
{ 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 ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 260,
|
|
||||||
hideInSearch: true,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
|
|
||||||
Key Pool
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
|
||||||
<Button size="small" danger>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const keyColumns: ProColumns<ProviderKey>[] = [
|
|
||||||
{ 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 ? <Tag color="green">活跃</Tag> : <Tag>冷却</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 160,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Popconfirm
|
|
||||||
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
|
|
||||||
onConfirm={() => toggleKeyMutation.mutate({
|
|
||||||
providerId: keyModalProviderId!,
|
|
||||||
keyId: record.id,
|
|
||||||
active: !record.is_active,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
|
|
||||||
{record.is_active ? '禁用' : '启用'}
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
<Popconfirm
|
|
||||||
title="确定删除此密钥?此操作不可恢复。"
|
|
||||||
onConfirm={() => deleteKeyMutation.mutate({
|
|
||||||
providerId: keyModalProviderId!,
|
|
||||||
keyId: record.id,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button size="small" danger>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const values = await form.validateFields()
|
|
||||||
if (editingId) {
|
|
||||||
updateMutation.mutate({ id: editingId, data: values })
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ProTable<Provider>
|
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.items ?? []}
|
|
||||||
loading={isLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={{}}
|
|
||||||
toolBarRender={() => [
|
|
||||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
|
||||||
新建服务商
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
pagination={{
|
|
||||||
total: data?.total ?? 0,
|
|
||||||
pageSize: data?.page_size ?? 20,
|
|
||||||
current: data?.page ?? 1,
|
|
||||||
showSizeChanger: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingId ? '编辑服务商' : '新建服务商'}
|
|
||||||
open={modalOpen}
|
|
||||||
onOk={handleSave}
|
|
||||||
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
|
|
||||||
<Input disabled={!!editingId} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="api_protocol" label="API 协议">
|
|
||||||
<Input placeholder="openai" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="rate_limit_rpm" label="RPM 限制">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="Key Pool"
|
|
||||||
open={!!keyModalProviderId}
|
|
||||||
onCancel={() => setKeyModalProviderId(null)}
|
|
||||||
footer={(_, { OkBtn, CancelBtn }) => (
|
|
||||||
<Space>
|
|
||||||
<CancelBtn />
|
|
||||||
<Button type="primary" onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
|
|
||||||
添加密钥
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
width={700}
|
|
||||||
>
|
|
||||||
<ProTable<ProviderKey>
|
|
||||||
columns={keyColumns}
|
|
||||||
dataSource={keysData ?? []}
|
|
||||||
loading={keysLoading}
|
|
||||||
rowKey="id"
|
|
||||||
search={false}
|
|
||||||
toolBarRender={false}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="添加密钥"
|
|
||||||
open={addKeyOpen}
|
|
||||||
onOk={() => {
|
|
||||||
addKeyForm.validateFields().then((v) =>
|
|
||||||
addKeyMutation.mutate({ providerId: keyModalProviderId!, data: v })
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
onCancel={() => setAddKeyOpen(false)}
|
|
||||||
confirmLoading={addKeyMutation.isPending}
|
|
||||||
>
|
|
||||||
<Form form={addKeyForm} layout="vertical">
|
|
||||||
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
|
|
||||||
<Input.Password />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="priority" label="优先级" initialValue={0}>
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="max_rpm" label="最大 RPM (可选)">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="max_tpm" label="最大 TPM (可选)">
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
// 中转任务
|
// 中转任务
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { relayService } from '@/services/relay'
|
import { relayService } from '@/services/relay'
|
||||||
import { useState } from 'react'
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
import type { RelayTask } from '@/types'
|
import type { RelayTask } from '@/types'
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
@@ -32,26 +32,57 @@ export default function Relay() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
|
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['relay-tasks', page, statusFilter],
|
queryKey: ['relay-tasks', page, statusFilter],
|
||||||
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="中转任务" description="查看和管理 AI 模型中转请求" />
|
||||||
|
<ErrorState message={(error as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ProColumns<RelayTask>[] = [
|
const columns: ProColumns<RelayTask>[] = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{r.id.substring(0, 8)}...</code> },
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => (
|
||||||
|
<code className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">
|
||||||
|
{r.id.substring(0, 8)}...
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => <Tag color={statusColors[r.status] || 'default'}>{statusLabels[r.status] || r.status}</Tag>,
|
render: (_, r) => (
|
||||||
|
<Tag color={statusColors[r.status] || 'default'}>
|
||||||
|
{statusLabels[r.status] || r.status}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '模型', dataIndex: 'model_id', width: 160 },
|
{ title: '模型', dataIndex: 'model_id', width: 160 },
|
||||||
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||||
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
|
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
|
||||||
{
|
{
|
||||||
title: 'Token',
|
title: 'Token (入/出)',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
|
render: (_, r) => (
|
||||||
|
<span className="text-sm">
|
||||||
|
{r.input_tokens.toLocaleString()} / {r.output_tokens.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
|
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
|
||||||
{
|
{
|
||||||
@@ -64,30 +95,36 @@ export default function Relay() {
|
|||||||
title: '完成时间',
|
title: '完成时间',
|
||||||
dataIndex: 'completed_at',
|
dataIndex: 'completed_at',
|
||||||
width: 180,
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<PageHeader
|
||||||
<Title level={4} style={{ margin: 0 }}>中转任务</Title>
|
title="中转任务"
|
||||||
<Select
|
description="查看和管理 AI 模型中转请求"
|
||||||
value={statusFilter}
|
actions={
|
||||||
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
|
<Select
|
||||||
placeholder="状态筛选"
|
value={statusFilter}
|
||||||
style={{ width: 140 }}
|
onChange={(v) => {
|
||||||
allowClear
|
setStatusFilter(v === 'all' ? undefined : v)
|
||||||
options={[
|
setPage(1)
|
||||||
{ value: 'all', label: '全部' },
|
}}
|
||||||
{ value: 'queued', label: '排队中' },
|
placeholder="状态筛选"
|
||||||
{ value: 'running', label: '运行中' },
|
className="w-36"
|
||||||
{ value: 'completed', label: '已完成' },
|
allowClear
|
||||||
{ value: 'failed', label: '失败' },
|
options={[
|
||||||
{ value: 'cancelled', label: '已取消' },
|
{ value: 'all', label: '全部' },
|
||||||
]}
|
{ value: 'queued', label: '排队中' },
|
||||||
/>
|
{ value: 'running', label: '运行中' },
|
||||||
</div>
|
{ value: 'completed', label: '已完成' },
|
||||||
|
{ value: 'failed', label: '失败' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<ProTable<RelayTask>
|
<ProTable<RelayTask>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -4,20 +4,24 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
|
import { Card, Col, Row, Select, Statistic } from 'antd'
|
||||||
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
import { ThunderboltOutlined, ColumnWidthOutlined } from '@ant-design/icons'
|
||||||
import type { ProColumns } from '@ant-design/pro-components'
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
import { ProTable } from '@ant-design/pro-components'
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
import { usageService } from '@/services/usage'
|
|
||||||
import { telemetryService } from '@/services/telemetry'
|
import { telemetryService } from '@/services/telemetry'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
||||||
|
|
||||||
const { Title } = Typography
|
|
||||||
|
|
||||||
export default function Usage() {
|
export default function Usage() {
|
||||||
const [days, setDays] = useState(30)
|
const [days, setDays] = useState(30)
|
||||||
|
|
||||||
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
|
const {
|
||||||
|
data: dailyData,
|
||||||
|
isLoading: dailyLoading,
|
||||||
|
error: dailyError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
queryKey: ['usage-daily', days],
|
queryKey: ['usage-daily', days],
|
||||||
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
|
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
|
||||||
})
|
})
|
||||||
@@ -28,7 +32,12 @@ export default function Usage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (dailyError) {
|
if (dailyError) {
|
||||||
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="用量统计" description="查看模型使用情况和 Token 消耗" />
|
||||||
|
<ErrorState message={(dailyError as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
||||||
@@ -36,22 +45,52 @@ export default function Usage() {
|
|||||||
|
|
||||||
const dailyColumns: ProColumns<DailyUsageStat>[] = [
|
const dailyColumns: ProColumns<DailyUsageStat>[] = [
|
||||||
{ title: '日期', dataIndex: 'day', width: 120 },
|
{ title: '日期', dataIndex: 'day', width: 120 },
|
||||||
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
|
{
|
||||||
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
|
title: '请求数',
|
||||||
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
|
dataIndex: 'request_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.request_count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入 Token',
|
||||||
|
dataIndex: 'input_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.input_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出 Token',
|
||||||
|
dataIndex: 'output_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.output_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
|
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const modelColumns: ProColumns<ModelUsageStat>[] = [
|
const modelColumns: ProColumns<ModelUsageStat>[] = [
|
||||||
{ title: '模型', dataIndex: 'model_id', width: 200 },
|
{ title: '模型', dataIndex: 'model_id', width: 200 },
|
||||||
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
|
{
|
||||||
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
|
title: '请求数',
|
||||||
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
|
dataIndex: 'request_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.request_count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入 Token',
|
||||||
|
dataIndex: 'input_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.input_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出 Token',
|
||||||
|
dataIndex: 'output_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.output_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '平均延迟',
|
title: '平均延迟',
|
||||||
dataIndex: 'avg_latency_ms',
|
dataIndex: 'avg_latency_ms',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
|
render: (_, r) => (r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '成功率',
|
title: '成功率',
|
||||||
@@ -63,34 +102,66 @@ export default function Usage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
<PageHeader
|
||||||
<Title level={4} style={{ margin: 0 }}>用量统计</Title>
|
title="用量统计"
|
||||||
<Select
|
description="查看模型使用情况和 Token 消耗"
|
||||||
value={days}
|
actions={
|
||||||
onChange={setDays}
|
<Select
|
||||||
options={[
|
value={days}
|
||||||
{ value: 7, label: '最近 7 天' },
|
onChange={setDays}
|
||||||
{ value: 30, label: '最近 30 天' },
|
options={[
|
||||||
{ value: 90, label: '最近 90 天' },
|
{ value: 7, label: '最近 7 天' },
|
||||||
]}
|
{ value: 30, label: '最近 30 天' },
|
||||||
style={{ width: 140 }}
|
{ value: 90, label: '最近 90 天' },
|
||||||
/>
|
]}
|
||||||
</div>
|
className="w-36"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
{/* Summary Cards */}
|
||||||
<Col span={12}>
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
<Card>
|
<Col xs={24} sm={12}>
|
||||||
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
总请求数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalRequests}
|
||||||
|
prefix={<ThunderboltOutlined style={{ color: '#863bff' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#863bff' }}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col xs={24} sm={12}>
|
||||||
<Card>
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
总 Token 数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalTokens}
|
||||||
|
prefix={<ColumnWidthOutlined style={{ color: '#47bfff' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#47bfff' }}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
|
{/* Daily Stats */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
每日统计
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="mb-6"
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
<ProTable<DailyUsageStat>
|
<ProTable<DailyUsageStat>
|
||||||
columns={dailyColumns}
|
columns={dailyColumns}
|
||||||
dataSource={dailyData ?? []}
|
dataSource={dailyData ?? []}
|
||||||
@@ -103,7 +174,16 @@ export default function Usage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="按模型统计" size="small">
|
{/* Model Stats */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
按模型统计
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
<ProTable<ModelUsageStat>
|
<ProTable<ModelUsageStat>
|
||||||
columns={modelColumns}
|
columns={modelColumns}
|
||||||
dataSource={modelData ?? []}
|
dataSource={modelData ?? []}
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'accounts', lazy: () => import('@/pages/Accounts').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: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'models', lazy: () => import('@/pages/Models').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: '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: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||||
{ path: 'relay', lazy: () => import('@/pages/Relay').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 })) },
|
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||||
|
|||||||
56
admin-v2/src/stores/themeStore.ts
Normal file
56
admin-v2/src/stores/themeStore.ts
Normal file
@@ -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<ThemeState>(() => ({
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
235
admin-v2/src/styles/globals.css
Normal file
235
admin-v2/src/styles/globals.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [tailwindcss(), react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|||||||
@@ -645,15 +645,11 @@ impl Kernel {
|
|||||||
// Reuse existing session or create new one
|
// Reuse existing session or create new one
|
||||||
let session_id = match session_id_override {
|
let session_id = match session_id_override {
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
// Verify the session exists; if not, create a new one
|
// Use get_or_create to ensure the frontend's session ID is persisted.
|
||||||
let existing = self.memory.get_messages(&id).await;
|
// This is the critical bridge: without it, the kernel generates a
|
||||||
match existing {
|
// different UUID each turn, so conversation history is never found.
|
||||||
Ok(msgs) if !msgs.is_empty() => id,
|
tracing::debug!("Reusing frontend session ID: {}", id);
|
||||||
_ => {
|
self.memory.get_or_create_session(&id, agent_id).await?
|
||||||
tracing::debug!("Session {} not found or empty, creating new session", id);
|
|
||||||
self.memory.create_session(agent_id).await?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => self.memory.create_session(agent_id).await?,
|
None => self.memory.create_session(agent_id).await?,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -173,6 +173,49 @@ impl MemoryStore {
|
|||||||
Ok(session_id)
|
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<SessionId> {
|
||||||
|
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
|
/// Append a message to a session
|
||||||
pub async fn append_message(&self, session_id: &SessionId, message: &Message) -> Result<()> {
|
pub async fn append_message(&self, session_id: &SessionId, message: &Message) -> Result<()> {
|
||||||
let session_str = session_id.to_string();
|
let session_str = session_id.to_string();
|
||||||
|
|||||||
@@ -360,10 +360,14 @@ impl OpenAiDriver {
|
|||||||
|
|
||||||
if let Some(calls) = calls {
|
if let Some(calls) = calls {
|
||||||
if !calls.is_empty() {
|
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 {
|
out.push(OpenAiMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: content.filter(|s| !s.is_empty()),
|
content: Some(content_value),
|
||||||
reasoning_content: reasoning.filter(|s| !s.is_empty()),
|
reasoning_content: reasoning.filter(|s| !s.is_empty()),
|
||||||
tool_calls: Some(calls),
|
tool_calls: Some(calls),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
@@ -371,11 +375,14 @@ impl OpenAiDriver {
|
|||||||
return;
|
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() {
|
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 {
|
out.push(OpenAiMessage {
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
content: content.filter(|s| !s.is_empty()),
|
content: Some(content_value),
|
||||||
reasoning_content: reasoning.filter(|s| !s.is_empty()),
|
reasoning_content: reasoning.filter(|s| !s.is_empty()),
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ function App() {
|
|||||||
document.title = 'ZCLAW';
|
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
|
// Watch for Hands that need approval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handsNeedingApproval = hands.filter(h => h.status === 'needs_approval');
|
const handsNeedingApproval = hands.filter(h => h.status === 'needs_approval');
|
||||||
@@ -345,6 +359,9 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 登录门禁 — 必须登录才能使用
|
// 登录门禁 — 必须登录才能使用
|
||||||
|
if (isRestoring) {
|
||||||
|
return <BootstrapScreen status="Restoring session..." />;
|
||||||
|
}
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export function CloneManager() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
loadClones();
|
loadClones().then(() => {
|
||||||
|
// Sync agents in chatStore and restore currentAgent/conversation
|
||||||
|
const clones = useAgentStore.getState().clones;
|
||||||
|
useChatStore.getState().syncAgents(clones);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [connected, loadClones]);
|
}, [connected, loadClones]);
|
||||||
|
|
||||||
|
|||||||
@@ -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` 同步检查后立即渲染 `<LoginPage />`。
|
||||||
|
|
||||||
|
**修复**: `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) - 配置文件位置、格式和最佳实践
|
- [ZCLAW 配置指南](./zclaw-configuration.md) - 配置文件位置、格式和最佳实践
|
||||||
- [Agent 和 LLM 提供商配置](./agent-provider-config.md) - Agent 管理和 Provider 配置
|
- [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-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-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 路由不匹配 |
|
| 2026-03-28 | 添加 12.1-12.4 节:SaaS 后端问题 — Admin 登录无请求、SQLite→PostgreSQL 遗留语法、角色权限不足、usage 路由不匹配 |
|
||||||
|
|||||||
Reference in New Issue
Block a user