Add projects
32
frontend/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '11.21.0'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Verify
|
||||
run: pnpm verify
|
||||
67
frontend/.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: '11.21.0'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mv dist/index.html dist/management.html
|
||||
tar -czf management-webui.tar.gz -C dist .
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
|
||||
if [ -n "${previous_tag}" ]; then
|
||||
range="${previous_tag}..${current_tag}"
|
||||
else
|
||||
range="${current_tag}"
|
||||
fi
|
||||
|
||||
: > release-notes.md
|
||||
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: management-webui.tar.gz
|
||||
body_path: release-notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
33
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
api.md
|
||||
usage.json
|
||||
management-api*
|
||||
antigravity_usage.json
|
||||
codex_usage.json
|
||||
style.md
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
skills
|
||||
|
||||
# Editor directories and files
|
||||
settings.local.json
|
||||
.codex
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
33
frontend/AGENTS.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This is a React 19 + TypeScript Vite frontend for the CLI Proxy API Management API. Main source lives in `src/`: routes in `src/router`, pages in `src/pages`, components in `src/components`, API clients in `src/services/api`, state in `src/stores`, hooks in `src/hooks`, styles in `src/styles`, and types in `src/types`. Assets live in `src/assets`, with provider icons under `src/assets/icons`. Localization files are in `src/i18n/locales`; update all supported locales when adding user-facing text. Production output is `dist/index.html` plus its files under `dist/assets/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm install`: install dependencies.
|
||||
- `pnpm dev`: start the Vite dev server at `http://localhost:5173`.
|
||||
- `pnpm build`: run TypeScript compilation and build `dist/`.
|
||||
- `pnpm preview`: serve the built output locally.
|
||||
- `pnpm test`: run the Vitest suite.
|
||||
- `pnpm lint`: run ESLint over TypeScript/TSX files.
|
||||
- `pnpm verify`: run tests, lint, TypeScript compilation, and the production build.
|
||||
- `pnpm type-check`: run `tsc --noEmit`.
|
||||
- `pnpm format`: apply Prettier to `src/**/*.{ts,tsx,css,scss}`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use 2-space indentation, semicolons, single quotes, ES5 trailing commas, and 100-character line width. Prefer typed React components and avoid new `any` unless it marks a boundary. Use the `@/` alias for `src` imports. Component files use PascalCase, hooks use `useName`, API modules use domain names such as `oauth.ts`, and SCSS Modules sit beside their page or component as `Name.module.scss`.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use Vitest and are colocated under `tests/` as `*.test.ts`. Run `pnpm test` for focused test work and `pnpm verify` before handoff. Use `pnpm type-check` as a fast standalone TypeScript check. For UI changes, verify the affected route in the browser and include screenshots or notes.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Git history follows Conventional Commit style, for example `feat: add support for xAI provider`, `fix(auth-files): keep disabled card actions visible`, and `ci: use node 24 for releases`. Keep commits focused and scoped when useful. Pull requests should include a change summary, linked issue when applicable, UI screenshots, backend version or reproduction details for integration work, and verification notes.
|
||||
|
||||
## Architecture & Configuration Notes
|
||||
|
||||
This UI is not the proxy; it talks to the backend Management API under `/v0/management`. Treat backend contracts as the source of truth. For OAuth/provider changes, inspect `../backend` before changing route names, provider keys, callback parameters, or auth-file semantics. Store no secrets in the repo; management keys are entered at runtime and persisted only in browser storage.
|
||||
21
frontend/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Router-For.ME
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
33
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// Pages in this app intentionally start async data loads from effects. Those loaders
|
||||
// synchronously expose their loading state before awaiting the Management API.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
);
|
||||
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN" translate="no" class="notranslate">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CLI Proxy API Management Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
57
frontend/package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "cli-proxy-webui-react",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.21.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||
"verify": "pnpm test && pnpm lint && pnpm build",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.9",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"axios": "1.18.1",
|
||||
"i18next": "^26.3.6",
|
||||
"motion": "^12.42.2",
|
||||
"motion-dom": "^12.43.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router": "^7.18.2",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"yaml": "^2.9.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.63.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sass": "^1.101.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^8.1.4",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"overrides": {
|
||||
"form-data": "4.0.6"
|
||||
}
|
||||
}
|
||||
59
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Outlet, RouterProvider, createHashRouter } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
function RootShell() {
|
||||
return (
|
||||
<>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
element: <RootShell />,
|
||||
children: [
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
path: '/*',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function App() {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupTheme = initializeTheme();
|
||||
return cleanupTheme;
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setLanguage(language);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅用于首屏同步 i18n 语言
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
28
frontend/src/assets/icons/antigravity.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: visioncortex VTracer 0.6.4 -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
|
||||
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
|
||||
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
|
||||
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
|
||||
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
|
||||
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
|
||||
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
|
||||
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
|
||||
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
|
||||
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
|
||||
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
|
||||
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
|
||||
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
|
||||
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
|
||||
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
|
||||
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
|
||||
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
|
||||
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
|
||||
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
|
||||
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
|
||||
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
|
||||
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/src/assets/icons/apikey-fun.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/src/assets/icons/bestproxy.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
1
frontend/src/assets/icons/claude.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/src/assets/icons/claudeapi.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/src/assets/icons/code0.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/icons/codex.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/icons/deepseek.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/src/assets/icons/fenno-ai.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
1
frontend/src/assets/icons/gemini.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
frontend/src/assets/icons/glm.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
frontend/src/assets/icons/grok-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 752 B |
1
frontend/src/assets/icons/grok.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
1
frontend/src/assets/icons/iflow.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="32" height="32" viewBox="0 0 32 32"><defs><filter id="master_svg0_278_51503" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur in="BackgroundImageFix" stdDeviation="1.3333334922790527"/><feComposite in2="SourceAlpha" operator="in" result="effect1_foregroundBlur"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_foregroundBlur" result="shape"/></filter><linearGradient x1="0.07353696972131729" y1="0.12899449467658997" x2="0.9907095821060244" y2="0.9383787344260006" id="master_svg1_93_40276"><stop offset="0%" stop-color="#5C5CFF" stop-opacity="1"/><stop offset="100%" stop-color="#AE5CFF" stop-opacity="1"/></linearGradient></defs><g><g filter="url(#master_svg0_278_51503)"><rect x="0" y="0" width="32" height="32" rx="16" fill="#F0F2F5" fill-opacity="0"/></g><g><g><path d="M31.843111328125,14.751C31.315411328125,7.18121,25.497411328125,1.04691,17.966011328125,0.119698C10.434711328125,-0.807512,3.302541328125,3.73244,0.954596328125,10.9482C0.345662328125,12.8248,1.732821328125,14.751,3.705641328125,14.751C4.950051328125,14.7517,6.055631328125,13.9569,6.451401328125,12.7772C7.497331328125,9.65101,10.504411328125,3.91401,18.482011328125,3.91401Q29.445911328125,3.91401,31.843111328125,14.751ZM9.127681328125,17.3314L9.127681328125,13.0862Q9.127681328125,13.0022,9.144081328125,12.9198Q9.160481328125,12.8373,9.192641328125,12.7597Q9.224801328125,12.682,9.271501328125,12.6122Q9.318191328125,12.5423,9.377621328125,12.4828Q9.437051328125,12.4234,9.506931328125,12.3767Q9.576811328125,12.33,9.654461328125,12.2979Q9.732111328125,12.2657,9.814541328125,12.2493Q9.896971328125,12.2329,9.981021328125,12.2329L11.049211328125,12.2329Q11.133211328125,12.2329,11.215711328125,12.2493Q11.298111328125,12.2657,11.375811328125,12.2979Q11.453411328125,12.33,11.523311328125,12.3767Q11.593211328125,12.4234,11.652611328125,12.4828Q11.712011328125,12.5423,11.758711328125,12.6122Q11.805411328125,12.682,11.837611328125,12.7597Q11.869711328125,12.8373,11.886111328125,12.9198Q11.902511328125,13.0022,11.902511328125,13.0862L11.902511328125,17.3314Q11.902511328125,17.4154,11.886111328125,17.4978Q11.869711328125,17.5803,11.837611328125,17.6579Q11.805411328125,17.7356,11.758711328125,17.8055Q11.712011328125,17.8753,11.652611328125,17.9348Q11.593211328125,17.9942,11.523311328125,18.0409Q11.453411328125,18.0876,11.375811328125,18.1197Q11.298111328125,18.1519,11.215711328125,18.1683Q11.133211328125,18.1847,11.049211328125,18.1847L9.981021328125,18.1847Q9.896971328125,18.1847,9.814541328125,18.1683Q9.732111328125,18.1519,9.654461328125,18.1197Q9.576811328125,18.0876,9.506931328125,18.0409Q9.437051328125,17.9942,9.377621328125,17.9348Q9.318191328125,17.8753,9.271501328125,17.8055Q9.224801328125,17.7356,9.192641328125,17.6579Q9.160481328125,17.5803,9.144081328125,17.4978Q9.127681328125,17.4154,9.127681328125,17.3314ZM17.273611328125,17.3295C17.272611328125,17.8015,17.654911328125,18.1847,18.126911328125,18.1847L19.408411328125,18.1847C19.879011328125,18.1847,20.260711328125,17.8038,20.261811328125,17.3332L20.266411328125,15.2107L20.266411328125,15.2069L20.261811328125,13.0844C20.260711328125,12.6138,19.879011328125,12.2329,19.408411328125,12.2329L18.126911328125,12.2329C17.654911328125,12.2329,17.272611328125,12.6161,17.273611328125,13.0881L17.278211328125,15.2069L17.278211328125,15.2107L17.273611328125,17.3295ZM13.574711328125,28.0523C21.552211328125,28.0523,24.559311328125,22.3153,25.605811328125,19.1897C26.001411328125,18.0098,27.107111328125,17.215,28.351511328125,17.2158C30.323811328125,17.2158,31.711511328125,19.1416,31.102611328125,21.0181C30.552411328125,22.7189,29.716211328125,24.3134,28.629811328125,25.733L30.137611328125,30.2235L24.775211328125,29.3432C14.645911328125,36.0484,1.048779328125,29.3346,0.214111328125,17.2158Q2.611231328125,28.0523,13.574711328125,28.0523Z" fill-rule="evenodd" fill="url(#master_svg1_93_40276)" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/src/assets/icons/infistar.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
1
frontend/src/assets/icons/kimi-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><rect width="24" height="24" rx="6" fill="#000"></rect><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>
|
||||
|
After Width: | Height: | Size: 829 B |
1
frontend/src/assets/icons/kimi-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><rect width="24" height="24" rx="6" fill="#fff"></rect><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#000"></path></svg>
|
||||
|
After Width: | Height: | Size: 829 B |
BIN
frontend/src/assets/icons/lmu-ai.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
1
frontend/src/assets/icons/minimax.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/assets/icons/openai-dark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/icons/openai-light.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/src/assets/icons/qiniu-cloud.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
1
frontend/src/assets/icons/qwen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
1
frontend/src/assets/icons/vertex.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
2
frontend/src/assets/logoInline.ts
Normal file
69
frontend/src/components/common/ConfirmationModal.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
export function ConfirmationModal() {
|
||||
const { t } = useTranslation();
|
||||
const confirmation = useNotificationStore((state) => state.confirmation);
|
||||
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
|
||||
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
|
||||
|
||||
const { isOpen, isLoading, options } = confirmation;
|
||||
|
||||
if (!isOpen || !options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText,
|
||||
cancelText,
|
||||
variant = 'primary',
|
||||
} = options;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setConfirmationLoading(true);
|
||||
await onConfirm();
|
||||
hideConfirmation();
|
||||
} catch (error) {
|
||||
console.error('Confirmation action failed:', error);
|
||||
// Optional: show error notification here if needed,
|
||||
// but usually the calling component handles specific errors.
|
||||
} finally {
|
||||
setConfirmationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
hideConfirmation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||
{typeof message === 'string' ? (
|
||||
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||
) : (
|
||||
<div style={{ margin: '1rem 0' }}>{message}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||
{cancelText || t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant={variant} onClick={handleConfirm} loading={isLoading}>
|
||||
{confirmText || t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/common/NotificationContainer.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import type { Notification } from '@/types';
|
||||
|
||||
interface AnimatedNotification extends Notification {
|
||||
isExiting?: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 300; // ms
|
||||
|
||||
export function NotificationContainer() {
|
||||
const { t } = useTranslation();
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
|
||||
const prevNotificationsRef = useRef<Notification[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const prevNotifications = prevNotificationsRef.current;
|
||||
const prevIds = new Set(prevNotifications.map((n) => n.id));
|
||||
const currentIds = new Set(notifications.map((n) => n.id));
|
||||
|
||||
const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
|
||||
|
||||
const removedIds = new Set(
|
||||
prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
|
||||
);
|
||||
|
||||
setAnimatedNotifications((prev) => {
|
||||
let updated = prev.map((n) => (removedIds.has(n.id) ? { ...n, isExiting: true } : n));
|
||||
|
||||
newNotifications.forEach((n) => {
|
||||
if (!updated.find((animatedNotification) => animatedNotification.id === n.id)) {
|
||||
updated.push({ ...n, isExiting: false });
|
||||
}
|
||||
});
|
||||
|
||||
updated = updated.filter((n) => currentIds.has(n.id) || n.isExiting);
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (removedIds.size > 0) {
|
||||
setTimeout(() => {
|
||||
setAnimatedNotifications((prev) => prev.filter((n) => !removedIds.has(n.id)));
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
prevNotificationsRef.current = notifications;
|
||||
}, [notifications]);
|
||||
|
||||
const handleClose = (id: string) => {
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, ANIMATION_DURATION);
|
||||
};
|
||||
|
||||
if (!animatedNotifications.length) return null;
|
||||
|
||||
return (
|
||||
<div className="notification-container">
|
||||
{animatedNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
|
||||
>
|
||||
<div className="message">{notification.message}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="close-btn"
|
||||
onClick={() => handleClose(notification.id)}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/common/PageTransition.scss
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
@use '@/styles/variables.scss' as *;
|
||||
|
||||
.page-transition {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--stacked {
|
||||
display: none;
|
||||
|
||||
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
|
||||
// Older stacked layers remain `display: none` for performance.
|
||||
&.page-transition__layer--stacked-keep {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
457
frontend/src/components/common/PageTransition.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import { animate } from 'motion/mini';
|
||||
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
|
||||
import {
|
||||
PAGE_TRANSITION_LAYER_CONTEXT_VALUES,
|
||||
PageTransitionLayerContext,
|
||||
type LayerStatus,
|
||||
} from './PageTransitionLayer';
|
||||
import './PageTransition.scss';
|
||||
|
||||
interface PageTransitionProps {
|
||||
render: (location: Location) => ReactNode;
|
||||
getRouteOrder?: (pathname: string) => number | null;
|
||||
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// Premium personality: enter > exit, decelerate-in / accelerate-out.
|
||||
const VERTICAL_ENTER_DURATION = 0.36;
|
||||
const VERTICAL_EXIT_DURATION = 0.22;
|
||||
const VERTICAL_ENTER_DISTANCE = 28;
|
||||
const VERTICAL_EXIT_DISTANCE = 12;
|
||||
const REDUCED_MOTION_DURATION = 0.15;
|
||||
|
||||
const IOS_TRANSITION_DURATION = 0.44;
|
||||
const IOS_ENTER_FROM_X_PERCENT = 100;
|
||||
const IOS_EXIT_TO_X_PERCENT_FORWARD = -22;
|
||||
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -22;
|
||||
const IOS_BACKGROUND_SCALE = 0.96;
|
||||
const IOS_BACKGROUND_OPACITY = 0.5;
|
||||
const IOS_SHADOW_VALUE = '-20px 0 36px rgba(0, 0, 0, 0.20)';
|
||||
|
||||
// easeOutQuart: powerful but elegant deceleration for hero entrances.
|
||||
const easeOutQuart = (progress: number) => 1 - (1 - progress) ** 4;
|
||||
// easeInQuad: gentle start, accelerates away — exits should not linger.
|
||||
const easeInQuad = (progress: number) => progress * progress;
|
||||
// easeOutCubic: smooth Apple-style settle for iOS push/pop.
|
||||
const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3;
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`;
|
||||
const buildIosTransform = (xPercent: number, y: number, scale = 1) =>
|
||||
scale === 1
|
||||
? `translate3d(${xPercent}%, ${y}px, 0px)`
|
||||
: `translate3d(${xPercent}%, ${y}px, 0px) scale(${scale})`;
|
||||
|
||||
const clearLayerStyles = (element: HTMLElement | null) => {
|
||||
if (!element) return;
|
||||
element.style.removeProperty('transform');
|
||||
element.style.removeProperty('opacity');
|
||||
element.style.removeProperty('box-shadow');
|
||||
};
|
||||
|
||||
type Layer = {
|
||||
key: string;
|
||||
location: Location;
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
type TransitionVariant = 'vertical' | 'ios';
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
scrollContainerRef,
|
||||
}: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||
const transitionVariantRef = useRef<TransitionVariant>('vertical');
|
||||
const exitScrollOffsetRef = useRef(0);
|
||||
const enterScrollOffsetRef = useRef(0);
|
||||
const scrollPositionsRef = useRef(new Map<string, number>());
|
||||
const nextLayersRef = useRef<Layer[] | null>(null);
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||
{
|
||||
key: location.key,
|
||||
location,
|
||||
status: 'current',
|
||||
},
|
||||
]);
|
||||
const currentLayer =
|
||||
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
|
||||
const currentLayerKey = currentLayer?.key ?? location.key;
|
||||
const currentLayerPathname = currentLayer?.location.pathname;
|
||||
|
||||
const resolveScrollContainer = useCallback(() => {
|
||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.scrollingElement as HTMLElement | null;
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isAnimating) return;
|
||||
if (location.key === currentLayerKey) return;
|
||||
if (currentLayerPathname === location.pathname) return;
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
|
||||
exitScrollOffsetRef.current = exitScrollOffset;
|
||||
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
|
||||
|
||||
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
|
||||
const resolveOrderIndex = (pathname?: string) => {
|
||||
if (!getRouteOrder || !pathname) return null;
|
||||
const index = getRouteOrder(pathname);
|
||||
return typeof index === 'number' && index >= 0 ? index : null;
|
||||
};
|
||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||
const toIndex = resolveOrderIndex(location.pathname);
|
||||
const nextVariant: TransitionVariant = getTransitionVariant
|
||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||
: 'vertical';
|
||||
|
||||
let nextDirection: TransitionDirection =
|
||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||
? 'forward'
|
||||
: toIndex > fromIndex
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
|
||||
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
|
||||
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
|
||||
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
|
||||
nextDirection = 'backward';
|
||||
}
|
||||
|
||||
transitionDirectionRef.current = nextDirection;
|
||||
transitionVariantRef.current = nextVariant;
|
||||
|
||||
const shouldSkipExitLayer = (() => {
|
||||
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
|
||||
const normalizeSegments = (pathname: string) =>
|
||||
pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.filter((segment) => segment.length > 0);
|
||||
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
|
||||
const toSegments = normalizeSegments(location.pathname);
|
||||
if (!fromSegments.length || !toSegments.length) return false;
|
||||
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
|
||||
})();
|
||||
|
||||
setLayers((prev) => {
|
||||
const variant = transitionVariantRef.current;
|
||||
const direction = transitionDirectionRef.current;
|
||||
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex =
|
||||
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
|
||||
const previousCurrent = prev[resolvedCurrentIndex];
|
||||
const previousStack: Layer[] = prev
|
||||
.filter((_, idx) => idx !== resolvedCurrentIndex)
|
||||
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
|
||||
|
||||
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
|
||||
|
||||
if (!previousCurrent) {
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [nextCurrent];
|
||||
}
|
||||
|
||||
if (variant === 'ios') {
|
||||
if (direction === 'forward') {
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
|
||||
|
||||
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
|
||||
return [...previousStack, exitingLayer, nextCurrent];
|
||||
}
|
||||
|
||||
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
|
||||
if (targetIndex !== -1) {
|
||||
const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => {
|
||||
const isTarget = idx === targetIndex;
|
||||
return {
|
||||
...layer,
|
||||
location: isTarget ? location : layer.location,
|
||||
status: isTarget ? 'current' : 'stacked',
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = targetStack;
|
||||
return targetStack;
|
||||
}
|
||||
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
nextLayersRef.current = targetStack;
|
||||
return [...targetStack, exitingLayer];
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [nextCurrent];
|
||||
}
|
||||
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [exitingLayer, nextCurrent];
|
||||
});
|
||||
setIsAnimating(true);
|
||||
}, [
|
||||
isAnimating,
|
||||
location,
|
||||
currentLayerKey,
|
||||
currentLayerPathname,
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
resolveScrollContainer,
|
||||
layers,
|
||||
]);
|
||||
|
||||
// Run Motion animation when animating starts
|
||||
useLayoutEffect(() => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
if (!currentLayerRef.current) return;
|
||||
|
||||
const currentLayerEl = currentLayerRef.current;
|
||||
const exitingLayerEl = exitingLayerRef.current;
|
||||
const transitionVariant = transitionVariantRef.current;
|
||||
|
||||
clearLayerStyles(currentLayerEl);
|
||||
clearLayerStyles(exitingLayerEl);
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||
const enterScrollOffset = enterScrollOffsetRef.current;
|
||||
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
|
||||
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
const transitionDirection = transitionDirectionRef.current;
|
||||
const isForward = transitionDirection === 'forward';
|
||||
const enterFromY = isForward ? VERTICAL_ENTER_DISTANCE : -VERTICAL_ENTER_DISTANCE;
|
||||
const exitToY = isForward ? -VERTICAL_EXIT_DISTANCE : VERTICAL_EXIT_DISTANCE;
|
||||
const exitBaseY = enterScrollOffset - exitScrollOffset;
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
const activeAnimations: AnimationPlaybackControlsWithThen[] = [];
|
||||
let cancelled = false;
|
||||
let completed = false;
|
||||
const completeTransition = () => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
|
||||
const nextLayers = nextLayersRef.current;
|
||||
nextLayersRef.current = null;
|
||||
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||
setIsAnimating(false);
|
||||
|
||||
clearLayerStyles(currentLayerEl);
|
||||
clearLayerStyles(exitingLayerEl);
|
||||
};
|
||||
|
||||
if (reduceMotion) {
|
||||
// Accessibility: skip spatial motion entirely, fall back to a quick crossfade.
|
||||
if (exitingLayerEl) {
|
||||
exitingLayerEl.style.transform =
|
||||
transitionVariant === 'ios'
|
||||
? buildIosTransform(0, exitBaseY)
|
||||
: buildVerticalTransform(exitBaseY);
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
exitingLayerEl,
|
||||
{ opacity: [1, 0] },
|
||||
{ duration: REDUCED_MOTION_DURATION, ease: easeOutCubic }
|
||||
)
|
||||
);
|
||||
}
|
||||
currentLayerEl.style.opacity = '0';
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
currentLayerEl,
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: REDUCED_MOTION_DURATION, ease: easeOutCubic }
|
||||
)
|
||||
);
|
||||
} else if (transitionVariant === 'ios') {
|
||||
const exitToXPercent = isForward
|
||||
? IOS_EXIT_TO_X_PERCENT_FORWARD
|
||||
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
|
||||
const enterFromXPercent = isForward
|
||||
? IOS_ENTER_FROM_X_PERCENT
|
||||
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
|
||||
|
||||
// Background layer (the one being pushed back / coming forward from behind) gets
|
||||
// scale + opacity dim to read as "behind". Top layer is the one sliding fully on/off.
|
||||
const exitScaleTo = isForward ? IOS_BACKGROUND_SCALE : 1;
|
||||
const exitOpacityTo = isForward ? IOS_BACKGROUND_OPACITY : 1;
|
||||
const enterScaleFrom = isForward ? 1 : IOS_BACKGROUND_SCALE;
|
||||
const enterOpacityFrom = isForward ? 1 : IOS_BACKGROUND_OPACITY;
|
||||
|
||||
if (exitingLayerEl) {
|
||||
exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY, 1);
|
||||
exitingLayerEl.style.opacity = '1';
|
||||
}
|
||||
|
||||
currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0, enterScaleFrom);
|
||||
currentLayerEl.style.opacity = String(enterOpacityFrom);
|
||||
|
||||
// Shadow sits on whichever layer is visually in front of the other during the slide.
|
||||
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
|
||||
if (topLayerEl) {
|
||||
topLayerEl.style.boxShadow = IOS_SHADOW_VALUE;
|
||||
}
|
||||
|
||||
if (exitingLayerEl) {
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
exitingLayerEl,
|
||||
{
|
||||
transform: [
|
||||
buildIosTransform(0, exitBaseY, 1),
|
||||
buildIosTransform(exitToXPercent, exitBaseY, exitScaleTo),
|
||||
],
|
||||
opacity: [1, exitOpacityTo],
|
||||
},
|
||||
{
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: easeOutCubic,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
currentLayerEl,
|
||||
{
|
||||
transform: [
|
||||
buildIosTransform(enterFromXPercent, 0, enterScaleFrom),
|
||||
buildIosTransform(0, 0, 1),
|
||||
],
|
||||
opacity: [enterOpacityFrom, 1],
|
||||
},
|
||||
{
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: easeOutCubic,
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Vertical: split timing — exit leaves quickly (accelerate), enter settles slowly (decelerate).
|
||||
if (exitingLayerEl) {
|
||||
exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY);
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
exitingLayerEl,
|
||||
{
|
||||
transform: [
|
||||
buildVerticalTransform(exitBaseY),
|
||||
buildVerticalTransform(exitBaseY + exitToY),
|
||||
],
|
||||
opacity: [1, 0],
|
||||
},
|
||||
{
|
||||
duration: VERTICAL_EXIT_DURATION,
|
||||
ease: easeInQuad,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
currentLayerEl.style.transform = buildVerticalTransform(enterFromY);
|
||||
currentLayerEl.style.opacity = '0';
|
||||
activeAnimations.push(
|
||||
animate(
|
||||
currentLayerEl,
|
||||
{
|
||||
transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)],
|
||||
opacity: [0, 1],
|
||||
},
|
||||
{
|
||||
duration: VERTICAL_ENTER_DURATION,
|
||||
ease: easeOutQuart,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeAnimations.length) {
|
||||
completeTransition();
|
||||
} else {
|
||||
void Promise.all(
|
||||
activeAnimations.map((animation) => animation.finished.catch(() => undefined))
|
||||
).then(() => {
|
||||
if (cancelled) return;
|
||||
completeTransition();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
activeAnimations.forEach((animation) => animation.stop());
|
||||
};
|
||||
}, [isAnimating, resolveScrollContainer]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
{(() => {
|
||||
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
|
||||
const keepStackedIndex = layers
|
||||
.slice(0, resolvedCurrentIndex)
|
||||
.map((layer, index) => ({ layer, index }))
|
||||
.reverse()
|
||||
.find(({ layer }) => layer.status === 'stacked')?.index;
|
||||
|
||||
return layers.map((layer, index) => {
|
||||
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
||||
return (
|
||||
<div
|
||||
key={layer.key}
|
||||
className={[
|
||||
'page-transition__layer',
|
||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden={layer.status !== 'current'}
|
||||
inert={layer.status !== 'current'}
|
||||
ref={
|
||||
layer.status === 'exiting'
|
||||
? exitingLayerRef
|
||||
: layer.status === 'current'
|
||||
? currentLayerRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PageTransitionLayerContext.Provider
|
||||
value={{
|
||||
...PAGE_TRANSITION_LAYER_CONTEXT_VALUES[layer.status],
|
||||
isAnimating,
|
||||
}}
|
||||
>
|
||||
{render(layer.location)}
|
||||
</PageTransitionLayerContext.Provider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/common/PageTransitionLayer.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
|
||||
export type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
isCurrentLayer: boolean;
|
||||
isAnimating: boolean;
|
||||
};
|
||||
|
||||
export const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export const PAGE_TRANSITION_LAYER_CONTEXT_VALUES: Record<
|
||||
LayerStatus,
|
||||
PageTransitionLayerContextValue
|
||||
> = {
|
||||
current: { status: 'current', isCurrentLayer: true, isAnimating: false },
|
||||
stacked: { status: 'stacked', isCurrentLayer: false, isAnimating: false },
|
||||
exiting: { status: 'exiting', isCurrentLayer: false, isAnimating: false },
|
||||
};
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
return useContext(PageTransitionLayerContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
@use '../../styles/variables' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.topBarTitle {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
padding-left: 6px;
|
||||
padding-right: 10px;
|
||||
justify-self: start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.backButton > span:last-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.backText {
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.rightSlot {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.loadingState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-2xl 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
77
frontend/src/components/common/SecondaryScreenShell.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { forwardRef, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconChevronLeft } from '@/components/ui/icons';
|
||||
import styles from './SecondaryScreenShell.module.scss';
|
||||
|
||||
export type SecondaryScreenShellProps = {
|
||||
title: ReactNode;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
backAriaLabel?: string;
|
||||
rightAction?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
|
||||
function SecondaryScreenShell(
|
||||
{
|
||||
title,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
backAriaLabel,
|
||||
rightAction,
|
||||
isLoading = false,
|
||||
loadingLabel = 'Loading...',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
children,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
|
||||
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
|
||||
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||
|
||||
return (
|
||||
<div className={containerClassName} ref={ref}>
|
||||
<div className={styles.topBar}>
|
||||
{onBack ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className={styles.backButton}
|
||||
aria-label={resolvedBackAriaLabel}
|
||||
>
|
||||
<span className={styles.backIcon}>
|
||||
<IconChevronLeft size={18} />
|
||||
</span>
|
||||
<span className={styles.backText}>{backLabel}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.rightSlot}>{rightAction}</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{loadingLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={contentClasses}>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
.chipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 4px 5px 4px 9px;
|
||||
border-radius: $radius-full;
|
||||
color: var(--text-primary);
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 显式勾选:实线 + primary 染色,读起来是「我选的」。 */
|
||||
.exact {
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-primary));
|
||||
}
|
||||
|
||||
/* 规则派生:虚线 = 「不是逐个挑的,是某条规则算出来的」。 */
|
||||
.wildcard {
|
||||
border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 目录外的精确规则:同样虚线,但更弱——它指向一个我们无法确认存在的模型。 */
|
||||
.unknown {
|
||||
border: 1px dashed var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.remove {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.remove {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.remove:active:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import styles from './ExcludedModelRuleChip.module.scss';
|
||||
|
||||
/**
|
||||
* 排除项 chip —— 按**来源**区分三种形态,取代两处近乎重复的手写标记
|
||||
* (`AuthFileDetailsSheet.module.scss` 的 `.excludedModelChip` 与
|
||||
* `AuthFilesOAuthExcludedEditPage.module.scss` 的 `.customRuleChip`)。
|
||||
*
|
||||
* - `exact` 实线 primary 染色:用户显式勾选的模型,可直接移除。
|
||||
* - `wildcard` 虚线:由通配符规则派生出的模型。没有 ✕——要移除得去改那条规则,
|
||||
* 直接给个 ✕ 会承诺一件它做不到的事。
|
||||
* - `unknown` 虚线弱化:精确规则但目录里没有(如已下线的模型 id),可移除。
|
||||
*/
|
||||
export type ExcludedModelChipVariant = 'exact' | 'wildcard' | 'unknown';
|
||||
|
||||
export interface ExcludedModelRuleChipProps {
|
||||
label: string;
|
||||
variant?: ExcludedModelChipVariant;
|
||||
/** 次要说明,例如派生该 chip 的规则。 */
|
||||
detail?: string;
|
||||
/** 省略即不渲染 ✕。 */
|
||||
onRemove?: () => void;
|
||||
removeAriaLabel?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** chip 的换行容器。单独导出,免得每个消费方各写一遍 flex-wrap。 */
|
||||
export function ExcludedModelChipRow({ children }: { children: ReactNode }) {
|
||||
return <div className={styles.chipRow}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ExcludedModelRuleChip({
|
||||
label,
|
||||
variant = 'exact',
|
||||
detail,
|
||||
onRemove,
|
||||
removeAriaLabel,
|
||||
disabled = false,
|
||||
title,
|
||||
}: ExcludedModelRuleChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.chip} ${styles[variant]}`}
|
||||
title={title ?? (detail ? `${label} — ${detail}` : label)}
|
||||
>
|
||||
<span className={styles.label}>{label}</span>
|
||||
{detail ? <span className={styles.detail}>{detail}</span> : null}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.remove}
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
aria-label={removeAriaLabel ?? label}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/excludedModels/ExcludedModelsPanel.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconCheck, IconSearch } from '@/components/ui/icons';
|
||||
import {
|
||||
getModelExclusionState,
|
||||
type ExclusionStats,
|
||||
type ModelExclusionState,
|
||||
} from './excludedModelRules';
|
||||
import styles from './ExcludedModelsPicker.module.scss';
|
||||
|
||||
export interface ExcludedModelCandidate {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface ExcludedModelsPanelProps {
|
||||
rules: readonly string[];
|
||||
candidates: readonly ExcludedModelCandidate[];
|
||||
/** 由 Picker 算好传下来,避免在 footer 里把整个目录再扫一遍。 */
|
||||
stats: ExclusionStats;
|
||||
onToggle: (modelId: string, excluded: boolean) => void;
|
||||
onSelectAll: () => void;
|
||||
onClear: () => void;
|
||||
disabled: boolean;
|
||||
listboxId: string;
|
||||
/** 展开后是否把焦点送进搜索框(键盘展开时为 true,鼠标点开时也为 true)。 */
|
||||
autoFocus: boolean;
|
||||
/** 收起面板并把焦点还给 trigger。 */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
const matchesQuery = (candidate: ExcludedModelCandidate, query: string): boolean =>
|
||||
candidate.id.toLowerCase().includes(query) ||
|
||||
(candidate.displayName ?? '').toLowerCase().includes(query);
|
||||
|
||||
export function ExcludedModelsPanel({
|
||||
rules,
|
||||
candidates,
|
||||
stats,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
disabled,
|
||||
listboxId,
|
||||
autoFocus,
|
||||
onDismiss,
|
||||
}: ExcludedModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return candidates;
|
||||
return candidates.filter((candidate) => matchesQuery(candidate, normalized));
|
||||
}, [candidates, query]);
|
||||
|
||||
// 高亮永远钳在可见范围内:过滤后列表变短,旧索引会指向不存在的行。
|
||||
const activeIndex = visible.length === 0 ? -1 : Math.min(highlight, visible.length - 1);
|
||||
const activeId = activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!autoFocus) return;
|
||||
// preventScroll:裸 focus() 会把外层 Sheet 的滚动猛拽过来,动画中途还会把面板顶出视野。
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
}, [autoFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || activeIndex < 0) return;
|
||||
document
|
||||
.getElementById(`${listboxId}-opt-${activeIndex}`)
|
||||
?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex, autoFocus, listboxId]);
|
||||
|
||||
const toggleAt = (index: number) => {
|
||||
const candidate = visible[index];
|
||||
if (!candidate || disabled) return;
|
||||
const current = getModelExclusionState(rules, candidate.id);
|
||||
// 纯通配符命中的行不可直接切换——它的排除权属于那条规则。行内副文本常驻解释原因。
|
||||
if (current.state === 'excluded' && current.by === 'wildcard') return;
|
||||
onToggle(candidate.id, current.state !== 'excluded');
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setHighlight((prev) => Math.min(prev + 1, visible.length - 1));
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setHighlight((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
case 'Home':
|
||||
if (visible.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlight(0);
|
||||
return;
|
||||
case 'End':
|
||||
if (visible.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlight(visible.length - 1);
|
||||
return;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0) toggleAt(activeIndex);
|
||||
return;
|
||||
case 'Escape':
|
||||
// 外层 Sheet 在 document 上、OAuth 页在 window 上都听 Escape。
|
||||
// 不拦住就会「关面板 = 关 Sheet / 离开页面 + 触发未保存弹窗」。
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (query) {
|
||||
setQuery('');
|
||||
setHighlight(0);
|
||||
return;
|
||||
}
|
||||
onDismiss();
|
||||
return;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.searchRow}>
|
||||
<IconSearch size={14} className={styles.searchIcon} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.search}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setHighlight(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('excluded_models.search_placeholder')}
|
||||
aria-label={t('excluded_models.search_aria')}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeId}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={listboxId}
|
||||
className={styles.list}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label={t('excluded_models.list_aria')}
|
||||
>
|
||||
{visible.length === 0 ? (
|
||||
<p className={styles.noResults}>
|
||||
{query.trim()
|
||||
? t('excluded_models.no_results', { query: query.trim() })
|
||||
: t('excluded_models.catalog_empty')}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((candidate, index) => (
|
||||
<ExcludedModelRow
|
||||
key={candidate.id.toLowerCase()}
|
||||
id={`${listboxId}-opt-${index}`}
|
||||
candidate={candidate}
|
||||
state={getModelExclusionState(rules, candidate.id)}
|
||||
highlighted={index === activeIndex}
|
||||
onHover={() => setHighlight(index)}
|
||||
onToggle={() => toggleAt(index)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.footerCount}>
|
||||
{t('excluded_models.footer_count', { excluded: stats.excluded, total: stats.total })}
|
||||
</span>
|
||||
<span className={styles.footerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerButton}
|
||||
onClick={onSelectAll}
|
||||
disabled={disabled || candidates.length === 0}
|
||||
>
|
||||
{t('excluded_models.select_all')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.footerButton}
|
||||
onClick={onClear}
|
||||
disabled={disabled}
|
||||
aria-label={t('excluded_models.clear_aria')}
|
||||
>
|
||||
{t('excluded_models.clear')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExcludedModelRowProps {
|
||||
id: string;
|
||||
candidate: ExcludedModelCandidate;
|
||||
state: ModelExclusionState;
|
||||
highlighted: boolean;
|
||||
onHover: () => void;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function ExcludedModelRow({
|
||||
id,
|
||||
candidate,
|
||||
state,
|
||||
highlighted,
|
||||
onHover,
|
||||
onToggle,
|
||||
}: ExcludedModelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const excluded = state.state === 'excluded';
|
||||
const lockedByRule = state.state === 'excluded' && state.by === 'wildcard';
|
||||
// 把「哪条规则、用哪句话解释」在一处收敛好,下面的 JSX 就不必再做类型收窄。
|
||||
const wildcardReason =
|
||||
state.state === 'excluded' && (state.by === 'wildcard' || state.by === 'both')
|
||||
? {
|
||||
rule: state.rule,
|
||||
text:
|
||||
state.by === 'wildcard'
|
||||
? t('excluded_models.wildcard_locked', { rule: state.rule })
|
||||
: t('excluded_models.also_wildcard', { rule: state.rule }),
|
||||
muted: state.by === 'both',
|
||||
}
|
||||
: null;
|
||||
|
||||
const rowClass = [
|
||||
styles.row,
|
||||
excluded ? styles.rowExcluded : '',
|
||||
lockedByRule ? styles.rowLocked : '',
|
||||
highlighted ? styles.rowHighlighted : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="option"
|
||||
// 行永不进 tab 序:外层 Sheet 的焦点陷阱每次 Tab 都枚举全部可聚焦元素,
|
||||
// 几十个可聚焦的行会把它拖垮。漫游全靠 aria-activedescendant。
|
||||
tabIndex={-1}
|
||||
aria-selected={excluded}
|
||||
aria-disabled={lockedByRule || undefined}
|
||||
className={rowClass}
|
||||
onMouseEnter={onHover}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className={styles.checkbox} aria-hidden="true">
|
||||
{excluded ? <IconCheck size={12} /> : null}
|
||||
</span>
|
||||
<span className={styles.rowText}>
|
||||
<span className={styles.rowId}>{candidate.id}</span>
|
||||
{candidate.displayName && candidate.displayName !== candidate.id ? (
|
||||
<span className={styles.rowDisplayName}>{candidate.displayName}</span>
|
||||
) : null}
|
||||
{wildcardReason ? <span className={styles.rowReason}>{wildcardReason.text}</span> : null}
|
||||
</span>
|
||||
{wildcardReason ? (
|
||||
<span className={`${styles.badge} ${wildcardReason.muted ? styles.badgeMuted : ''}`.trim()}>
|
||||
{t('excluded_models.badge_wildcard')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Trigger —— 摘要 + 计量条,取代「把计数塞进 placeholder」 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.trigger {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
/* 整条 40px 宽元素上 0.97 太橡皮;0.99 足够被感知又不显廉价。 */
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.triggerText {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerSpinner {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
animation: excluded-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.triggerOpen .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 底边发丝计量条:零成本地长期回答「我到底排除了多少」。 */
|
||||
.meter {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.meterFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--primary-color) 70%, var(--text-primary));
|
||||
transition: width 360ms var(--ease-out-strong, ease);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 内联展开:grid 0fr→1fr。搜索框会在展开状态下过滤列表,每次击键都改高度, */
|
||||
/* grid 轨道自动重解,无需测量,也没有 ResizeObserver 要去跟击键搏斗。 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.disclosure {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
/* 退场更快 + 加速。themes.scss 无 --ease-in* token,这里用关键字而非发明全局 token。 */
|
||||
transition: grid-template-rows 120ms ease-in;
|
||||
}
|
||||
|
||||
.disclosureOpen {
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.disclosureInner {
|
||||
/* 必需:grid item 默认 min-height:auto,漏了它面板收不回去。 */
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
transform-origin: top;
|
||||
animation: excluded-panel-in var(--dur-hover, 200ms) var(--ease-out-strong, ease-out) both;
|
||||
}
|
||||
|
||||
/* 只写 from,让元素的静止样式定义终点(与 toolbar-popover-in 同一写法)。 */
|
||||
@keyframes excluded-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
/* 0.98 而非 0.95——面板是宽内联块,600px 下 0.95 是 30px 的横向蠕动。 */
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes excluded-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 搜索 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.searchRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 列表 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 260px;
|
||||
padding: 6px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 7px 8px;
|
||||
border-radius: $radius-sm;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.rowHighlighted {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.rowExcluded .checkbox {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 纯规则命中:压暗且不可切换,但仍可聚焦、仍会朗读原因。 */
|
||||
.rowLocked {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
background: var(--bg-primary);
|
||||
transition:
|
||||
background-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
|
||||
border-color var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rowId {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowDisplayName,
|
||||
.rowReason {
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border: 1px dashed color-mix(in srgb, var(--primary-color) 38%, var(--border-color));
|
||||
border-radius: $radius-full;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
border-style: dotted;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
margin: 0;
|
||||
padding: $spacing-lg $spacing-sm;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 吸底摘要 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.footerCount {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.footerButton {
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: $radius-sm;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 无目录降级 / 规则编辑器 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
.catalogNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-top: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-sm;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.chipsMore {
|
||||
align-self: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ruleEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ruleLabel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ruleMatches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--text-secondary);
|
||||
font-family: $font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
/* 零命中是 warning 不是 error:规则可以合法地指向目录不认识的模型。 */
|
||||
.ruleMatchNone {
|
||||
color: var(--warning-color, #{$warning-color});
|
||||
}
|
||||
|
||||
.ruleWarning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
color: var(--warning-color, #{$warning-color});
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.disclosure,
|
||||
.disclosureOpen {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.trigger,
|
||||
.chevron,
|
||||
.meterFill,
|
||||
.row,
|
||||
.checkbox,
|
||||
.footerButton,
|
||||
.retryButton {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.triggerSpinner {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.trigger:active:not(:disabled),
|
||||
.footerButton:active:not(:disabled),
|
||||
.retryButton:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
319
frontend/src/components/excludedModels/ExcludedModelsPicker.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconAlertTriangle, IconChevronDown, IconLoader2 } from '@/components/ui/icons';
|
||||
import { ExcludedModelChipRow, ExcludedModelRuleChip } from './ExcludedModelRuleChip';
|
||||
import { ExcludedModelsPanel, type ExcludedModelCandidate } from './ExcludedModelsPanel';
|
||||
import {
|
||||
formatExcludedRulesText,
|
||||
getModelExclusionState,
|
||||
matchedModelsByRule,
|
||||
normalizeExcludedRules,
|
||||
replaceCustomExcludedRules,
|
||||
splitExcludedRules,
|
||||
summarizeExclusion,
|
||||
toggleExcludedRule,
|
||||
} from './excludedModelRules';
|
||||
import styles from './ExcludedModelsPicker.module.scss';
|
||||
|
||||
export type { ExcludedModelCandidate };
|
||||
|
||||
export type ExcludedModelsCatalogState = 'ready' | 'loading' | 'unavailable' | 'error';
|
||||
|
||||
/** 派生 chip 的上限——超过这个数就只报总数,否则 chip 行会淹没整个字段。 */
|
||||
const DERIVED_CHIP_LIMIT = 8;
|
||||
|
||||
export interface ExcludedModelsPickerProps {
|
||||
/** 规范的规则列表。调用方内部存文本/Set 都行,在边界上适配一次即可。 */
|
||||
value: readonly string[];
|
||||
onChange: (next: string[]) => void;
|
||||
|
||||
candidates: readonly ExcludedModelCandidate[];
|
||||
catalogState?: ExcludedModelsCatalogState;
|
||||
onRetryCatalog?: () => void;
|
||||
|
||||
/** 真实禁用(未连接 / 保存中)。**绝不要**因为目录为空就传 true。 */
|
||||
disabled?: boolean;
|
||||
|
||||
/** picker 不得读写、也不许用户输入的规则。provider 表单传 `['*']`。 */
|
||||
reservedRules?: readonly string[];
|
||||
reservedRuleMessage?: string;
|
||||
|
||||
/** 关掉通配符规则编辑器。 */
|
||||
showRuleEditor?: boolean;
|
||||
|
||||
labelledBy?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExcludedModelsPicker({
|
||||
value,
|
||||
onChange,
|
||||
candidates,
|
||||
catalogState = 'ready',
|
||||
onRetryCatalog,
|
||||
disabled = false,
|
||||
reservedRules,
|
||||
reservedRuleMessage,
|
||||
showRuleEditor = true,
|
||||
labelledBy,
|
||||
className,
|
||||
}: ExcludedModelsPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const baseId = useId();
|
||||
const panelId = `${baseId}-panel`;
|
||||
const listboxId = `${baseId}-listbox`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reservedHit, setReservedHit] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const reservedKeys = useMemo(
|
||||
() => new Set((reservedRules ?? []).map((rule) => rule.trim().toLowerCase())),
|
||||
[reservedRules]
|
||||
);
|
||||
|
||||
/**
|
||||
* 保留规则在**入口**就被剥掉,因此 picker 内部从不见到它,也就不可能把它写回去。
|
||||
* provider 表单的 `'*'`(= 已停用)由 disabled 开关独占,排除面无权触碰。
|
||||
*/
|
||||
const rules = useMemo(
|
||||
() =>
|
||||
normalizeExcludedRules(value).filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())),
|
||||
[reservedKeys, value]
|
||||
);
|
||||
|
||||
const candidateIds = useMemo(() => candidates.map((c) => c.id), [candidates]);
|
||||
const stats = useMemo(() => summarizeExclusion(rules, candidateIds), [candidateIds, rules]);
|
||||
const { exactRules, unknownRules, customRules } = useMemo(
|
||||
() => splitExcludedRules(rules, candidateIds),
|
||||
[candidateIds, rules]
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(next: readonly string[]) => {
|
||||
// 出口再滤一次保留规则:纵深防御,规则编辑器里手打的 `*` 到不了调用方。
|
||||
onChange(next.filter((rule) => !reservedKeys.has(rule.trim().toLowerCase())));
|
||||
},
|
||||
[onChange, reservedKeys]
|
||||
);
|
||||
|
||||
const hasCatalog = catalogState === 'ready' && candidates.length > 0;
|
||||
|
||||
/** 通配符派生出的模型(排除掉已显式勾选的,那些走实线 chip)。 */
|
||||
const derivedModels = useMemo(() => {
|
||||
if (!hasCatalog) return [];
|
||||
const out: Array<{ id: string; rule: string }> = [];
|
||||
candidateIds.forEach((id) => {
|
||||
const state = getModelExclusionState(rules, id);
|
||||
if (state.state === 'excluded' && state.by === 'wildcard') out.push({ id, rule: state.rule });
|
||||
});
|
||||
return out;
|
||||
}, [candidateIds, hasCatalog, rules]);
|
||||
|
||||
const ruleSummaries = useMemo(
|
||||
() => (hasCatalog ? matchedModelsByRule(customRules, candidateIds) : []),
|
||||
[candidateIds, customRules, hasCatalog]
|
||||
);
|
||||
|
||||
const handleToggle = (modelId: string, excluded: boolean) =>
|
||||
commit(toggleExcludedRule(rules, modelId, excluded));
|
||||
|
||||
const handleSelectAll = () => commit(normalizeExcludedRules([...rules, ...candidateIds]));
|
||||
|
||||
/** 只清精确勾选,通配符规则留给它自己的编辑器——否则一次点击会抹掉用户手写的规则。 */
|
||||
const handleClear = () => commit(customRules);
|
||||
|
||||
const handleRuleEditorChange = (text: string) => {
|
||||
const typedReserved = text
|
||||
.split(/\r?\n/)
|
||||
.some((line) => reservedKeys.has(line.trim().toLowerCase()));
|
||||
setReservedHit(typedReserved);
|
||||
commit(replaceCustomExcludedRules(rules, candidateIds, text));
|
||||
};
|
||||
|
||||
const dismissPanel = useCallback(() => {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
const summaryText = () => {
|
||||
if (catalogState === 'loading') return t('excluded_models.catalog_loading');
|
||||
if (hasCatalog) {
|
||||
if (stats.excluded === 0 && rules.length === 0) return t('excluded_models.trigger_empty');
|
||||
return t('excluded_models.trigger_summary', {
|
||||
excluded: stats.excluded,
|
||||
available: stats.available,
|
||||
});
|
||||
}
|
||||
// 无目录:只能诚实地报规则条数,不能假装知道「还剩几个可用」。
|
||||
if (rules.length === 0) return t('excluded_models.trigger_empty');
|
||||
return t('excluded_models.trigger_summary_rules', { n: rules.length });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.root} ${className ?? ''}`.trim()}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`.trim()}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' && !open) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
} else if (event.key === 'Escape' && open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
aria-labelledby={labelledBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={styles.triggerText}>
|
||||
{catalogState === 'loading' ? (
|
||||
<IconLoader2 size={13} className={styles.triggerSpinner} aria-hidden="true" />
|
||||
) : null}
|
||||
{summaryText()}
|
||||
</span>
|
||||
<IconChevronDown size={14} className={styles.chevron} aria-hidden="true" />
|
||||
{hasCatalog ? (
|
||||
<span
|
||||
className={styles.meter}
|
||||
role="img"
|
||||
aria-label={t('excluded_models.meter_aria', {
|
||||
excluded: stats.excluded,
|
||||
total: stats.total,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={styles.meterFill}
|
||||
style={{ width: `${stats.total ? (stats.excluded / stats.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={panelId}
|
||||
className={`${styles.disclosure} ${open ? styles.disclosureOpen : ''}`.trim()}
|
||||
>
|
||||
<div className={styles.disclosureInner} inert={!open}>
|
||||
{catalogState === 'ready' || candidates.length > 0 ? (
|
||||
<ExcludedModelsPanel
|
||||
rules={rules}
|
||||
candidates={candidates}
|
||||
stats={stats}
|
||||
onToggle={handleToggle}
|
||||
onSelectAll={handleSelectAll}
|
||||
onClear={handleClear}
|
||||
disabled={disabled}
|
||||
listboxId={listboxId}
|
||||
autoFocus={open}
|
||||
onDismiss={dismissPanel}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.catalogNotice}>
|
||||
<span>
|
||||
{catalogState === 'loading'
|
||||
? t('excluded_models.catalog_loading')
|
||||
: catalogState === 'error'
|
||||
? t('excluded_models.catalog_error')
|
||||
: t('excluded_models.catalog_unavailable')}
|
||||
</span>
|
||||
{onRetryCatalog && catalogState !== 'loading' ? (
|
||||
<button type="button" className={styles.retryButton} onClick={onRetryCatalog}>
|
||||
{t('excluded_models.catalog_retry')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exactRules.length > 0 || derivedModels.length > 0 || unknownRules.length > 0 ? (
|
||||
<ExcludedModelChipRow>
|
||||
{exactRules.map((rule) => (
|
||||
<ExcludedModelRuleChip
|
||||
key={`exact-${rule.toLowerCase()}`}
|
||||
label={rule}
|
||||
variant="exact"
|
||||
onRemove={() => commit(toggleExcludedRule(rules, rule, false))}
|
||||
removeAriaLabel={t('excluded_models.chip_remove', { rule })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{derivedModels.slice(0, DERIVED_CHIP_LIMIT).map((item) => (
|
||||
<ExcludedModelRuleChip
|
||||
key={`derived-${item.id.toLowerCase()}`}
|
||||
label={item.id}
|
||||
variant="wildcard"
|
||||
detail={item.rule}
|
||||
title={t('excluded_models.wildcard_reason', { rule: item.rule })}
|
||||
/>
|
||||
))}
|
||||
{derivedModels.length > DERIVED_CHIP_LIMIT ? (
|
||||
<span className={styles.chipsMore}>
|
||||
{t('excluded_models.chips_more', { n: derivedModels.length - DERIVED_CHIP_LIMIT })}
|
||||
</span>
|
||||
) : null}
|
||||
{unknownRules.map((rule) => (
|
||||
<ExcludedModelRuleChip
|
||||
key={`unknown-${rule.toLowerCase()}`}
|
||||
label={rule}
|
||||
variant="unknown"
|
||||
detail={t('excluded_models.badge_unknown')}
|
||||
onRemove={() => commit(toggleExcludedRule(rules, rule, false))}
|
||||
removeAriaLabel={t('excluded_models.chip_remove', { rule })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</ExcludedModelChipRow>
|
||||
) : null}
|
||||
|
||||
{showRuleEditor ? (
|
||||
<div className={styles.ruleEditor}>
|
||||
<label className={styles.ruleLabel} htmlFor={`${baseId}-rules`}>
|
||||
{t('excluded_models.rules_label')}
|
||||
</label>
|
||||
<textarea
|
||||
id={`${baseId}-rules`}
|
||||
className="input"
|
||||
value={formatExcludedRulesText(customRules)}
|
||||
placeholder={t('excluded_models.rules_placeholder')}
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
onChange={(event) => handleRuleEditorChange(event.target.value)}
|
||||
/>
|
||||
|
||||
{reservedHit ? (
|
||||
<p className={styles.ruleWarning}>
|
||||
<IconAlertTriangle size={12} aria-hidden="true" />
|
||||
{reservedRuleMessage ?? t('excluded_models.rules_reserved')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasCatalog ? (
|
||||
<ul className={styles.ruleMatches}>
|
||||
{ruleSummaries.map((summary) => (
|
||||
<li
|
||||
key={summary.rule.toLowerCase()}
|
||||
className={summary.matchCount === 0 ? styles.ruleMatchNone : undefined}
|
||||
>
|
||||
<code>{summary.rule}</code>
|
||||
{summary.matchCount === 0
|
||||
? t('excluded_models.rules_match_none')
|
||||
: t('excluded_models.rules_match_count', { n: summary.matchCount })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<p className="hint">{t('excluded_models.rules_hint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
frontend/src/components/excludedModels/excludedModelRules.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* 排除模型规则 —— 唯一的纯逻辑源。
|
||||
*
|
||||
* 由两个已删除的模块合并而来:`excludedModelSelection.ts`(文本/数组式)与
|
||||
* `oauthExcludedRules.ts`(Set 式),二者曾是同一个领域模型写了两遍——
|
||||
* 其 normalize 实现逐字相同,只是一个吃换行文本、一个吃可迭代对象。
|
||||
*
|
||||
* 规则语义(与后端一致):
|
||||
* - 大小写不敏感;
|
||||
* - `*` 匹配任意字符,其余字符按字面量处理(`gpt-4.1` 里的 `.` 不是正则通配符);
|
||||
* - 去重按小写 key,但**保留首次出现的拼写**。
|
||||
*/
|
||||
|
||||
/** 后端「停用整个 provider」的编码。只属于 provider 表单的 disabled 开关,排除面永不产出它。 */
|
||||
export const DISABLE_ALL_RULE = '*';
|
||||
|
||||
const ruleKey = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
export const isWildcardRule = (rule: string): boolean => rule.includes('*');
|
||||
|
||||
export function normalizeExcludedRules(values: Iterable<string>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const rules: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const rule = value.trim();
|
||||
const key = ruleKey(rule);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rules.push(rule);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
export const parseExcludedRulesText = (text: string): string[] =>
|
||||
normalizeExcludedRules(text.split(/\r?\n/));
|
||||
|
||||
export const formatExcludedRulesText = (rules: readonly string[]): string => rules.join('\n');
|
||||
|
||||
export function matchesExcludedRule(rule: string, modelId: string): boolean {
|
||||
const normalizedRule = ruleKey(rule);
|
||||
const normalizedModel = ruleKey(modelId);
|
||||
if (!normalizedRule || !normalizedModel) return false;
|
||||
if (!isWildcardRule(normalizedRule)) return normalizedRule === normalizedModel;
|
||||
|
||||
// 按 `*` 切开,逐段转义正则元字符,再用 `.*` 接回——只有 `*` 是通配符。
|
||||
const escaped = normalizedRule
|
||||
.split('*')
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('.*');
|
||||
return new RegExp(`^${escaped}$`, 'i').test(normalizedModel);
|
||||
}
|
||||
|
||||
/** 该模型是否被某条**通配符**规则命中(精确规则不算)。 */
|
||||
export const isMatchedByWildcardRule = (rules: Iterable<string>, modelId: string): boolean =>
|
||||
Array.from(rules).some((rule) => isWildcardRule(rule) && matchesExcludedRule(rule, modelId));
|
||||
|
||||
/** 规则列表里是否存在与 candidate 字面相等(忽略大小写)的一条。不做通配符展开。 */
|
||||
export function hasExcludedRule(rules: Iterable<string>, candidate: string): boolean {
|
||||
const candidateKey = ruleKey(candidate);
|
||||
if (!candidateKey) return false;
|
||||
return Array.from(rules).some((rule) => ruleKey(rule) === candidateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增删一条字面规则。
|
||||
*
|
||||
* 注意:这里按 key 过滤,**不**豁免含 `*` 的规则。旧的 `toggleExcludedModel` 曾拒绝删除
|
||||
* 通配符规则,那是因为调用点有两个互不知情的写入面(列表管精确、textarea 管通配符)需要
|
||||
* 互不践踏。统一组件里两个面同属一个组件,该守卫属于组件而非纯函数。
|
||||
*/
|
||||
export function toggleExcludedRule(
|
||||
rules: Iterable<string>,
|
||||
candidate: string,
|
||||
excluded: boolean
|
||||
): string[] {
|
||||
const candidateRule = candidate.trim();
|
||||
const candidateKey = ruleKey(candidateRule);
|
||||
const next = normalizeExcludedRules(rules).filter((rule) => ruleKey(rule) !== candidateKey);
|
||||
|
||||
if (excluded && candidateKey) next.push(candidateRule);
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface SplitExcludedRules {
|
||||
/** 精确命中目录的规则,**改写为目录的拼写**(勾选框驱动,id 应当规范化)。 */
|
||||
exactRules: string[];
|
||||
/** 含 `*` 的规则,保留配置里的拼写。 */
|
||||
wildcardRules: string[];
|
||||
/** 精确但目录里没有的规则(如已下线的模型 id),保留配置里的拼写。 */
|
||||
unknownRules: string[];
|
||||
/** `wildcardRules ∪ unknownRules`,但按**原始出现顺序**——textarea 的内容与顺序敏感的 diff 都依赖它。 */
|
||||
customRules: string[];
|
||||
}
|
||||
|
||||
export function splitExcludedRules(
|
||||
rules: Iterable<string>,
|
||||
candidateIds: readonly string[]
|
||||
): SplitExcludedRules {
|
||||
const candidateByKey = new Map(candidateIds.map((id) => [ruleKey(id), id]));
|
||||
const exactRules: string[] = [];
|
||||
const wildcardRules: string[] = [];
|
||||
const unknownRules: string[] = [];
|
||||
const customRules: string[] = [];
|
||||
|
||||
normalizeExcludedRules(rules).forEach((rule) => {
|
||||
if (isWildcardRule(rule)) {
|
||||
wildcardRules.push(rule);
|
||||
customRules.push(rule);
|
||||
return;
|
||||
}
|
||||
const candidate = candidateByKey.get(ruleKey(rule));
|
||||
if (candidate) {
|
||||
exactRules.push(candidate);
|
||||
return;
|
||||
}
|
||||
unknownRules.push(rule);
|
||||
customRules.push(rule);
|
||||
});
|
||||
|
||||
return { exactRules, wildcardRules, unknownRules, customRules };
|
||||
}
|
||||
|
||||
/** 用一段文本整体替换「自定义」半边(通配符 + 目录外精确规则),保留精确勾选的那一半。 */
|
||||
export function replaceCustomExcludedRules(
|
||||
rules: Iterable<string>,
|
||||
candidateIds: readonly string[],
|
||||
text: string
|
||||
): string[] {
|
||||
const { exactRules } = splitExcludedRules(rules, candidateIds);
|
||||
return normalizeExcludedRules([...exactRules, ...parseExcludedRulesText(text)]);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 展示用派生量 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 单个模型的排除态。
|
||||
*
|
||||
* `both` 是最微妙的一档:模型既被显式勾选、又被某条通配符规则命中。取消勾选后它**依然
|
||||
* 被排除**,所以那一行不能在视觉上「取消打勾」,否则用户会以为点击失败。旧 UI 把这一档
|
||||
* 完全藏了起来。
|
||||
*/
|
||||
export type ModelExclusionState =
|
||||
| { state: 'included' }
|
||||
| { state: 'excluded'; by: 'exact' }
|
||||
| { state: 'excluded'; by: 'wildcard'; rule: string }
|
||||
| { state: 'excluded'; by: 'both'; rule: string };
|
||||
|
||||
export function getModelExclusionState(
|
||||
rules: readonly string[],
|
||||
modelId: string
|
||||
): ModelExclusionState {
|
||||
const modelKey = ruleKey(modelId);
|
||||
if (!modelKey) return { state: 'included' };
|
||||
|
||||
let hasExact = false;
|
||||
let wildcard: string | undefined;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (isWildcardRule(rule)) {
|
||||
if (wildcard === undefined && matchesExcludedRule(rule, modelId)) wildcard = rule;
|
||||
} else if (!hasExact && ruleKey(rule) === modelKey) {
|
||||
hasExact = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExact && wildcard !== undefined) return { state: 'excluded', by: 'both', rule: wildcard };
|
||||
if (hasExact) return { state: 'excluded', by: 'exact' };
|
||||
if (wildcard !== undefined) return { state: 'excluded', by: 'wildcard', rule: wildcard };
|
||||
return { state: 'included' };
|
||||
}
|
||||
|
||||
export const isModelExcluded = (rules: readonly string[], modelId: string): boolean =>
|
||||
getModelExclusionState(rules, modelId).state === 'excluded';
|
||||
|
||||
export interface RuleMatchSummary {
|
||||
rule: string;
|
||||
/** 该规则命中的目录模型,按目录顺序。 */
|
||||
matched: string[];
|
||||
matchCount: number;
|
||||
}
|
||||
|
||||
/** 每条规则各命中了目录里的哪些模型——通配符编辑器的实时反馈就靠它。 */
|
||||
export const matchedModelsByRule = (
|
||||
rules: readonly string[],
|
||||
candidateIds: readonly string[]
|
||||
): RuleMatchSummary[] =>
|
||||
rules.map((rule) => {
|
||||
const matched = candidateIds.filter((id) => matchesExcludedRule(rule, id));
|
||||
return { rule, matched, matchCount: matched.length };
|
||||
});
|
||||
|
||||
export interface ExclusionStats {
|
||||
total: number;
|
||||
excluded: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘要行与计量条的数据源。
|
||||
*
|
||||
* `excluded` 数的是**目录内被任意规则命中的模型数**,不是 `rules.length`——一条
|
||||
* `gpt-5-*` 可能命中 6 个模型,也可能一个都不命中。用规则数当分子会在新地方复刻
|
||||
* 旧 UI 那个谎言:分子分母必须同源,计量条才是诚实的。
|
||||
*/
|
||||
export function summarizeExclusion(
|
||||
rules: readonly string[],
|
||||
candidateIds: readonly string[]
|
||||
): ExclusionStats {
|
||||
const total = candidateIds.length;
|
||||
const excluded = candidateIds.reduce(
|
||||
(count, id) => (isModelExcluded(rules, id) ? count + 1 : count),
|
||||
0
|
||||
);
|
||||
return { total, excluded, available: total - excluded };
|
||||
}
|
||||
33
frontend/src/components/excludedModels/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export {
|
||||
ExcludedModelsPicker,
|
||||
type ExcludedModelCandidate,
|
||||
type ExcludedModelsCatalogState,
|
||||
type ExcludedModelsPickerProps,
|
||||
} from './ExcludedModelsPicker';
|
||||
export {
|
||||
ExcludedModelChipRow,
|
||||
ExcludedModelRuleChip,
|
||||
type ExcludedModelChipVariant,
|
||||
type ExcludedModelRuleChipProps,
|
||||
} from './ExcludedModelRuleChip';
|
||||
export {
|
||||
DISABLE_ALL_RULE,
|
||||
formatExcludedRulesText,
|
||||
getModelExclusionState,
|
||||
hasExcludedRule,
|
||||
isMatchedByWildcardRule,
|
||||
isModelExcluded,
|
||||
isWildcardRule,
|
||||
matchedModelsByRule,
|
||||
matchesExcludedRule,
|
||||
normalizeExcludedRules,
|
||||
parseExcludedRulesText,
|
||||
replaceCustomExcludedRules,
|
||||
splitExcludedRules,
|
||||
summarizeExclusion,
|
||||
toggleExcludedRule,
|
||||
type ExclusionStats,
|
||||
type ModelExclusionState,
|
||||
type RuleMatchSummary,
|
||||
type SplitExcludedRules,
|
||||
} from './excludedModelRules';
|
||||
1177
frontend/src/components/layout/MainLayout.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
@use '../../styles/variables' as *;
|
||||
|
||||
.scrollContainer {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tapHint {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
min-height: 300px;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
user-select: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
|
||||
min-width: max(100%, 960px);
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// SVG layer for connection lines (behind columns so links are visible)
|
||||
.connections {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.providers {
|
||||
align-items: flex-end;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&.sources {
|
||||
align-items: flex-start;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&.aliases {
|
||||
align-items: flex-start;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&.dropTarget {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--primary-color);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 2px rgba($primary-color, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
// Mindmap-style provider branch (root node)
|
||||
.providerItem {
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.providerLabel {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronRight {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
border-width: 5px 4px 0 4px;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
}
|
||||
|
||||
.chevronRight {
|
||||
border-width: 4px 0 4px 5px;
|
||||
border-color: transparent transparent transparent currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.providerGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sourceItem,
|
||||
.aliasItem {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -3px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.dotLeft {
|
||||
left: -3px;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.sourceItem .dot {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemCount {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.contextMenu {
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
|
||||
.menuItem {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-error-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menuDivider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border-color);
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsEmpty {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: $spacing-lg 0;
|
||||
}
|
||||
|
||||
.settingsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) auto;
|
||||
gap: $spacing-md;
|
||||
align-items: center;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsNames {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settingsSource,
|
||||
.settingsAlias {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.settingsArrow {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settingsActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.settingsLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settingsDelete {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--error-color);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-error-light);
|
||||
}
|
||||
}
|
||||
700
frontend/src/components/modelAlias/ModelMappingDiagram.tsx
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { OAuthModelAliasEntry } from '@/types';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
|
||||
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
|
||||
import {
|
||||
AddAliasModal,
|
||||
RenameAliasModal,
|
||||
SettingsAliasModal,
|
||||
SettingsSourceModal,
|
||||
} from './ModelMappingDiagramModals';
|
||||
import type {
|
||||
AliasNode,
|
||||
AuthFileModelItem,
|
||||
ContextMenuState,
|
||||
DiagramLine,
|
||||
SourceNode,
|
||||
} from './ModelMappingDiagramTypes';
|
||||
import { hasModelAliasConflict } from './aliasValidation';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
export interface ModelMappingDiagramProps {
|
||||
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||
allProviderModels?: Record<string, AuthFileModelItem[]>;
|
||||
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
|
||||
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
|
||||
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
|
||||
onDeleteAlias?: (alias: string) => void;
|
||||
onEditProvider?: (provider: string) => void;
|
||||
onDeleteProvider?: (provider: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_COLORS = [
|
||||
'#8b8680',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#c65746',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#84cc16',
|
||||
];
|
||||
|
||||
function getProviderColor(provider: string): string {
|
||||
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
|
||||
}
|
||||
|
||||
export interface ModelMappingDiagramRef {
|
||||
collapseAll: () => void;
|
||||
refreshLayout: () => void;
|
||||
}
|
||||
|
||||
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(
|
||||
function ModelMappingDiagram(
|
||||
{
|
||||
modelAlias,
|
||||
allProviderModels = {},
|
||||
onUpdate,
|
||||
onDeleteLink,
|
||||
onToggleFork,
|
||||
onRenameAlias,
|
||||
onDeleteAlias,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const enableTapLinking = useMemo(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
|
||||
return (
|
||||
window.matchMedia('(any-pointer: coarse)').matches &&
|
||||
!window.matchMedia('(any-pointer: fine)').matches
|
||||
);
|
||||
}, []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = useState<DiagramLine[]>([]);
|
||||
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
|
||||
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
|
||||
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
|
||||
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
|
||||
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
|
||||
const [tapAlias, setTapAlias] = useState<string | null>(null);
|
||||
const [extraAliases, setExtraAliases] = useState<string[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
|
||||
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
|
||||
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
const [addAliasOpen, setAddAliasOpen] = useState(false);
|
||||
const [addAliasValue, setAddAliasValue] = useState('');
|
||||
const [addAliasError, setAddAliasError] = useState('');
|
||||
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
|
||||
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
|
||||
|
||||
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
|
||||
const { aliasNodes, providerNodes } = useMemo(() => {
|
||||
const sourceMap = new Map<
|
||||
string,
|
||||
{ provider: string; name: string; aliases: Map<string, boolean> }
|
||||
>();
|
||||
const aliasSet = new Set<string>();
|
||||
|
||||
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
|
||||
Object.entries(modelAlias).forEach(([provider, mappings]) => {
|
||||
(mappings ?? []).forEach((m) => {
|
||||
const name = (m?.name || '').trim();
|
||||
const alias = (m?.alias || '').trim();
|
||||
if (!name || !alias) return;
|
||||
|
||||
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||
if (!sourceMap.has(pk)) {
|
||||
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||
}
|
||||
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
|
||||
aliasSet.add(alias);
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Unmapped models from allProviderModels (no mapping yet)
|
||||
Object.entries(allProviderModels).forEach(([provider, models]) => {
|
||||
(models ?? []).forEach((m) => {
|
||||
const name = (m.id || '').trim();
|
||||
if (!name) return;
|
||||
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||
if (sourceMap.has(pk)) {
|
||||
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
|
||||
return;
|
||||
}
|
||||
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Source nodes: distinct by id = provider::name
|
||||
const sources: SourceNode[] = Array.from(sourceMap.entries())
|
||||
.map(([id, v]) => ({
|
||||
id,
|
||||
provider: v.provider,
|
||||
name: v.name,
|
||||
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork })),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// 4. Extra aliases (no mapping yet)
|
||||
extraAliases.forEach((alias) => aliasSet.add(alias));
|
||||
|
||||
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
|
||||
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
|
||||
.map((alias) => ({
|
||||
id: alias,
|
||||
alias,
|
||||
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias)),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
|
||||
return a.alias.localeCompare(b.alias);
|
||||
});
|
||||
|
||||
// 6. Group sources by provider
|
||||
const providerMap = new Map<string, SourceNode[]>();
|
||||
sources.forEach((s) => {
|
||||
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
|
||||
providerMap.get(s.provider)!.push(s);
|
||||
});
|
||||
const providerNodesList = Array.from(providerMap.entries())
|
||||
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
|
||||
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||
|
||||
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
|
||||
}, [modelAlias, allProviderModels, extraAliases]);
|
||||
|
||||
// Track element positions
|
||||
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const toggleProviderCollapse = (provider: string) => {
|
||||
setCollapsedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(provider)) next.delete(provider);
|
||||
else next.add(provider);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
|
||||
const updateLines = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newLines: { path: string; color: string; id: string }[] = [];
|
||||
const nextProviderGroupHeights: Record<string, number> = {};
|
||||
|
||||
const bezier = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
const cpx1 = x1 + (x2 - x1) * 0.5;
|
||||
const cpx2 = x2 - (x2 - x1) * 0.5;
|
||||
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
|
||||
};
|
||||
|
||||
providerNodes.forEach(({ provider, sources }) => {
|
||||
const collapsed = collapsedProviders.has(provider);
|
||||
if (collapsed) return;
|
||||
|
||||
if (sources.length > 0) {
|
||||
const firstEl = sourceRefs.current.get(sources[0].id);
|
||||
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
|
||||
if (firstEl && lastEl) {
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top
|
||||
)
|
||||
);
|
||||
if (height > 0) nextProviderGroupHeights[provider] = height;
|
||||
}
|
||||
}
|
||||
|
||||
const providerEl = providerRefs.current.get(provider);
|
||||
if (!providerEl) return;
|
||||
const providerRect = providerEl.getBoundingClientRect();
|
||||
const px = providerRect.right - containerRect.left;
|
||||
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
|
||||
const color = getProviderColor(provider);
|
||||
|
||||
// Provider → Source (branch link, no dot)
|
||||
sources.forEach((source) => {
|
||||
const sourceEl = sourceRefs.current.get(source.id);
|
||||
if (!sourceEl) return;
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
const sx = sourceRect.left - containerRect.left;
|
||||
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||
newLines.push({
|
||||
id: `provider-${provider}-source-${source.id}`,
|
||||
path: bezier(px, py, sx, sy),
|
||||
color,
|
||||
});
|
||||
});
|
||||
// Source → Alias: one line per alias
|
||||
sources.forEach((source) => {
|
||||
if (!source.aliases || source.aliases.length === 0) return;
|
||||
|
||||
source.aliases.forEach((aliasEntry) => {
|
||||
const sourceEl = sourceRefs.current.get(source.id);
|
||||
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
|
||||
if (!sourceEl || !aliasEl) return;
|
||||
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
const aliasRect = aliasEl.getBoundingClientRect();
|
||||
|
||||
// Calculate coordinates relative to the container
|
||||
const x1 = sourceRect.right - containerRect.left;
|
||||
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||
const x2 = aliasRect.left - containerRect.left;
|
||||
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
|
||||
|
||||
newLines.push({
|
||||
id: `${source.id}-${aliasEntry.alias}`,
|
||||
path: bezier(x1, y1, x2, y2),
|
||||
color,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setLines(newLines);
|
||||
setProviderGroupHeights((prev) => {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(nextProviderGroupHeights);
|
||||
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
|
||||
for (const key of nextKeys) {
|
||||
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
|
||||
return nextProviderGroupHeights;
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [providerNodes, collapsedProviders]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
|
||||
refreshLayout: () => updateLines(),
|
||||
}),
|
||||
[providerNodes, updateLines]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
window.addEventListener('resize', updateLines);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', updateLines);
|
||||
};
|
||||
}, [updateLines, aliasNodes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [providerGroupHeights, updateLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => updateLines());
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [updateLines]);
|
||||
|
||||
// Drag and Drop handlers
|
||||
// 1. Source -> Alias
|
||||
const handleDragStart = (e: DragEvent, source: SourceNode) => {
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
setDraggedSource(source);
|
||||
e.dataTransfer.setData('text/plain', source.id);
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent, alias: string) => {
|
||||
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
|
||||
e.preventDefault(); // Allow drop
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setDropTargetAlias(alias);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDropTargetAlias(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, alias: string) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedSource &&
|
||||
!draggedSource.aliases.some((entry) => entry.alias === alias) &&
|
||||
onUpdate
|
||||
) {
|
||||
onUpdate(draggedSource.provider, draggedSource.name, alias);
|
||||
}
|
||||
setDraggedSource(null);
|
||||
setDropTargetAlias(null);
|
||||
};
|
||||
|
||||
// 2. Alias -> Source
|
||||
const handleDragStartAlias = (e: DragEvent, alias: string) => {
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
setDraggedAlias(alias);
|
||||
e.dataTransfer.setData('text/plain', alias);
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
|
||||
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setDropTargetSource(source.id);
|
||||
};
|
||||
|
||||
const handleDragLeaveSource = () => {
|
||||
setDropTargetSource(null);
|
||||
};
|
||||
|
||||
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
draggedAlias &&
|
||||
!source.aliases.some((entry) => entry.alias === draggedAlias) &&
|
||||
onUpdate
|
||||
) {
|
||||
onUpdate(source.provider, source.name, draggedAlias);
|
||||
}
|
||||
setDraggedAlias(null);
|
||||
setDropTargetSource(null);
|
||||
};
|
||||
|
||||
const handleContextMenu = (
|
||||
e: ReactMouseEvent,
|
||||
type: 'alias' | 'background' | 'provider' | 'source',
|
||||
data?: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type,
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const resolveSourceById = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id) return null;
|
||||
for (const { sources } of providerNodes) {
|
||||
const found = sources.find((source) => source.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[providerNodes]
|
||||
);
|
||||
|
||||
const handleTapSelectSource = (source: SourceNode) => {
|
||||
if (!onUpdate) return;
|
||||
if (tapSourceId === source.id) {
|
||||
setTapSourceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tapAlias) {
|
||||
onUpdate(source.provider, source.name, tapAlias);
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTapSourceId(source.id);
|
||||
setTapAlias(null);
|
||||
};
|
||||
|
||||
const handleTapSelectAlias = (alias: string) => {
|
||||
if (!onUpdate) return;
|
||||
if (tapAlias === alias) {
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tapSourceId) {
|
||||
const source = resolveSourceById(tapSourceId);
|
||||
if (source) {
|
||||
onUpdate(source.provider, source.name, alias);
|
||||
}
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTapAlias(alias);
|
||||
setTapSourceId(null);
|
||||
};
|
||||
|
||||
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
|
||||
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
|
||||
};
|
||||
|
||||
const handleToggleFork = (
|
||||
provider: string,
|
||||
sourceModel: string,
|
||||
alias: string,
|
||||
value: boolean
|
||||
) => {
|
||||
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
|
||||
};
|
||||
|
||||
const handleAddAlias = () => {
|
||||
closeContextMenu();
|
||||
setAddAliasOpen(true);
|
||||
setAddAliasValue('');
|
||||
setAddAliasError('');
|
||||
};
|
||||
|
||||
const handleAddAliasSubmit = () => {
|
||||
const trimmed = addAliasValue.trim();
|
||||
if (!trimmed) {
|
||||
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
hasModelAliasConflict(
|
||||
aliasNodes.map((alias) => alias.alias),
|
||||
trimmed
|
||||
)
|
||||
) {
|
||||
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
|
||||
return;
|
||||
}
|
||||
setExtraAliases((prev) => [...prev, trimmed]);
|
||||
setAddAliasOpen(false);
|
||||
};
|
||||
|
||||
const handleRenameClick = (oldAlias: string) => {
|
||||
closeContextMenu();
|
||||
setRenameState({ oldAlias });
|
||||
setRenameValue(oldAlias);
|
||||
setRenameError('');
|
||||
};
|
||||
|
||||
const handleRenameSubmit = () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === renameState?.oldAlias) {
|
||||
setRenameState(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
hasModelAliasConflict(
|
||||
aliasNodes.map((alias) => alias.alias),
|
||||
trimmed,
|
||||
renameState?.oldAlias
|
||||
)
|
||||
) {
|
||||
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
|
||||
return;
|
||||
}
|
||||
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
|
||||
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
|
||||
setExtraAliases((prev) => prev.map((a) => (a === renameState?.oldAlias ? trimmed : a)));
|
||||
}
|
||||
setRenameState(null);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (alias: string) => {
|
||||
closeContextMenu();
|
||||
const node = aliasNodes.find((n) => n.alias === alias);
|
||||
if (!node) return;
|
||||
|
||||
if (node.sources.length === 0) {
|
||||
setExtraAliases((prev) => prev.filter((a) => a !== alias));
|
||||
} else {
|
||||
if (onDeleteAlias) onDeleteAlias(alias);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
|
||||
{enableTapLinking && onUpdate && (
|
||||
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
|
||||
)}
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={containerRef}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<svg className={styles.connections}>
|
||||
{lines.map((line) => (
|
||||
<path
|
||||
key={line.id}
|
||||
d={line.path}
|
||||
stroke={line.color}
|
||||
strokeOpacity={isDark ? 0.4 : 0.3}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<ProviderColumn
|
||||
providerNodes={providerNodes}
|
||||
collapsedProviders={collapsedProviders}
|
||||
getProviderColor={getProviderColor}
|
||||
providerGroupHeights={providerGroupHeights}
|
||||
providerRefs={providerRefs}
|
||||
onToggleCollapse={toggleProviderCollapse}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_providers')}
|
||||
expandLabel={t('oauth_model_alias.diagram_expand')}
|
||||
collapseLabel={t('oauth_model_alias.diagram_collapse')}
|
||||
/>
|
||||
<SourceColumn
|
||||
providerNodes={providerNodes}
|
||||
collapsedProviders={collapsedProviders}
|
||||
sourceRefs={sourceRefs}
|
||||
getProviderColor={getProviderColor}
|
||||
selectedSourceId={enableTapLinking ? tapSourceId : null}
|
||||
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
|
||||
draggedSource={draggedSource}
|
||||
dropTargetSource={dropTargetSource}
|
||||
draggable={!!onUpdate}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => {
|
||||
setDraggedSource(null);
|
||||
setDropTargetAlias(null);
|
||||
}}
|
||||
onDragOver={handleDragOverSource}
|
||||
onDragLeave={handleDragLeaveSource}
|
||||
onDrop={handleDropOnSource}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_source_models')}
|
||||
/>
|
||||
<AliasColumn
|
||||
aliasNodes={aliasNodes}
|
||||
aliasRefs={aliasRefs}
|
||||
dropTargetAlias={dropTargetAlias}
|
||||
draggedAlias={draggedAlias}
|
||||
selectedAlias={enableTapLinking ? tapAlias : null}
|
||||
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
|
||||
draggable={!!onUpdate}
|
||||
onDragStart={handleDragStartAlias}
|
||||
onDragEnd={() => {
|
||||
setDraggedAlias(null);
|
||||
setDropTargetSource(null);
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_aliases')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DiagramContextMenu
|
||||
contextMenu={contextMenu}
|
||||
t={t}
|
||||
onRequestClose={() => setContextMenu(null)}
|
||||
onAddAlias={handleAddAlias}
|
||||
onRenameAlias={handleRenameClick}
|
||||
onOpenAliasSettings={(alias) => {
|
||||
setContextMenu(null);
|
||||
setSettingsAlias(alias);
|
||||
}}
|
||||
onDeleteAlias={handleDeleteClick}
|
||||
onEditProvider={(provider) => {
|
||||
setContextMenu(null);
|
||||
onEditProvider?.(provider);
|
||||
}}
|
||||
onDeleteProvider={(provider) => {
|
||||
setContextMenu(null);
|
||||
onDeleteProvider?.(provider);
|
||||
}}
|
||||
onOpenSourceSettings={(sourceId) => {
|
||||
setContextMenu(null);
|
||||
setSettingsSourceId(sourceId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RenameAliasModal
|
||||
open={!!renameState}
|
||||
t={t}
|
||||
value={renameValue}
|
||||
error={renameError}
|
||||
onChange={(value) => {
|
||||
setRenameValue(value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onClose={() => setRenameState(null)}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<AddAliasModal
|
||||
open={addAliasOpen}
|
||||
t={t}
|
||||
value={addAliasValue}
|
||||
error={addAliasError}
|
||||
onChange={(value) => {
|
||||
setAddAliasValue(value);
|
||||
setAddAliasError('');
|
||||
}}
|
||||
onClose={() => setAddAliasOpen(false)}
|
||||
onSubmit={handleAddAliasSubmit}
|
||||
/>
|
||||
<SettingsAliasModal
|
||||
open={Boolean(settingsAlias)}
|
||||
t={t}
|
||||
alias={settingsAlias}
|
||||
aliasNodes={aliasNodes}
|
||||
onClose={() => setSettingsAlias(null)}
|
||||
onToggleFork={handleToggleFork}
|
||||
onUnlink={handleUnlinkSource}
|
||||
/>
|
||||
<SettingsSourceModal
|
||||
open={Boolean(settingsSourceId)}
|
||||
t={t}
|
||||
source={resolveSourceById(settingsSourceId)}
|
||||
onClose={() => setSettingsSourceId(null)}
|
||||
onToggleFork={handleToggleFork}
|
||||
onUnlink={handleUnlinkSource}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface ProviderColumnProps {
|
||||
providerNodes: ProviderNode[];
|
||||
collapsedProviders: Set<string>;
|
||||
getProviderColor: (provider: string) => string;
|
||||
providerGroupHeights?: Record<string, number>;
|
||||
providerRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
onToggleCollapse: (provider: string) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
expandLabel: string;
|
||||
collapseLabel: string;
|
||||
}
|
||||
|
||||
export function ProviderColumn({
|
||||
providerNodes,
|
||||
collapsedProviders,
|
||||
getProviderColor,
|
||||
providerGroupHeights = {},
|
||||
providerRefs,
|
||||
onToggleCollapse,
|
||||
onContextMenu,
|
||||
label,
|
||||
expandLabel,
|
||||
collapseLabel,
|
||||
}: ProviderColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.providers}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{providerNodes.map(({ provider, sources }) => {
|
||||
const collapsed = collapsedProviders.has(provider);
|
||||
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className={styles.providerGroup}
|
||||
style={groupHeight ? { height: groupHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) providerRefs.current?.set(provider, el);
|
||||
else providerRefs.current?.delete(provider);
|
||||
}}
|
||||
className={`${styles.item} ${styles.providerItem}`}
|
||||
style={{ borderLeftColor: getProviderColor(provider) }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'provider', provider);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => onToggleCollapse(provider)}
|
||||
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||
title={collapsed ? expandLabel : collapseLabel}
|
||||
>
|
||||
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
|
||||
</button>
|
||||
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
|
||||
{provider}
|
||||
</span>
|
||||
<span className={styles.itemCount}>{sources.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceColumnProps {
|
||||
providerNodes: ProviderNode[];
|
||||
collapsedProviders: Set<string>;
|
||||
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
getProviderColor: (provider: string) => string;
|
||||
selectedSourceId?: string | null;
|
||||
onSelectSource?: (source: SourceNode) => void;
|
||||
draggedSource: SourceNode | null;
|
||||
dropTargetSource: string | null;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent, source: SourceNode) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent, source: SourceNode) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: DragEvent, source: SourceNode) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SourceColumn({
|
||||
providerNodes,
|
||||
collapsedProviders,
|
||||
sourceRefs,
|
||||
getProviderColor,
|
||||
selectedSourceId,
|
||||
onSelectSource,
|
||||
draggedSource,
|
||||
dropTargetSource,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onContextMenu,
|
||||
label,
|
||||
}: SourceColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.sources}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{providerNodes.flatMap(({ provider, sources }) => {
|
||||
if (collapsedProviders.has(provider)) return [];
|
||||
return sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
ref={(el) => {
|
||||
if (el) sourceRefs.current?.set(source.id, el);
|
||||
else sourceRefs.current?.delete(source.id);
|
||||
}}
|
||||
className={`${styles.item} ${styles.sourceItem} ${
|
||||
draggedSource?.id === source.id ? styles.dragging : ''
|
||||
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
|
||||
selectedSourceId === source.id ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => onSelectSource?.(source)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => onDragStart(e, source)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, source)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, source)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'source', source.id);
|
||||
}}
|
||||
>
|
||||
<span className={styles.itemName} title={source.name}>
|
||||
{source.name}
|
||||
</span>
|
||||
<div
|
||||
className={styles.dot}
|
||||
style={{
|
||||
background: getProviderColor(source.provider),
|
||||
opacity: source.aliases.length > 0 ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AliasColumnProps {
|
||||
aliasNodes: AliasNode[];
|
||||
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
dropTargetAlias: string | null;
|
||||
draggedAlias: string | null;
|
||||
selectedAlias?: string | null;
|
||||
onSelectAlias?: (alias: string) => void;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent, alias: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent, alias: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: DragEvent, alias: string) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function AliasColumn({
|
||||
aliasNodes,
|
||||
aliasRefs,
|
||||
dropTargetAlias,
|
||||
draggedAlias,
|
||||
selectedAlias,
|
||||
onSelectAlias,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onContextMenu,
|
||||
label,
|
||||
}: AliasColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.aliases}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{aliasNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
ref={(el) => {
|
||||
if (el) aliasRefs.current?.set(node.id, el);
|
||||
else aliasRefs.current?.delete(node.id);
|
||||
}}
|
||||
className={`${styles.item} ${styles.aliasItem} ${
|
||||
dropTargetAlias === node.alias ? styles.dropTarget : ''
|
||||
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
|
||||
selectedAlias === node.alias ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => onSelectAlias?.(node.alias)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => onDragStart(e, node.alias)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, node.alias)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, node.alias)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'alias', node.alias);
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.dot} ${styles.dotLeft}`} />
|
||||
<span className={styles.itemName} title={node.alias}>
|
||||
{node.alias}
|
||||
</span>
|
||||
<span className={styles.itemCount}>{node.sources.length}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ContextMenuState } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface DiagramContextMenuProps {
|
||||
contextMenu: ContextMenuState | null;
|
||||
t: TFunction;
|
||||
onRequestClose: () => void;
|
||||
onAddAlias: () => void;
|
||||
onRenameAlias: (alias: string) => void;
|
||||
onOpenAliasSettings: (alias: string) => void;
|
||||
onDeleteAlias: (alias: string) => void;
|
||||
onEditProvider: (provider: string) => void;
|
||||
onDeleteProvider: (provider: string) => void;
|
||||
onOpenSourceSettings: (sourceId: string) => void;
|
||||
}
|
||||
|
||||
export function DiagramContextMenu({
|
||||
contextMenu,
|
||||
t,
|
||||
onRequestClose,
|
||||
onAddAlias,
|
||||
onRenameAlias,
|
||||
onOpenAliasSettings,
|
||||
onDeleteAlias,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
onOpenSourceSettings,
|
||||
}: DiagramContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleClick = (event: globalThis.MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
onRequestClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [contextMenu, onRequestClose]);
|
||||
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const { type, data } = contextMenu;
|
||||
|
||||
const renderBackground = () => (
|
||||
<div className={styles.menuItem} onClick={onAddAlias}>
|
||||
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAlias = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_rename')}</span>
|
||||
</div>
|
||||
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||
</div>
|
||||
<div className={styles.menuDivider} />
|
||||
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProvider = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
|
||||
<span>{t('common.edit')}</span>
|
||||
</div>
|
||||
<div className={styles.menuDivider} />
|
||||
<div
|
||||
className={`${styles.menuItem} ${styles.danger}`}
|
||||
onClick={() => onDeleteProvider(data)}
|
||||
>
|
||||
<span>{t('oauth_model_alias.delete')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSource = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={styles.contextMenu}
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{type === 'background' && renderBackground()}
|
||||
{type === 'alias' && renderAlias()}
|
||||
{type === 'provider' && renderProvider()}
|
||||
{type === 'source' && renderSource()}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/modelAlias/ModelMappingDiagramModals.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import type { KeyboardEvent } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconTrash2 } from '@/components/ui/icons';
|
||||
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface RenameAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
value: string;
|
||||
error: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function RenameAliasModal({
|
||||
open,
|
||||
t,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: RenameAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_rename_alias_title')}
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_model_alias.diagram_rename_alias_label')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') onSubmit();
|
||||
}}
|
||||
error={error}
|
||||
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
value: string;
|
||||
error: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function AddAliasModal({
|
||||
open,
|
||||
t,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AddAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_add_alias_title')}
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_model_alias.diagram_add_alias_label')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') onSubmit();
|
||||
}}
|
||||
error={error}
|
||||
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
alias: string | null;
|
||||
aliasNodes: AliasNode[];
|
||||
onClose: () => void;
|
||||
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsAliasModal({
|
||||
open,
|
||||
t,
|
||||
alias,
|
||||
aliasNodes,
|
||||
onClose,
|
||||
onToggleFork,
|
||||
onUnlink,
|
||||
}: SettingsAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
|
||||
width={720}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{alias
|
||||
? (() => {
|
||||
const node = aliasNodes.find((n) => n.alias === alias);
|
||||
if (!node || node.sources.length === 0) {
|
||||
return (
|
||||
<div className={styles.settingsEmpty}>
|
||||
{t('oauth_model_alias.diagram_settings_empty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.settingsList}>
|
||||
{node.sources.map((source) => {
|
||||
const entry = source.aliases.find((item) => item.alias === alias);
|
||||
const forkEnabled = entry?.fork === true;
|
||||
return (
|
||||
<div key={source.id} className={styles.settingsRow}>
|
||||
<div className={styles.settingsNames}>
|
||||
<span className={styles.settingsSource}>{source.name}</span>
|
||||
<span className={styles.settingsArrow}>→</span>
|
||||
<span className={styles.settingsAlias}>{alias}</span>
|
||||
</div>
|
||||
<div className={styles.settingsActions}>
|
||||
<span className={styles.settingsLabel}>
|
||||
{t('oauth_model_alias.alias_fork_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={forkEnabled}
|
||||
onChange={(value) =>
|
||||
onToggleFork(source.provider, source.name, alias, value)
|
||||
}
|
||||
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.settingsDelete}
|
||||
onClick={() => onUnlink(source.provider, source.name, alias)}
|
||||
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name,
|
||||
})}
|
||||
title={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name,
|
||||
})}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSourceModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
source: SourceNode | null;
|
||||
onClose: () => void;
|
||||
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsSourceModal({
|
||||
open,
|
||||
t,
|
||||
source,
|
||||
onClose,
|
||||
onToggleFork,
|
||||
onUnlink,
|
||||
}: SettingsSourceModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_settings_source_title')}
|
||||
width={720}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{source ? (
|
||||
source.aliases.length === 0 ? (
|
||||
<div className={styles.settingsEmpty}>
|
||||
{t('oauth_model_alias.diagram_settings_empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.settingsList}>
|
||||
{source.aliases.map((entry) => (
|
||||
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
|
||||
<div className={styles.settingsNames}>
|
||||
<span className={styles.settingsSource}>{source.name}</span>
|
||||
<span className={styles.settingsArrow}>→</span>
|
||||
<span className={styles.settingsAlias}>{entry.alias}</span>
|
||||
</div>
|
||||
<div className={styles.settingsActions}>
|
||||
<span className={styles.settingsLabel}>
|
||||
{t('oauth_model_alias.alias_fork_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={entry.fork === true}
|
||||
onChange={(value) =>
|
||||
onToggleFork(source.provider, source.name, entry.alias, value)
|
||||
}
|
||||
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.settingsDelete}
|
||||
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
|
||||
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name,
|
||||
})}
|
||||
title={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name,
|
||||
})}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
export interface AuthFileModelItem {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
type?: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
export interface SourceNode {
|
||||
id: string; // unique: provider::name
|
||||
provider: string;
|
||||
name: string;
|
||||
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
|
||||
}
|
||||
|
||||
export interface AliasNode {
|
||||
id: string; // alias
|
||||
alias: string;
|
||||
sources: SourceNode[];
|
||||
}
|
||||
|
||||
export interface ProviderNode {
|
||||
provider: string;
|
||||
sources: SourceNode[];
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'alias' | 'background' | 'provider' | 'source';
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export type DiagramLine = { path: string; color: string; id: string };
|
||||
19
frontend/src/components/modelAlias/aliasValidation.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const normalizeModelAliasKey = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
export function hasModelAliasConflict(
|
||||
aliases: string[],
|
||||
candidate: string,
|
||||
excludedAlias?: string
|
||||
): boolean {
|
||||
const candidateKey = normalizeModelAliasKey(candidate);
|
||||
if (!candidateKey) return false;
|
||||
|
||||
let excluded = false;
|
||||
return aliases.some((alias) => {
|
||||
if (!excluded && excludedAlias !== undefined && alias === excludedAlias) {
|
||||
excluded = true;
|
||||
return false;
|
||||
}
|
||||
return normalizeModelAliasKey(alias) === candidateKey;
|
||||
});
|
||||
}
|
||||
2
frontend/src/components/modelAlias/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { ModelMappingDiagram } from './ModelMappingDiagram';
|
||||
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';
|
||||
155
frontend/src/components/providers/ProviderStatusBar.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { StatusBarData, StatusBlockDetail } from '@/utils/recentRequests';
|
||||
|
||||
const defaultStyles: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* 根据成功率 (0–1) 在三个色标之间做 RGB 线性插值
|
||||
* 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e)
|
||||
*/
|
||||
const COLOR_STOPS = [
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
] as const;
|
||||
|
||||
function rateToColor(rate: number): string {
|
||||
const t = Math.max(0, Math.min(1, rate));
|
||||
const segment = t < 0.5 ? 0 : 1;
|
||||
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
|
||||
const from = COLOR_STOPS[segment];
|
||||
const to = COLOR_STOPS[segment + 1];
|
||||
const r = Math.round(from.r + (to.r - from.r) * localT);
|
||||
const g = Math.round(from.g + (to.g - from.g) * localT);
|
||||
const b = Math.round(from.b + (to.b - from.b) * localT);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const h = date.getHours().toString().padStart(2, '0');
|
||||
const m = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function formatSuccessRate(rate: number): string {
|
||||
const rounded = rate.toFixed(1);
|
||||
return `${rounded.endsWith('.0') ? rounded.slice(0, -2) : rounded}%`;
|
||||
}
|
||||
|
||||
type StylesModule = Record<string, string>;
|
||||
|
||||
interface ProviderStatusBarProps {
|
||||
statusData: StatusBarData;
|
||||
styles?: StylesModule;
|
||||
}
|
||||
|
||||
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const s = (stylesProp || defaultStyles) as StylesModule;
|
||||
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||
const blocksRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||
const rateClass = !hasData
|
||||
? ''
|
||||
: statusData.successRate >= 90
|
||||
? s.statusRateHigh
|
||||
: statusData.successRate >= 50
|
||||
? s.statusRateMedium
|
||||
: s.statusRateLow;
|
||||
|
||||
// 点击外部关闭 tooltip(移动端)
|
||||
useEffect(() => {
|
||||
if (activeTooltip === null) return;
|
||||
const handler = (e: PointerEvent) => {
|
||||
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
|
||||
setActiveTooltip(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', handler);
|
||||
return () => document.removeEventListener('pointerdown', handler);
|
||||
}, [activeTooltip]);
|
||||
|
||||
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
setActiveTooltip(idx);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
setActiveTooltip(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTooltipPositionClass = (idx: number, total: number): string => {
|
||||
if (idx <= 2) return s.statusTooltipLeft;
|
||||
if (idx >= total - 3) return s.statusTooltipRight;
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||
const total = detail.success + detail.failure;
|
||||
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
|
||||
const timeRange = `${formatTime(detail.startTime)} – ${formatTime(detail.endTime)}`;
|
||||
|
||||
return (
|
||||
<div className={`${s.statusTooltip} ${posClass}`}>
|
||||
<span className={s.tooltipTime}>{timeRange}</span>
|
||||
{total > 0 ? (
|
||||
<span className={s.tooltipStats}>
|
||||
<span className={s.tooltipSuccess}>
|
||||
{t('status_bar.success_short')} {detail.success}
|
||||
</span>
|
||||
<span className={s.tooltipFailure}>
|
||||
{t('status_bar.failure_short')} {detail.failure}
|
||||
</span>
|
||||
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={s.statusBar}>
|
||||
<div className={s.statusBlocks} ref={blocksRef}>
|
||||
{statusData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
<div
|
||||
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && renderTooltip(detail, idx)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={`${s.statusRate} ${rateClass}`}>
|
||||
{hasData ? formatSuccessRate(statusData.successRate) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { apiKeyUsageApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import {
|
||||
normalizeRecentRequestUsageEntry,
|
||||
type ApiKeyUsageResponse,
|
||||
type RecentRequestUsageEntry,
|
||||
} from '@/utils/recentRequests';
|
||||
|
||||
const PROVIDER_RECENT_REQUESTS_STALE_TIME_MS = 240_000;
|
||||
|
||||
export type ProviderRecentRequests = Map<string, Map<string, RecentRequestUsageEntry>>;
|
||||
|
||||
export type UseProviderRecentRequestsOptions = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_USAGE_BY_PROVIDER: ProviderRecentRequests = new Map();
|
||||
|
||||
type ProviderRecentRequestsCache = {
|
||||
cachedUsageByProvider: ProviderRecentRequests;
|
||||
cachedAt: number;
|
||||
inFlightRequest: Promise<ProviderRecentRequests> | null;
|
||||
};
|
||||
|
||||
const createProviderRecentRequestsCache = (): ProviderRecentRequestsCache => ({
|
||||
cachedUsageByProvider: EMPTY_USAGE_BY_PROVIDER,
|
||||
cachedAt: 0,
|
||||
inFlightRequest: null,
|
||||
});
|
||||
|
||||
export const createProviderRecentRequestsCacheController = () => {
|
||||
let currentApiBase = '';
|
||||
let currentManagementKey = '';
|
||||
let currentCache = createProviderRecentRequestsCache();
|
||||
|
||||
return {
|
||||
forScope(apiBase: string, managementKey: string): ProviderRecentRequestsCache {
|
||||
if (apiBase !== currentApiBase || managementKey !== currentManagementKey) {
|
||||
currentApiBase = apiBase;
|
||||
currentManagementKey = managementKey;
|
||||
currentCache = createProviderRecentRequestsCache();
|
||||
}
|
||||
return currentCache;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const providerRecentRequestsCacheController = createProviderRecentRequestsCacheController();
|
||||
|
||||
const normalizeProviderKey = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const normalizeApiKeyUsageResponse = (payload: ApiKeyUsageResponse): ProviderRecentRequests => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return EMPTY_USAGE_BY_PROVIDER;
|
||||
}
|
||||
|
||||
const usageByProvider: ProviderRecentRequests = new Map();
|
||||
|
||||
Object.entries(payload).forEach(([provider, entries]) => {
|
||||
const providerKey = normalizeProviderKey(provider);
|
||||
if (!providerKey || !entries || typeof entries !== 'object' || Array.isArray(entries)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usageByCompositeKey = new Map<string, RecentRequestUsageEntry>();
|
||||
Object.entries(entries).forEach(([compositeKey, entry]) => {
|
||||
usageByCompositeKey.set(compositeKey, normalizeRecentRequestUsageEntry(entry));
|
||||
});
|
||||
|
||||
usageByProvider.set(providerKey, usageByCompositeKey);
|
||||
});
|
||||
|
||||
return usageByProvider;
|
||||
};
|
||||
|
||||
const fetchProviderRecentRequests = async (
|
||||
cache: ProviderRecentRequestsCache
|
||||
): Promise<ProviderRecentRequests> => {
|
||||
if (!cache.inFlightRequest) {
|
||||
const request = apiKeyUsageApi
|
||||
.getUsage()
|
||||
.then((payload) => {
|
||||
const normalized = normalizeApiKeyUsageResponse(payload);
|
||||
cache.cachedUsageByProvider = normalized;
|
||||
cache.cachedAt = Date.now();
|
||||
return normalized;
|
||||
})
|
||||
.finally(() => {
|
||||
if (cache.inFlightRequest === request) {
|
||||
cache.inFlightRequest = null;
|
||||
}
|
||||
});
|
||||
cache.inFlightRequest = request;
|
||||
}
|
||||
|
||||
return cache.inFlightRequest;
|
||||
};
|
||||
|
||||
export function useProviderRecentRequests(options: UseProviderRecentRequestsOptions = {}) {
|
||||
const enabled = options.enabled ?? true;
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const managementKey = useAuthStore((state) => state.managementKey);
|
||||
const cache = useMemo(
|
||||
() => providerRecentRequestsCacheController.forScope(apiBase, managementKey),
|
||||
[apiBase, managementKey]
|
||||
);
|
||||
const [usageState, setUsageState] = useState(() => ({
|
||||
cache,
|
||||
value: cache.cachedUsageByProvider,
|
||||
}));
|
||||
const [loadingState, setLoadingState] = useState(() => ({ cache, value: false }));
|
||||
|
||||
const setUsageForCurrentScope = useCallback(
|
||||
(value: ProviderRecentRequests) => setUsageState({ cache, value }),
|
||||
[cache]
|
||||
);
|
||||
|
||||
const setLoadingForCurrentScope = useCallback(
|
||||
(value: boolean) => setLoadingState({ cache, value }),
|
||||
[cache]
|
||||
);
|
||||
|
||||
const loadRecentRequests = useCallback(
|
||||
async (loadOptions: { force?: boolean } = {}) => {
|
||||
if (!enabled) {
|
||||
return EMPTY_USAGE_BY_PROVIDER;
|
||||
}
|
||||
|
||||
const hasFreshCache =
|
||||
cache.cachedAt > 0 &&
|
||||
Date.now() - cache.cachedAt < PROVIDER_RECENT_REQUESTS_STALE_TIME_MS;
|
||||
|
||||
if (!loadOptions.force && hasFreshCache) {
|
||||
setUsageForCurrentScope(cache.cachedUsageByProvider);
|
||||
return cache.cachedUsageByProvider;
|
||||
}
|
||||
|
||||
setLoadingForCurrentScope(true);
|
||||
try {
|
||||
const nextUsage = await fetchProviderRecentRequests(cache);
|
||||
setUsageForCurrentScope(nextUsage);
|
||||
return nextUsage;
|
||||
} catch {
|
||||
if (cache.cachedAt > 0) {
|
||||
setUsageForCurrentScope(cache.cachedUsageByProvider);
|
||||
}
|
||||
return cache.cachedUsageByProvider;
|
||||
} finally {
|
||||
setLoadingForCurrentScope(false);
|
||||
}
|
||||
},
|
||||
[cache, enabled, setLoadingForCurrentScope, setUsageForCurrentScope]
|
||||
);
|
||||
|
||||
const refreshRecentRequests = useCallback(
|
||||
async () => loadRecentRequests({ force: true }),
|
||||
[loadRecentRequests]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setUsageForCurrentScope(EMPTY_USAGE_BY_PROVIDER);
|
||||
return;
|
||||
}
|
||||
void loadRecentRequests().catch(() => {});
|
||||
}, [enabled, loadRecentRequests, setUsageForCurrentScope]);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
void refreshRecentRequests().catch(() => {});
|
||||
},
|
||||
enabled ? PROVIDER_RECENT_REQUESTS_STALE_TIME_MS : null
|
||||
);
|
||||
|
||||
const usageByProvider =
|
||||
usageState.cache === cache ? usageState.value : cache.cachedUsageByProvider;
|
||||
const isLoading =
|
||||
loadingState.cache === cache ? loadingState.value : cache.inFlightRequest !== null;
|
||||
|
||||
return {
|
||||
usageByProvider: enabled ? usageByProvider : EMPTY_USAGE_BY_PROVIDER,
|
||||
isLoading: enabled ? isLoading : false,
|
||||
loadRecentRequests,
|
||||
refreshRecentRequests,
|
||||
};
|
||||
}
|
||||
258
frontend/src/components/providers/utils.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import {
|
||||
buildRecentRequestCompositeKey,
|
||||
mergeRecentRequestBucketGroups,
|
||||
statusBarDataFromRecentRequests,
|
||||
sumRecentRequests,
|
||||
type RecentRequestBucket,
|
||||
type RecentRequestUsageEntry,
|
||||
type StatusBarData,
|
||||
} from '@/utils/recentRequests';
|
||||
|
||||
const DISABLE_ALL_MODELS_RULE = '*';
|
||||
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
|
||||
|
||||
export const hasDisableAllModelsRule = (models?: string[]) =>
|
||||
Array.isArray(models) &&
|
||||
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
|
||||
|
||||
export const stripDisableAllModelsRule = (models?: string[]) =>
|
||||
Array.isArray(models)
|
||||
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
|
||||
: [];
|
||||
|
||||
export const withDisableAllModelsRule = (models?: string[]) => {
|
||||
const base = stripDisableAllModelsRule(models);
|
||||
return [...base, DISABLE_ALL_MODELS_RULE];
|
||||
};
|
||||
|
||||
export const withoutDisableAllModelsRule = (models?: string[]) => stripDisableAllModelsRule(models);
|
||||
|
||||
const normalizeUpstreamBaseUrl = (baseUrl: string, fallback = ''): string => {
|
||||
let trimmed = String(baseUrl || '').trim();
|
||||
if (!trimmed) return fallback;
|
||||
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||
trimmed = trimmed.replace(/\/+$/g, '');
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `http://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const buildGeminiModelResource = (model: string): string => {
|
||||
const trimmed = String(model || '')
|
||||
.trim()
|
||||
.replace(/^\/+/g, '')
|
||||
.replace(/:generateContent$/i, '');
|
||||
if (!trimmed) return '';
|
||||
|
||||
if (/^(models|tunedModels)\//i.test(trimmed)) {
|
||||
return trimmed.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
return `models/${encodeURIComponent(trimmed)}`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeUpstreamBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/chat/completions`;
|
||||
};
|
||||
|
||||
export const buildCodexResponsesEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeUpstreamBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (/\/v1\/responses$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/\/v1\/models$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\/models$/i, '/responses');
|
||||
}
|
||||
if (/\/v1$/i.test(trimmed)) {
|
||||
return `${trimmed}/responses`;
|
||||
}
|
||||
return `${trimmed}/v1/responses`;
|
||||
};
|
||||
|
||||
export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeUpstreamBaseUrl(baseUrl, 'https://api.anthropic.com');
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/v1/messages')) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.endsWith('/v1')) {
|
||||
return `${trimmed}/messages`;
|
||||
}
|
||||
return `${trimmed}/v1/messages`;
|
||||
};
|
||||
|
||||
export const INTERACTIONS_API_REVISION = '2026-05-20';
|
||||
|
||||
export const buildInteractionsProbePayload = (model: string) => ({
|
||||
model,
|
||||
input: 'Hi',
|
||||
});
|
||||
|
||||
export const buildInteractionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeUpstreamBaseUrl(baseUrl, DEFAULT_GEMINI_BASE_URL);
|
||||
if (!trimmed) return '';
|
||||
if (/\/v1beta\/interactions$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let root = trimmed.replace(/\/+$/g, '');
|
||||
root = root.replace(/\/v1beta\/models$/i, '');
|
||||
if (/\/v1beta$/i.test(root)) {
|
||||
return `${root}/interactions`;
|
||||
}
|
||||
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
|
||||
return `${root}/v1beta/interactions`;
|
||||
};
|
||||
|
||||
export const buildGeminiGenerateContentEndpoint = (baseUrl: string, model: string): string => {
|
||||
const resource = buildGeminiModelResource(model);
|
||||
if (!resource) return '';
|
||||
|
||||
const trimmed = normalizeUpstreamBaseUrl(baseUrl, DEFAULT_GEMINI_BASE_URL);
|
||||
if (!trimmed) return '';
|
||||
if (/:generateContent$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let root = trimmed.replace(/\/+$/g, '');
|
||||
if (/\/v1beta\/models$/i.test(root)) {
|
||||
root = root.replace(/\/models$/i, '');
|
||||
} else if (!/\/v1beta$/i.test(root)) {
|
||||
root = root.replace(/\/v1beta(?:\/.*)?$/i, '');
|
||||
root = `${root}/v1beta`;
|
||||
}
|
||||
|
||||
return `${root}/${resource}:generateContent`;
|
||||
};
|
||||
|
||||
export const getProviderUsageKey = (provider: string): string => {
|
||||
if (provider === 'claudeApi') return 'claude';
|
||||
if (provider === 'interactions') return 'gemini-interactions';
|
||||
return provider;
|
||||
};
|
||||
|
||||
export type ProviderRecentUsageMap = Map<string, Map<string, RecentRequestUsageEntry>>;
|
||||
|
||||
const EMPTY_RECENT_USAGE_ENTRY: RecentRequestUsageEntry = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
recentRequests: [],
|
||||
};
|
||||
|
||||
const normalizeProviderRecentKey = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const getProviderRecentUsageEntry = (
|
||||
usageByProvider: ProviderRecentUsageMap,
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string
|
||||
): RecentRequestUsageEntry => {
|
||||
if (!String(apiKey ?? '').trim()) {
|
||||
return EMPTY_RECENT_USAGE_ENTRY;
|
||||
}
|
||||
|
||||
const providerKey = normalizeProviderRecentKey(provider);
|
||||
const compositeKey = buildRecentRequestCompositeKey(baseUrl, apiKey);
|
||||
return usageByProvider.get(providerKey)?.get(compositeKey) ?? EMPTY_RECENT_USAGE_ENTRY;
|
||||
};
|
||||
|
||||
const getProviderRecentBuckets = (
|
||||
usageByProvider: ProviderRecentUsageMap,
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string
|
||||
): RecentRequestBucket[] =>
|
||||
getProviderRecentUsageEntry(usageByProvider, provider, apiKey, baseUrl).recentRequests;
|
||||
|
||||
export function getProviderRecentStatusData(
|
||||
usageByProvider: ProviderRecentUsageMap,
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string
|
||||
): StatusBarData {
|
||||
return statusBarDataFromRecentRequests(
|
||||
getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
export function getProviderTotalStats(
|
||||
usageByProvider: ProviderRecentUsageMap,
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string
|
||||
): { success: number; failure: number } {
|
||||
const entry = getProviderRecentUsageEntry(usageByProvider, provider, apiKey, baseUrl);
|
||||
return { success: entry.success, failure: entry.failed };
|
||||
}
|
||||
|
||||
export function getProviderRecentWindowStats(
|
||||
usageByProvider: ProviderRecentUsageMap,
|
||||
provider: string,
|
||||
apiKey?: string,
|
||||
baseUrl?: string
|
||||
): { success: number; failure: number } {
|
||||
return sumRecentRequests(getProviderRecentBuckets(usageByProvider, provider, apiKey, baseUrl));
|
||||
}
|
||||
|
||||
const collectOpenAIProviderRecentBuckets = (
|
||||
provider: OpenAIProviderConfig,
|
||||
usageByProvider: ProviderRecentUsageMap
|
||||
): RecentRequestBucket[] => {
|
||||
if (!provider.apiKeyEntries?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups = provider.apiKeyEntries.map((entry) =>
|
||||
getProviderRecentBuckets(usageByProvider, provider.name, entry.apiKey, provider.baseUrl)
|
||||
);
|
||||
|
||||
return mergeRecentRequestBucketGroups(groups);
|
||||
};
|
||||
|
||||
export function getOpenAIProviderRecentWindowStats(
|
||||
provider: OpenAIProviderConfig,
|
||||
usageByProvider: ProviderRecentUsageMap
|
||||
): { success: number; failure: number } {
|
||||
return sumRecentRequests(collectOpenAIProviderRecentBuckets(provider, usageByProvider));
|
||||
}
|
||||
|
||||
export function getOpenAIProviderTotalStats(
|
||||
provider: OpenAIProviderConfig,
|
||||
usageByProvider: ProviderRecentUsageMap
|
||||
): { success: number; failure: number } {
|
||||
return (provider.apiKeyEntries || []).reduce(
|
||||
(total, entry) => {
|
||||
const usageEntry = getProviderRecentUsageEntry(
|
||||
usageByProvider,
|
||||
provider.name,
|
||||
entry.apiKey,
|
||||
provider.baseUrl
|
||||
);
|
||||
return {
|
||||
success: total.success + usageEntry.success,
|
||||
failure: total.failure + usageEntry.failed,
|
||||
};
|
||||
},
|
||||
{ success: 0, failure: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenAIProviderRecentStatusData(
|
||||
provider: OpenAIProviderConfig,
|
||||
usageByProvider: ProviderRecentUsageMap
|
||||
): StatusBarData {
|
||||
return statusBarDataFromRecentRequests(
|
||||
collectOpenAIProviderRecentBuckets(provider, usageByProvider)
|
||||
);
|
||||
}
|
||||
190
frontend/src/components/ui/AutocompleteInput.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { IconChevronDown } from './icons';
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: string[] | { value: string; label?: string }[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
id?: string;
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
hint,
|
||||
error,
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
wrapperStyle,
|
||||
id,
|
||||
rightElement,
|
||||
}: AutocompleteInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const normalizedOptions = options.map((opt) =>
|
||||
typeof opt === 'string'
|
||||
? { value: opt, label: opt }
|
||||
: { value: opt.value, label: opt.label || opt.value }
|
||||
);
|
||||
|
||||
const filteredOptions = normalizedOptions.filter((opt) => {
|
||||
const v = value.toLowerCase();
|
||||
return (
|
||||
opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v))
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredOptions[highlightedIndex].value);
|
||||
} else if (isOpen) {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Tab') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={id}
|
||||
className={`input ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
style={{ paddingRight: 32 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: disabled ? 'none' : 'auto',
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
}}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{rightElement}
|
||||
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||
</div>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||
<div
|
||||
className="autocomplete-dropdown"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((opt, index) => (
|
||||
<div
|
||||
key={`${opt.value}-${index}`}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor:
|
||||
index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||
{opt.label && opt.label !== opt.value && (
|
||||
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>
|
||||
{opt.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'md' | 'sm';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: PropsWithChildren<ButtonProps>) {
|
||||
const hasChildren = children !== null && children !== undefined && children !== false;
|
||||
const classes = [
|
||||
'btn',
|
||||
`btn-${variant}`,
|
||||
size === 'sm' ? 'btn-sm' : '',
|
||||
fullWidth ? 'btn-full' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button className={classes} disabled={disabled || loading} {...rest}>
|
||||
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
||||
{hasChildren && <span>{children}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||
return (
|
||||
<div className={className ? `card ${className}` : 'card'}>
|
||||
{(title || extra) && (
|
||||
<div className="card-header">
|
||||
<div className="title">{title}</div>
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.root {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
user-select: none;
|
||||
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--accent-bg) 35%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summaryHint {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--muted-foreground);
|
||||
transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
flex-shrink: 0;
|
||||
|
||||
.root[open] & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.contentFlush {
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 展开时内容 160ms 淡入。只做透明度、不做高度动画 ——
|
||||
与程序化 details.open = true(搜索跳转强制展开)不会互相打架。 */
|
||||
.root[open] > .content,
|
||||
.root[open] > .contentFlush {
|
||||
animation: collapsible-content-in var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
@keyframes collapsible-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chevron {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.root[open] > .content,
|
||||
.root[open] > .contentFlush {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
54
frontend/src/components/ui/Collapsible/Collapsible.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { useState, type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import { IconChevronDown } from '../icons';
|
||||
import styles from './Collapsible.module.scss';
|
||||
|
||||
interface CollapsibleProps extends HTMLAttributes<HTMLDetailsElement> {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onToggle?: (event: React.SyntheticEvent<HTMLDetailsElement>) => void;
|
||||
flush?: boolean;
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
label,
|
||||
hint,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onToggle,
|
||||
flush,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: PropsWithChildren<CollapsibleProps>) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const resolvedOpen = open ?? uncontrolledOpen;
|
||||
const cls = [styles.root, className].filter(Boolean).join(' ');
|
||||
const contentCls = flush ? styles.contentFlush : styles.content;
|
||||
|
||||
return (
|
||||
<details
|
||||
className={cls}
|
||||
open={resolvedOpen}
|
||||
onToggle={(event) => {
|
||||
if (open === undefined) {
|
||||
setUncontrolledOpen(event.currentTarget.open);
|
||||
}
|
||||
onToggle?.(event);
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.summaryLabel}>
|
||||
<span>{label}</span>
|
||||
{hint ? <span className={styles.summaryHint}>{hint}</span> : null}
|
||||
</span>
|
||||
<span className={styles.chevron} aria-hidden="true">
|
||||
<IconChevronDown size={16} />
|
||||
</span>
|
||||
</summary>
|
||||
<div className={contentCls}>{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/ui/Collapsible/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Collapsible } from './Collapsible';
|
||||
25
frontend/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { IconInbox } from './icons';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-content">
|
||||
<div className="empty-icon" aria-hidden="true">
|
||||
<IconInbox size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="empty-title">{title}</div>
|
||||
{description && <div className="empty-desc">{description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{action && <div className="empty-action">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useId, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
/** 渲染在标签正下方的小字行(如赞助跳转链接)。 */
|
||||
labelExtra?: ReactNode;
|
||||
/** 渲染在标签上方的占位行(用于与同排带 labelExtra 的字段保持输入框对齐)。 */
|
||||
topExtra?: ReactNode;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
labelExtra,
|
||||
topExtra,
|
||||
hint,
|
||||
error,
|
||||
rightElement,
|
||||
className = '',
|
||||
id,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const generatedId = useId();
|
||||
const inputId = id ?? generatedId;
|
||||
const hintId = hint ? `${inputId}-hint` : undefined;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const describedBy =
|
||||
[rest['aria-describedby'], errorId, hintId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
{topExtra}
|
||||
{label && <label htmlFor={inputId}>{label}</label>}
|
||||
{labelExtra}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={inputId}
|
||||
className={`input ${className}`.trim()}
|
||||
aria-invalid={Boolean(error) || rest['aria-invalid']}
|
||||
aria-describedby={describedBy}
|
||||
{...rest}
|
||||
/>
|
||||
{rightElement && (
|
||||
<div
|
||||
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
{rightElement}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && (
|
||||
<div id={hintId} className="hint">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div id={errorId} className="error-box">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function LoadingSpinner({
|
||||
size = 20,
|
||||
className = '',
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`loading-spinner${className ? ` ${className}` : ''}`}
|
||||
style={{ width: size, height: size, borderWidth: size / 7 }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
/>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/ui/Modal.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconX } from './icons';
|
||||
import { FOCUSABLE_SELECTOR, lockScroll, unlockScroll } from './scrollLock';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title?: ReactNode;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: number | string;
|
||||
className?: string;
|
||||
closeDisabled?: boolean;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
footer,
|
||||
width = 520,
|
||||
className,
|
||||
closeDisabled = false,
|
||||
children,
|
||||
}: PropsWithChildren<ModalProps>) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const getFocusableElements = useCallback(() => {
|
||||
if (!modalRef.current) return [] as HTMLElement[];
|
||||
return Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(element) => !element.hasAttribute('disabled') && element.tabIndex !== -1
|
||||
);
|
||||
}, []);
|
||||
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
if (closeTimerRef.current !== null) return;
|
||||
setIsClosing(true);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsClosing(false);
|
||||
closeTimerRef.current = null;
|
||||
if (notifyParent) {
|
||||
onClose();
|
||||
}
|
||||
}, CLOSE_ANIMATION_DURATION);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
});
|
||||
} else if (isVisible) {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
startClose(false);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
startClose(true);
|
||||
}, [startClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldLockScroll = open || isVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockScroll) return;
|
||||
lockScroll();
|
||||
return () => unlockScroll();
|
||||
}, [shouldLockScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
previouslyFocusedRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const firstFocusable = getFocusableElements()[0];
|
||||
(firstFocusable ?? closeButtonRef.current ?? modalRef.current)?.focus();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
};
|
||||
}, [getFocusableElements, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || isVisible) return;
|
||||
previouslyFocusedRef.current?.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}, [isVisible, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (closeDisabled) return;
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
modalRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement || activeElement === modalRef.current) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeDisabled, getFocusableElements, handleClose, open]);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
|
||||
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={modalClass}
|
||||
style={{ width, maxWidth: '100%' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
className="modal-close-floating"
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
aria-label={t('common.close')}
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title" id={title ? titleId : undefined}>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return modalContent;
|
||||
}
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
124
frontend/src/components/ui/Select.module.scss
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
@use '../../styles/mixins' as *;
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wrapFullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-primary);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
var(--shadow),
|
||||
0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
|
||||
&[aria-expanded='true'] {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
var(--shadow),
|
||||
0 0 0 3px rgba($primary-color, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerSm {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.triggerText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
display: inline-flex;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
[aria-expanded='true'] > & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 12px;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.optionActive {
|
||||
border-color: rgba($primary-color, 0.5);
|
||||
background: rgba($primary-color, 0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.optionHighlighted {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
342
frontend/src/components/ui/Select.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconChevronDown } from './icons';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string;
|
||||
options: ReadonlyArray<SelectOption>;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
ariaDescribedBy?: string;
|
||||
fullWidth?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const DROPDOWN_OFFSET = 6;
|
||||
const DROPDOWN_MAX_HEIGHT = 240;
|
||||
const DROPDOWN_Z_INDEX = 2010;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
|
||||
const left = clamp(
|
||||
rect.left,
|
||||
VIEWPORT_MARGIN,
|
||||
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
|
||||
);
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
|
||||
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
|
||||
const direction = spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
|
||||
const maxHeight = Math.max(
|
||||
0,
|
||||
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
|
||||
);
|
||||
|
||||
return direction === 'down'
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: rect.bottom + DROPDOWN_OFFSET,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
zIndex: DROPDOWN_Z_INDEX,
|
||||
}
|
||||
: {
|
||||
position: 'fixed',
|
||||
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
|
||||
left,
|
||||
width,
|
||||
maxHeight,
|
||||
zIndex: DROPDOWN_Z_INDEX,
|
||||
};
|
||||
};
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
ariaDescribedBy,
|
||||
fullWidth = true,
|
||||
size = 'md',
|
||||
id,
|
||||
}: SelectProps) {
|
||||
const generatedId = useId();
|
||||
const selectId = id ?? generatedId;
|
||||
const listboxId = `${selectId}-listbox`;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||
const isOpen = open && !disabled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disabled) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [disabled, open]);
|
||||
|
||||
const updateDropdownStyle = useCallback(() => {
|
||||
if (!wrapRef.current) return;
|
||||
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
|
||||
}, []);
|
||||
|
||||
const scheduleDropdownStyleUpdate = useCallback(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
updateDropdownStyle();
|
||||
});
|
||||
}, [updateDropdownStyle]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (rafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateDropdownStyle();
|
||||
|
||||
const handleViewportChange = () => {
|
||||
scheduleDropdownStyleUpdate();
|
||||
};
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && wrapRef.current
|
||||
? new ResizeObserver(() => {
|
||||
scheduleDropdownStyleUpdate();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (resizeObserver && wrapRef.current) {
|
||||
resizeObserver.observe(wrapRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
resizeObserver?.disconnect();
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
|
||||
|
||||
const selectedIndex = useMemo(
|
||||
() => options.findIndex((option) => option.value === value),
|
||||
[options, value]
|
||||
);
|
||||
const resolvedHighlightedIndex =
|
||||
highlightedIndex >= 0
|
||||
? highlightedIndex
|
||||
: selectedIndex >= 0
|
||||
? selectedIndex
|
||||
: options.length > 0
|
||||
? 0
|
||||
: -1;
|
||||
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;
|
||||
const displayText = selected?.label ?? placeholder ?? '';
|
||||
const isPlaceholder = !selected && placeholder;
|
||||
|
||||
const commitSelection = useCallback(
|
||||
(nextIndex: number) => {
|
||||
const nextOption = options[nextIndex];
|
||||
if (!nextOption) return;
|
||||
onChange(nextOption.value);
|
||||
setOpen(false);
|
||||
setHighlightedIndex(nextIndex);
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const moveHighlight = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (options.length === 0) return;
|
||||
const nextIndex = (resolvedHighlightedIndex + direction + options.length) % options.length;
|
||||
setHighlightedIndex(nextIndex);
|
||||
},
|
||||
[options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
moveHighlight(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
moveHighlight(-1);
|
||||
return;
|
||||
case 'Home':
|
||||
if (!isOpen || options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(0);
|
||||
return;
|
||||
case 'End':
|
||||
if (!isOpen || options.length === 0) return;
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(options.length - 1);
|
||||
return;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault();
|
||||
if (!isOpen) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (resolvedHighlightedIndex >= 0) {
|
||||
commitSelection(resolvedHighlightedIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'Escape':
|
||||
if (!isOpen) return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
case 'Tab':
|
||||
if (isOpen) setOpen(false);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || resolvedHighlightedIndex < 0) return;
|
||||
const highlightedOption = document.getElementById(
|
||||
`${selectId}-option-${resolvedHighlightedIndex}`
|
||||
);
|
||||
highlightedOption?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isOpen, resolvedHighlightedIndex, selectId]);
|
||||
|
||||
const dropdown =
|
||||
isOpen && dropdownStyle ? (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={styles.dropdown}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-label={ariaLabel}
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{options.map((opt, index) => {
|
||||
const active = opt.value === value;
|
||||
const highlighted = index === resolvedHighlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`${selectId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => commitSelection(index)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||
ref={wrapRef}
|
||||
>
|
||||
<button
|
||||
id={selectId}
|
||||
type="button"
|
||||
className={`${styles.trigger} ${size === 'sm' ? styles.triggerSm : ''}`.trim()}
|
||||
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? listboxId : undefined}
|
||||
aria-activedescendant={
|
||||
isOpen && resolvedHighlightedIndex >= 0
|
||||
? `${selectId}-option-${resolvedHighlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className={styles.triggerIcon} aria-hidden="true">
|
||||
<IconChevronDown size={14} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{dropdown &&
|
||||
(typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/ui/SelectionCheckbox.module.scss
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
@use '../../styles/variables' as *;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||
color: var(--primary-contrast, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
border-color $transition-fast,
|
||||
background-color $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
transform $transition-fast;
|
||||
}
|
||||
|
||||
.root:hover .box {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
.root:active .box {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.disabled:hover .box {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.disabled:active .box {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.input:focus-visible + .box {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow:
|
||||
0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--primary-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.boxChecked {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.boxChecked svg {
|
||||
display: block;
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
50
frontend/src/components/ui/SelectionCheckbox.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import { IconCheck } from './icons';
|
||||
import styles from './SelectionCheckbox.module.scss';
|
||||
|
||||
interface SelectionCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export function SelectionCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
ariaLabel,
|
||||
title,
|
||||
disabled = false,
|
||||
className,
|
||||
labelClassName,
|
||||
}: SelectionCheckboxProps) {
|
||||
const rootClassName = [styles.root, disabled ? styles.disabled : '', className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const boxClassName = [styles.box, checked ? styles.boxChecked : ''].filter(Boolean).join(' ');
|
||||
const textClassName = [styles.label, labelClassName].filter(Boolean).join(' ');
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={rootClassName} title={title}>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
|
||||
{label ? <div className={textClassName}>{label}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/ui/Sheet/Sheet.module.scss
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
&.entering {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.exiting {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
box-shadow: var(--floating-shadow);
|
||||
transform: translateX(100%);
|
||||
transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
|
||||
&.entering {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.exiting {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.sizeMd {
|
||||
width: min(640px, 100vw);
|
||||
}
|
||||
|
||||
.sizeLg {
|
||||
width: min(720px, 100vw);
|
||||
}
|
||||
|
||||
.sizeXl {
|
||||
width: min(960px, 100vw);
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
padding: 20px 56px 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color $transition-fast,
|
||||
color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
width: 100vw !important;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px 52px 16px 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
261
frontend/src/components/ui/Sheet/Sheet.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconX } from '../icons';
|
||||
import { FOCUSABLE_SELECTOR, lockScroll, unlockScroll } from '../scrollLock';
|
||||
import styles from './Sheet.module.scss';
|
||||
|
||||
export type SheetSize = 'md' | 'lg' | 'xl';
|
||||
|
||||
interface SheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
size?: SheetSize;
|
||||
eyebrow?: ReactNode;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
closeDisabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* If provided, called before starting the close animation when the user
|
||||
* triggers a close (Escape, overlay click, or close button). Return false
|
||||
* (or a Promise that resolves to false) to keep the sheet open.
|
||||
*/
|
||||
confirmClose?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 280;
|
||||
const SIZE_CLASS: Record<SheetSize, string> = {
|
||||
md: styles.sizeMd,
|
||||
lg: styles.sizeLg,
|
||||
xl: styles.sizeXl,
|
||||
};
|
||||
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
size = 'md',
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
closeDisabled = false,
|
||||
className,
|
||||
ariaLabel,
|
||||
confirmClose,
|
||||
children,
|
||||
}: PropsWithChildren<SheetProps>) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const descId = useId();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sheetRef = useRef<HTMLDivElement | null>(null);
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const getFocusableElements = useCallback(() => {
|
||||
if (!sheetRef.current) return [] as HTMLElement[];
|
||||
return Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.hasAttribute('disabled') && el.tabIndex !== -1
|
||||
);
|
||||
}, []);
|
||||
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
if (closeTimerRef.current !== null) return;
|
||||
setIsClosing(true);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsClosing(false);
|
||||
closeTimerRef.current = null;
|
||||
if (notifyParent) {
|
||||
onClose();
|
||||
}
|
||||
}, CLOSE_ANIMATION_DURATION);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
});
|
||||
} else if (isVisible) {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
startClose(false);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (confirmClose) {
|
||||
try {
|
||||
const ok = await confirmClose();
|
||||
if (ok === false) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
startClose(true);
|
||||
}, [confirmClose, startClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldLockScroll = open || isVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockScroll) return;
|
||||
lockScroll();
|
||||
return () => unlockScroll();
|
||||
}, [shouldLockScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previouslyFocusedRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const t = window.setTimeout(() => {
|
||||
if (bodyRef.current) bodyRef.current.scrollTop = 0;
|
||||
const first = getFocusableElements()[0];
|
||||
(first ?? closeBtnRef.current ?? sheetRef.current)?.focus({ preventScroll: true });
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [getFocusableElements, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || isVisible) return;
|
||||
previouslyFocusedRef.current?.focus();
|
||||
previouslyFocusedRef.current = null;
|
||||
}, [isVisible, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (closeDisabled) return;
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusables = getFocusableElements();
|
||||
if (focusables.length === 0) {
|
||||
event.preventDefault();
|
||||
sheetRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const firstEl = focusables[0];
|
||||
const lastEl = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (event.shiftKey) {
|
||||
if (active === firstEl || active === sheetRef.current) {
|
||||
event.preventDefault();
|
||||
lastEl.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (active === lastEl) {
|
||||
event.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [closeDisabled, getFocusableElements, handleClose, open]);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const stateClass = isClosing ? styles.exiting : styles.entering;
|
||||
const overlayCls = `${styles.overlay} ${stateClass}`.trim();
|
||||
const contentCls = [styles.content, SIZE_CLASS[size], stateClass, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={overlayCls}
|
||||
role="presentation"
|
||||
onMouseDown={(e) => {
|
||||
if (closeDisabled) return;
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={contentCls}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-describedby={description ? descId : undefined}
|
||||
aria-label={!title && ariaLabel ? ariaLabel : undefined}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
disabled={closeDisabled}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
{(eyebrow || title || description) && (
|
||||
<div className={styles.header}>
|
||||
{eyebrow ? <div className={styles.eyebrow}>{eyebrow}</div> : null}
|
||||
{title ? (
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p id={descId} className={styles.description}>
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bodyRef} className={styles.body}>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? <div className={styles.footer}>{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return content;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
2
frontend/src/components/ui/Sheet/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Sheet } from './Sheet';
|
||||
export type { SheetSize } from './Sheet';
|
||||
31
frontend/src/components/ui/Skeleton/Skeleton.module.scss
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 0%,
|
||||
var(--bg-hover) 50%,
|
||||
var(--bg-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
19
frontend/src/components/ui/Skeleton/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { CSSProperties, HTMLAttributes } from 'react';
|
||||
import styles from './Skeleton.module.scss';
|
||||
|
||||
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
rounded?: number | string;
|
||||
}
|
||||
|
||||
export function Skeleton({ width, height, rounded, className, style, ...rest }: SkeletonProps) {
|
||||
const merged: CSSProperties = {
|
||||
...style,
|
||||
width: width ?? style?.width,
|
||||
height: height ?? style?.height,
|
||||
borderRadius: rounded ?? style?.borderRadius,
|
||||
};
|
||||
const cls = [styles.skeleton, className].filter(Boolean).join(' ');
|
||||
return <div className={cls} style={merged} aria-hidden="true" {...rest} />;
|
||||
}
|
||||
1
frontend/src/components/ui/Skeleton/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Skeleton } from './Skeleton';
|
||||
70
frontend/src/components/ui/Table/Table.module.scss
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.wrap {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.head {
|
||||
background: var(--muted-bg);
|
||||
|
||||
th {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th.alignRight {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.row {
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&:hover td {
|
||||
background: color-mix(in srgb, var(--accent-bg) 50%, transparent);
|
||||
}
|
||||
|
||||
&.selected td {
|
||||
background: var(--primary-8);
|
||||
}
|
||||
}
|
||||
|
||||
.body .row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.alignRight {
|
||||
text-align: right;
|
||||
}
|
||||
106
frontend/src/components/ui/Table/Table.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type {
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
TableHTMLAttributes,
|
||||
TdHTMLAttributes,
|
||||
ThHTMLAttributes,
|
||||
} from 'react';
|
||||
import styles from './Table.module.scss';
|
||||
|
||||
interface TableProps extends TableHTMLAttributes<HTMLTableElement> {
|
||||
className?: string;
|
||||
cols?: ReactNode;
|
||||
}
|
||||
|
||||
export function Table({ children, cols, className, ...rest }: PropsWithChildren<TableProps>) {
|
||||
const tableCls = [styles.table, className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.scroll}>
|
||||
<table className={tableCls} {...rest}>
|
||||
{cols ? <colgroup>{cols}</colgroup> : null}
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: PropsWithChildren<HTMLAttributes<HTMLTableSectionElement>>) {
|
||||
return (
|
||||
<thead className={[styles.head, className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableBody({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: PropsWithChildren<HTMLAttributes<HTMLTableSectionElement>>) {
|
||||
return (
|
||||
<tbody className={[styles.body, className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
children,
|
||||
className,
|
||||
selected,
|
||||
...rest
|
||||
}: PropsWithChildren<TableRowProps>) {
|
||||
const cls = [styles.row, selected ? styles.selected : null, className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<tr className={cls} {...rest}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
|
||||
alignRight?: boolean;
|
||||
}
|
||||
|
||||
export function TableHead({
|
||||
children,
|
||||
className,
|
||||
alignRight,
|
||||
...rest
|
||||
}: PropsWithChildren<TableHeadProps>) {
|
||||
const cls = [alignRight ? styles.alignRight : null, className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<th className={cls || undefined} {...rest}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
|
||||
alignRight?: boolean;
|
||||
}
|
||||
|
||||
export function TableCell({
|
||||
children,
|
||||
className,
|
||||
alignRight,
|
||||
...rest
|
||||
}: PropsWithChildren<TableCellProps>) {
|
||||
const cls = [alignRight ? styles.alignRight : null, className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<td className={cls || undefined} {...rest}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/ui/Table/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table';
|
||||
58
frontend/src/components/ui/ToggleSwitch.module.scss
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.labelLeft {
|
||||
.label {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.root input {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.track {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
border-radius: $radius-full;
|
||||
position: relative;
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: transform $transition-fast;
|
||||
}
|
||||
|
||||
.root input:checked + .track {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.root input:checked + .track .thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
48
frontend/src/components/ui/ToggleSwitch.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import styles from './ToggleSwitch.module.scss';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
labelPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
labelPosition = 'right',
|
||||
}: ToggleSwitchProps) {
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.checked);
|
||||
};
|
||||
|
||||
const className = [
|
||||
styles.root,
|
||||
labelPosition === 'left' ? styles.labelLeft : '',
|
||||
disabled ? styles.disabled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<span className={styles.track}>
|
||||
<span className={styles.thumb} />
|
||||
</span>
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
503
frontend/src/components/ui/icons.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import type { SVGProps } from 'react';
|
||||
|
||||
// Inline SVG icons (Lucide, ISC) avoid separate icon requests.
|
||||
// Source: https://github.com/lucide-icons/lucide (via lucide-static).
|
||||
|
||||
export interface IconProps extends SVGProps<SVGSVGElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const baseSvgProps: SVGProps<SVGSVGElement> = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
focusable: 'false',
|
||||
};
|
||||
|
||||
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconKey({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
|
||||
<path d="m21 2-9.6 9.6" />
|
||||
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBot({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M15 13v2" />
|
||||
<path d="M9 13v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconModelCluster({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3" y="5" width="6" height="6" rx="1.5" />
|
||||
<rect x="15" y="5" width="6" height="6" rx="1.5" />
|
||||
<rect x="9" y="13" width="6" height="6" rx="1.5" />
|
||||
<path d="M9 8h6" />
|
||||
<path d="M12 11v2" />
|
||||
<path d="M7.5 11v2" />
|
||||
<path d="M16.5 11v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFilterAll({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="3.5" y="3.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="15.5" y="3.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="3.5" y="15.5" width="5" height="5" rx="1.4" />
|
||||
<rect x="15.5" y="15.5" width="5" height="5" rx="1.4" />
|
||||
<path d="M8.5 8.5 10.75 10.75" />
|
||||
<path d="M15.5 8.5 13.25 10.75" />
|
||||
<path d="M8.5 15.5 10.75 13.25" />
|
||||
<path d="M15.5 15.5 13.25 13.25" />
|
||||
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFileText({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconShield({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSettings({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlug({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M12 22v-5" />
|
||||
<path d="M9 8V2" />
|
||||
<path d="M15 8V2" />
|
||||
<path d="M6 8h12v4a6 6 0 0 1-12 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconScrollText({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M15 12h-5" />
|
||||
<path d="M15 8h-5" />
|
||||
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
|
||||
<path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconInfo({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRefreshCw({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconDownload({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M12 15V3" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconUpload({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M12 3v12" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m17 8-5-5-5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash2({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMaximize2({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="m21 3-7 7" />
|
||||
<path d="M9 21H3v-6" />
|
||||
<path d="m3 21 7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMinimize2({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M4 14h6v6" />
|
||||
<path d="m10 14-7 7" />
|
||||
<path d="M20 10h-6V4" />
|
||||
<path d="m14 10 7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconAlertTriangle({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheckCircle2({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconNetwork({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect x="16" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="2" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="2" width="6" height="6" rx="1" />
|
||||
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
|
||||
<path d="M12 12V8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLoader2({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronUp({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m21 21-4.34-4.34" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconX({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEye({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconEyeOff({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconInbox({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSatellite({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m13.5 6.5-3.148-3.148a1.205 1.205 0 0 0-1.704 0L6.352 5.648a1.205 1.205 0 0 0 0 1.704L9.5 10.5" />
|
||||
<path d="M16.5 7.5 19 5" />
|
||||
<path d="m17.5 10.5 3.148 3.148a1.205 1.205 0 0 1 0 1.704l-2.296 2.296a1.205 1.205 0 0 1-1.704 0L13.5 14.5" />
|
||||
<path d="M9 21a6 6 0 0 0-6-6" />
|
||||
<path d="M9.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l4.296-4.296a1.205 1.205 0 0 0 0-1.704l-2.296-2.296a1.205 1.205 0 0 0-1.704 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTimer({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<line x1="10" x2="14" y1="2" y2="2" />
|
||||
<line x1="12" x2="15" y1="14" y2="11" />
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconDollarSign({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<line x1="12" x2="12" y1="2" y2="22" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGithub({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHeart({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconExternalLink({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBookOpen({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCode({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarDashboard({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect width="7" height="9" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||
<rect width="7" height="5" x="3" y="16" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarQuickStart({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconSidebarConfig = IconSlidersHorizontal;
|
||||
|
||||
export const IconSidebarPlugins = IconPlug;
|
||||
|
||||
export function IconSidebarStore({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7" />
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4" />
|
||||
<path d="M2 7h20" />
|
||||
<path d="M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconSidebarProviders = IconNetwork;
|
||||
|
||||
export function IconSidebarAuthFiles({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarOauth({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M2 21a8 8 0 0 1 13.292-6" />
|
||||
<circle cx="10" cy="8" r="5" />
|
||||
<path d="m16 19 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSidebarQuota({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="m12 14 4-4" />
|
||||
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconSidebarLogs = IconScrollText;
|
||||
|
||||
export function IconSidebarSystem({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" />
|
||||
<line x1="6" x2="6.01" y1="6" y2="6" />
|
||||
<line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/ui/scrollLock.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const MODAL_LOCK_CLASS = 'modal-open';
|
||||
|
||||
let activeLockCount = 0;
|
||||
|
||||
const snapshot = {
|
||||
scrollY: 0,
|
||||
contentScrollTop: 0,
|
||||
contentEl: null as HTMLElement | null,
|
||||
bodyPosition: '',
|
||||
bodyTop: '',
|
||||
bodyLeft: '',
|
||||
bodyRight: '',
|
||||
bodyWidth: '',
|
||||
bodyOverflow: '',
|
||||
htmlOverflow: '',
|
||||
};
|
||||
|
||||
const resolveContentScrollContainer = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const contentEl = document.querySelector('.content');
|
||||
return contentEl instanceof HTMLElement ? contentEl : null;
|
||||
};
|
||||
|
||||
export function lockScroll(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (activeLockCount === 0) {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const contentEl = resolveContentScrollContainer();
|
||||
|
||||
snapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
|
||||
snapshot.contentEl = contentEl;
|
||||
snapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
|
||||
snapshot.bodyPosition = body.style.position;
|
||||
snapshot.bodyTop = body.style.top;
|
||||
snapshot.bodyLeft = body.style.left;
|
||||
snapshot.bodyRight = body.style.right;
|
||||
snapshot.bodyWidth = body.style.width;
|
||||
snapshot.bodyOverflow = body.style.overflow;
|
||||
snapshot.htmlOverflow = html.style.overflow;
|
||||
|
||||
body.classList.add(MODAL_LOCK_CLASS);
|
||||
html.classList.add(MODAL_LOCK_CLASS);
|
||||
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${snapshot.scrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
body.style.overflow = 'hidden';
|
||||
html.style.overflow = 'hidden';
|
||||
}
|
||||
activeLockCount += 1;
|
||||
}
|
||||
|
||||
export function unlockScroll(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
activeLockCount = Math.max(0, activeLockCount - 1);
|
||||
if (activeLockCount === 0) {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
const scrollY = snapshot.scrollY;
|
||||
const contentScrollTop = snapshot.contentScrollTop;
|
||||
const contentEl = snapshot.contentEl;
|
||||
|
||||
body.classList.remove(MODAL_LOCK_CLASS);
|
||||
html.classList.remove(MODAL_LOCK_CLASS);
|
||||
|
||||
body.style.position = snapshot.bodyPosition;
|
||||
body.style.top = snapshot.bodyTop;
|
||||
body.style.left = snapshot.bodyLeft;
|
||||
body.style.right = snapshot.bodyRight;
|
||||
body.style.width = snapshot.bodyWidth;
|
||||
body.style.overflow = snapshot.bodyOverflow;
|
||||
html.style.overflow = snapshot.htmlOverflow;
|
||||
|
||||
if (contentEl) {
|
||||
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
|
||||
}
|
||||
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
|
||||
|
||||
snapshot.scrollY = 0;
|
||||
snapshot.contentScrollTop = 0;
|
||||
snapshot.contentEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
102
frontend/src/features/authFiles/AuthFilesPage.module.scss
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
@use '../../styles/mixins' as *;
|
||||
|
||||
/* ============================================================
|
||||
* 认证文件页壳层:纯布局,视觉细节都在各组件的 colocated 模块里。
|
||||
* ============================================================ */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
/* 底部为悬浮批量条留出实时高度 */
|
||||
padding-bottom: calc(
|
||||
var(--auth-files-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 工作区(tabs + 工具栏 + 网格 + 分页) ---------- */
|
||||
|
||||
.workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--danger-color);
|
||||
background: var(--bg-error-light);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- 卡片网格 ---------- */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gridCompact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
|
||||
}
|
||||
|
||||
/* provider tab 激活(配额模式)时卡片承载配额区,需要更宽的列。
|
||||
值与 .grid 相同但**必须保留且排在 .gridCompact 之后**:三个类同时挂在网格上,
|
||||
同specificity 靠源序决胜,删掉这条会让「紧凑 + 配额」从 340 掉回 260。 */
|
||||
.gridQuota {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
|
||||
}
|
||||
|
||||
.emptyActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ---------- 分页 ---------- */
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- OAuth 配置双卡 ---------- */
|
||||
|
||||
.configGrid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.page {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
808
frontend/src/features/authFiles/AuthFilesPage.tsx
Normal file
|
|
@ -0,0 +1,808 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useRevealOnScroll } from '@/hooks/motion';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import {
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
clampCardPageSize,
|
||||
getTypeLabel,
|
||||
isProblemAuthFile,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeProviderKey,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme,
|
||||
} from '@/features/authFiles/constants';
|
||||
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
|
||||
import { AuthFileDetailsSheet } from '@/features/authFiles/components/AuthFileDetailsSheet';
|
||||
import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileModelsModal';
|
||||
import { AuthFilesToolbar } from '@/features/authFiles/components/AuthFilesToolbar';
|
||||
import { BatchActionBar } from '@/features/authFiles/components/BatchActionBar';
|
||||
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
|
||||
import { OAuthModelAliasCard } from '@/features/authFiles/components/OAuthModelAliasCard';
|
||||
import { ProviderTabs } from '@/features/authFiles/components/ProviderTabs';
|
||||
import { VaultHeader } from '@/features/authFiles/components/VaultHeader';
|
||||
import { VaultPulse } from '@/features/authFiles/components/VaultPulse';
|
||||
import { invalidateAuthFileDerivedCaches } from '@/features/authFiles/cacheInvalidation';
|
||||
import {
|
||||
buildWildcardSearch,
|
||||
matchesAuthFileSearch,
|
||||
sortAuthFiles,
|
||||
} from '@/features/authFiles/logic';
|
||||
import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
|
||||
import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels';
|
||||
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
|
||||
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||
import {
|
||||
isAuthFilesStatusFilterMode,
|
||||
isAuthFilesSortMode,
|
||||
readAuthFilesUiState,
|
||||
readPersistedAuthFilesCompactMode,
|
||||
writeAuthFilesUiState,
|
||||
writePersistedAuthFilesCompactMode,
|
||||
type AuthFilesStatusFilterMode,
|
||||
type AuthFilesSortMode,
|
||||
} from '@/features/authFiles/uiState';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import styles from './AuthFilesPage.module.scss';
|
||||
|
||||
const DEFAULT_REGULAR_PAGE_SIZE = 9;
|
||||
const DEFAULT_COMPACT_PAGE_SIZE = 12;
|
||||
const SKELETON_CARD_COUNT = 6;
|
||||
/** 首屏卡片级联入场总预算,与 useRevealGroup 同一 360ms 语汇。 */
|
||||
const CARD_ENTRANCE_BUDGET_MS = 360;
|
||||
|
||||
const resolveStatusFilterMode = (
|
||||
problemOnly: boolean,
|
||||
disabledOnly: boolean
|
||||
): AuthFilesStatusFilterMode => {
|
||||
if (problemOnly) return 'problem';
|
||||
if (disabledOnly) return 'disabled';
|
||||
return 'all';
|
||||
};
|
||||
|
||||
const normalizePersistedStatusFilterMode = (value: unknown): AuthFilesStatusFilterMode | null => {
|
||||
if (value === 'disabledProblem') return 'problem';
|
||||
return isAuthFilesStatusFilterMode(value) ? value : null;
|
||||
};
|
||||
|
||||
export function AuthFilesPage() {
|
||||
const { t } = useTranslation();
|
||||
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [filter, setFilter] = useState<'all' | string>('all');
|
||||
const [statusFilterMode, setStatusFilterMode] = useState<AuthFilesStatusFilterMode>('all');
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSizeByMode, setPageSizeByMode] = useState({
|
||||
regular: DEFAULT_REGULAR_PAGE_SIZE,
|
||||
compact: DEFAULT_COMPACT_PAGE_SIZE,
|
||||
});
|
||||
const [pageSizeInput, setPageSizeInput] = useState('9');
|
||||
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
|
||||
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
|
||||
const [uiStateHydrated, setUiStateHydrated] = useState(false);
|
||||
|
||||
const {
|
||||
modelsModalOpen,
|
||||
modelsLoading,
|
||||
modelsList,
|
||||
modelsFileName,
|
||||
modelsFileType,
|
||||
modelsError,
|
||||
showModels,
|
||||
closeModelsModal,
|
||||
invalidateModels,
|
||||
} = useAuthFilesModels();
|
||||
|
||||
const invalidateDerivedCaches = useCallback(
|
||||
(names?: string[]) => invalidateAuthFileDerivedCaches(invalidateModels, names),
|
||||
[invalidateModels]
|
||||
);
|
||||
|
||||
const {
|
||||
files,
|
||||
selectedFiles,
|
||||
selectionCount,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
uploading,
|
||||
deleting,
|
||||
deletingAll,
|
||||
statusUpdating,
|
||||
manualRefreshing,
|
||||
batchStatusUpdating,
|
||||
fileInputRef,
|
||||
loadFiles,
|
||||
handleUploadClick,
|
||||
handleFileChange,
|
||||
handleDelete,
|
||||
handleDeleteAll,
|
||||
handleDownload,
|
||||
handleManualRefresh,
|
||||
handleStatusToggle,
|
||||
toggleSelect,
|
||||
selectAllVisible,
|
||||
invertVisibleSelection,
|
||||
deselectAll,
|
||||
batchDownload,
|
||||
batchSetStatus,
|
||||
batchDelete,
|
||||
} = useAuthFilesData({ onFilesMutated: invalidateDerivedCaches });
|
||||
|
||||
const statusBarCache = useAuthFilesStatusBarCache(files);
|
||||
|
||||
const {
|
||||
excluded,
|
||||
excludedError,
|
||||
modelAlias,
|
||||
modelAliasError,
|
||||
allProviderModels,
|
||||
loadExcluded,
|
||||
loadModelAlias,
|
||||
deleteExcluded,
|
||||
deleteModelAlias,
|
||||
handleMappingUpdate,
|
||||
handleDeleteLink,
|
||||
handleToggleFork,
|
||||
handleRenameAlias,
|
||||
handleDeleteAlias,
|
||||
} = useAuthFilesOauth({ viewMode, files });
|
||||
|
||||
const {
|
||||
prefixProxyEditor,
|
||||
prefixProxyUpdatedText,
|
||||
prefixProxyDirty,
|
||||
openPrefixProxyEditor,
|
||||
closePrefixProxyEditor,
|
||||
handlePrefixProxyChange,
|
||||
handlePrefixProxySave,
|
||||
} = useAuthFilesPrefixProxyEditor({
|
||||
disableControls: connectionStatus !== 'connected',
|
||||
loadFiles,
|
||||
});
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
const normalizedFilter = normalizeProviderKey(String(filter));
|
||||
const quotaFilterType: QuotaProviderType | null = QUOTA_PROVIDER_TYPES.has(
|
||||
normalizedFilter as QuotaProviderType
|
||||
)
|
||||
? (normalizedFilter as QuotaProviderType)
|
||||
: null;
|
||||
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
|
||||
const problemOnly = statusFilterMode === 'problem';
|
||||
const disabledOnly = statusFilterMode === 'disabled';
|
||||
const enabledOnly = statusFilterMode === 'enabled';
|
||||
|
||||
/* ---------- uiState 水合与持久化(localStorage key/形状与旧版完全一致) ---------- */
|
||||
|
||||
useEffect(() => {
|
||||
const persistedCompactMode = readPersistedAuthFilesCompactMode();
|
||||
if (typeof persistedCompactMode === 'boolean') {
|
||||
setCompactMode(persistedCompactMode);
|
||||
}
|
||||
|
||||
const persisted = readAuthFilesUiState();
|
||||
if (persisted) {
|
||||
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
|
||||
setFilter(normalizeProviderKey(persisted.filter));
|
||||
}
|
||||
const persistedStatusFilterMode = normalizePersistedStatusFilterMode(
|
||||
persisted.statusFilterMode
|
||||
);
|
||||
if (persistedStatusFilterMode) {
|
||||
setStatusFilterMode(persistedStatusFilterMode);
|
||||
} else if (
|
||||
typeof persisted.problemOnly === 'boolean' ||
|
||||
typeof persisted.disabledOnly === 'boolean'
|
||||
) {
|
||||
setStatusFilterMode(
|
||||
resolveStatusFilterMode(persisted.problemOnly === true, persisted.disabledOnly === true)
|
||||
);
|
||||
}
|
||||
if (typeof persistedCompactMode !== 'boolean' && typeof persisted.compactMode === 'boolean') {
|
||||
setCompactMode(persisted.compactMode);
|
||||
}
|
||||
if (typeof persisted.search === 'string') {
|
||||
setSearch(persisted.search);
|
||||
}
|
||||
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
|
||||
setPage(Math.max(1, Math.round(persisted.page)));
|
||||
}
|
||||
const legacyPageSize =
|
||||
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
|
||||
? clampCardPageSize(persisted.pageSize)
|
||||
: null;
|
||||
const regularPageSize =
|
||||
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
|
||||
? clampCardPageSize(persisted.regularPageSize)
|
||||
: (legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE);
|
||||
const compactPageSize =
|
||||
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
|
||||
? clampCardPageSize(persisted.compactPageSize)
|
||||
: (legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE);
|
||||
setPageSizeByMode({
|
||||
regular: regularPageSize,
|
||||
compact: compactPageSize,
|
||||
});
|
||||
if (isAuthFilesSortMode(persisted.sortMode)) {
|
||||
setSortMode(persisted.sortMode);
|
||||
}
|
||||
}
|
||||
|
||||
setUiStateHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiStateHydrated) return;
|
||||
|
||||
writeAuthFilesUiState({
|
||||
filter,
|
||||
statusFilterMode,
|
||||
problemOnly,
|
||||
disabledOnly,
|
||||
compactMode,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
regularPageSize: pageSizeByMode.regular,
|
||||
compactPageSize: pageSizeByMode.compact,
|
||||
sortMode,
|
||||
});
|
||||
writePersistedAuthFilesCompactMode(compactMode);
|
||||
}, [
|
||||
compactMode,
|
||||
disabledOnly,
|
||||
filter,
|
||||
page,
|
||||
pageSize,
|
||||
pageSizeByMode,
|
||||
problemOnly,
|
||||
search,
|
||||
sortMode,
|
||||
statusFilterMode,
|
||||
uiStateHydrated,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageSizeInput(String(pageSize));
|
||||
}, [pageSize]);
|
||||
|
||||
const setCurrentModePageSize = useCallback(
|
||||
(next: number) => {
|
||||
setPageSizeByMode((current) =>
|
||||
compactMode ? { ...current, compact: next } : { ...current, regular: next }
|
||||
);
|
||||
},
|
||||
[compactMode]
|
||||
);
|
||||
|
||||
const commitPageSizeInput = useCallback(
|
||||
(rawValue: string) => {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
setPageSizeInput(String(pageSize));
|
||||
return;
|
||||
}
|
||||
|
||||
const value = Number(trimmed);
|
||||
if (!Number.isFinite(value)) {
|
||||
setPageSizeInput(String(pageSize));
|
||||
return;
|
||||
}
|
||||
|
||||
const next = clampCardPageSize(value);
|
||||
setCurrentModePageSize(next);
|
||||
setPageSizeInput(String(next));
|
||||
setPage(1);
|
||||
},
|
||||
[pageSize, setCurrentModePageSize]
|
||||
);
|
||||
|
||||
const handlePageSizeChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = event.currentTarget.value;
|
||||
setPageSizeInput(rawValue);
|
||||
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
|
||||
const rounded = Math.round(parsed);
|
||||
// 超出 [MIN, MAX] 时不提交(clamp 后不等于原值即越界)
|
||||
if (clampCardPageSize(rounded) !== rounded) return;
|
||||
|
||||
setCurrentModePageSize(rounded);
|
||||
setPage(1);
|
||||
},
|
||||
[setCurrentModePageSize]
|
||||
);
|
||||
|
||||
const handleSortModeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!isAuthFilesSortMode(value) || value === sortMode) return;
|
||||
setSortMode(value);
|
||||
setPage(1);
|
||||
},
|
||||
[sortMode]
|
||||
);
|
||||
|
||||
const handleStatusFilterModeChange = useCallback((nextMode: AuthFilesStatusFilterMode) => {
|
||||
setStatusFilterMode(nextMode);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
/* ---------- 数据加载:首载前台(骨架屏),此后一律后台(不清空网格) ---------- */
|
||||
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadFiles({ background: true }), loadExcluded(), loadModelAlias()]);
|
||||
}, [loadFiles, loadExcluded, loadModelAlias]);
|
||||
|
||||
useHeaderRefresh(handleHeaderRefresh);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentLayer) return;
|
||||
void loadFiles(initialLoadDoneRef.current ? { background: true } : undefined);
|
||||
initialLoadDoneRef.current = true;
|
||||
loadExcluded();
|
||||
loadModelAlias();
|
||||
}, [isCurrentLayer, loadFiles, loadExcluded, loadModelAlias]);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
void loadFiles({ background: true }).catch(() => {});
|
||||
},
|
||||
isCurrentLayer ? 240_000 : null
|
||||
);
|
||||
|
||||
/* ---------- 过滤 / 排序 / 分页 memos ---------- */
|
||||
|
||||
const existingTypes = useMemo(() => {
|
||||
const types = new Set<string>(['all']);
|
||||
files.forEach((file) => {
|
||||
const type = normalizeProviderKey(String(file.type ?? file.provider ?? ''));
|
||||
if (type) types.add(type);
|
||||
});
|
||||
return Array.from(types);
|
||||
}, [files]);
|
||||
|
||||
const filesMatchingStatusFilters = useMemo(
|
||||
() =>
|
||||
files.filter((file) => {
|
||||
if (enabledOnly && file.disabled === true) return false;
|
||||
if (disabledOnly && file.disabled !== true) return false;
|
||||
if (problemOnly && !isProblemAuthFile(file)) return false;
|
||||
return true;
|
||||
}),
|
||||
[disabledOnly, enabledOnly, files, problemOnly]
|
||||
);
|
||||
|
||||
const statusFilterOptions = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ value: 'all', label: t('auth_files.problem_filter_all') },
|
||||
{ value: 'enabled', label: t('auth_files.problem_filter_enabled') },
|
||||
{ value: 'disabled', label: t('auth_files.problem_filter_disabled') },
|
||||
{ value: 'problem', label: t('auth_files.problem_filter_problem') },
|
||||
] satisfies Array<{ value: AuthFilesStatusFilterMode; label: string }>,
|
||||
[t]
|
||||
);
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'default', label: t('auth_files.sort_default') },
|
||||
{ value: 'az', label: t('auth_files.sort_az') },
|
||||
{ value: 'priority', label: t('auth_files.sort_priority') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = { all: filesMatchingStatusFilters.length };
|
||||
filesMatchingStatusFilters.forEach((file) => {
|
||||
const type = normalizeProviderKey(String(file.type ?? file.provider ?? ''));
|
||||
if (!type) return;
|
||||
counts[type] = (counts[type] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [filesMatchingStatusFilters]);
|
||||
|
||||
const normalizedSearch = search.trim();
|
||||
const wildcardSearch = useMemo(() => buildWildcardSearch(normalizedSearch), [normalizedSearch]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
filesMatchingStatusFilters.filter((item) => {
|
||||
const type = normalizeProviderKey(String(item.type ?? item.provider ?? ''));
|
||||
const matchType = normalizedFilter === 'all' || type === normalizedFilter;
|
||||
return matchType && matchesAuthFileSearch(item, normalizedSearch, wildcardSearch);
|
||||
}),
|
||||
[filesMatchingStatusFilters, normalizedFilter, normalizedSearch, wildcardSearch]
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => sortAuthFiles(filtered, sortMode), [filtered, sortMode]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const pageItems = useMemo(() => sorted.slice(start, start + pageSize), [pageSize, sorted, start]);
|
||||
const selectablePageItems = useMemo(
|
||||
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[pageItems]
|
||||
);
|
||||
const selectableFilteredItems = useMemo(
|
||||
() => sorted.filter((file) => !isRuntimeOnlyAuthFile(file)),
|
||||
[sorted]
|
||||
);
|
||||
const selectedNames = useMemo(() => Array.from(selectedFiles), [selectedFiles]);
|
||||
const selectedHasStatusUpdating = useMemo(
|
||||
() => selectedNames.some((name) => statusUpdating[name] === true),
|
||||
[selectedNames, statusUpdating]
|
||||
);
|
||||
const batchStatusButtonsDisabled =
|
||||
disableControls ||
|
||||
selectedNames.length === 0 ||
|
||||
batchStatusUpdating ||
|
||||
selectedHasStatusUpdating;
|
||||
|
||||
/* ---------- 头部遥测计数 ---------- */
|
||||
|
||||
const activeCount = useMemo(() => files.filter((file) => file.disabled !== true).length, [files]);
|
||||
const problemCount = useMemo(() => files.filter(isProblemAuthFile).length, [files]);
|
||||
|
||||
/* ---------- 首屏卡片一次性级联入场 ----------
|
||||
* 首批数据渲染后立即翻转 cardsAnimated;已挂载的卡片在挂载时捕获过
|
||||
* 自己的延迟(AuthFileCard 内 useState 初始化),不受后续 null 影响,
|
||||
* 而过滤/翻页/轮询新挂载的卡片拿到 null——不重播。 */
|
||||
|
||||
const [cardsAnimated, setCardsAnimated] = useState(false);
|
||||
const enableCardEntrance = !cardsAnimated && isCurrentLayer && !loading && pageItems.length > 0;
|
||||
useEffect(() => {
|
||||
if (enableCardEntrance) {
|
||||
setCardsAnimated(true);
|
||||
}
|
||||
}, [enableCardEntrance]);
|
||||
const cardEntranceDelay = (index: number): number | null => {
|
||||
if (!enableCardEntrance) return null;
|
||||
if (pageItems.length <= 1) return 0;
|
||||
return Math.round((index / (pageItems.length - 1)) * CARD_ENTRANCE_BUDGET_MS);
|
||||
};
|
||||
|
||||
/* ---------- 杂项 ---------- */
|
||||
|
||||
const copyTextWithNotification = useCallback(
|
||||
async (text: string) => {
|
||||
const copied = await copyToClipboard(text);
|
||||
showNotification(
|
||||
copied
|
||||
? t('notification.link_copied', { defaultValue: 'Copied to clipboard' })
|
||||
: t('notification.copy_failed', { defaultValue: 'Copy failed' }),
|
||||
copied ? 'success' : 'error'
|
||||
);
|
||||
},
|
||||
[showNotification, t]
|
||||
);
|
||||
|
||||
const openExcludedEditor = useCallback(
|
||||
(provider?: string) => {
|
||||
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
|
||||
const params = new URLSearchParams();
|
||||
if (providerValue) {
|
||||
params.set('provider', providerValue);
|
||||
}
|
||||
const nextSearch = params.toString();
|
||||
navigate(`/auth-files/oauth-excluded${nextSearch ? `?${nextSearch}` : ''}`, {
|
||||
state: { fromAuthFiles: true },
|
||||
});
|
||||
},
|
||||
[filter, navigate]
|
||||
);
|
||||
|
||||
const openModelAliasEditor = useCallback(
|
||||
(provider?: string) => {
|
||||
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
|
||||
const params = new URLSearchParams();
|
||||
if (providerValue) {
|
||||
params.set('provider', providerValue);
|
||||
}
|
||||
const nextSearch = params.toString();
|
||||
navigate(`/auth-files/oauth-model-alias${nextSearch ? `?${nextSearch}` : ''}`, {
|
||||
state: { fromAuthFiles: true },
|
||||
});
|
||||
},
|
||||
[filter, navigate]
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilter('all');
|
||||
setStatusFilterMode('all');
|
||||
setSearch('');
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const deleteAllButtonLabel = (() => {
|
||||
if (enabledOnly || disabledOnly) {
|
||||
return t('auth_files.delete_filtered_result_button');
|
||||
}
|
||||
if (problemOnly) {
|
||||
return normalizedFilter === 'all'
|
||||
? t('auth_files.delete_problem_button')
|
||||
: t('auth_files.delete_problem_button_with_type', {
|
||||
type: getTypeLabel(t, normalizedFilter),
|
||||
});
|
||||
}
|
||||
return normalizedFilter === 'all'
|
||||
? t('auth_files.delete_all_button')
|
||||
: `${t('common.delete')} ${getTypeLabel(t, normalizedFilter)}`;
|
||||
})();
|
||||
|
||||
const oauthSectionRef = useRevealOnScroll<HTMLDivElement>();
|
||||
|
||||
const isFirstRunEmpty = !loading && files.length === 0 && !error;
|
||||
const isNoResults = !loading && files.length > 0 && pageItems.length === 0;
|
||||
|
||||
const gridClasses = [
|
||||
styles.grid,
|
||||
compactMode ? styles.gridCompact : '',
|
||||
quotaFilterType ? styles.gridQuota : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<VaultHeader
|
||||
totalCount={files.length}
|
||||
activeCount={activeCount}
|
||||
problemCount={problemCount}
|
||||
loading={loading}
|
||||
refreshing={refreshing}
|
||||
uploading={uploading}
|
||||
disableControls={disableControls}
|
||||
onUpload={handleUploadClick}
|
||||
onRefresh={() => void handleHeaderRefresh()}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<VaultPulse files={files} statusBarCache={statusBarCache} />
|
||||
|
||||
<section className={styles.workbench} aria-label={t('auth_files.title_section')}>
|
||||
<ProviderTabs
|
||||
types={existingTypes}
|
||||
counts={typeCounts}
|
||||
active={normalizedFilter}
|
||||
resolvedTheme={resolvedTheme}
|
||||
onChange={(type) => {
|
||||
setFilter(type);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthFilesToolbar
|
||||
search={search}
|
||||
onSearchChange={(value) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
}}
|
||||
statusFilterMode={statusFilterMode}
|
||||
statusFilterOptions={statusFilterOptions}
|
||||
onStatusFilterChange={handleStatusFilterModeChange}
|
||||
sortMode={sortMode}
|
||||
sortOptions={sortOptions}
|
||||
onSortModeChange={handleSortModeChange}
|
||||
pageSizeInput={pageSizeInput}
|
||||
onPageSizeInputChange={handlePageSizeChange}
|
||||
onPageSizeCommit={commitPageSizeInput}
|
||||
compactMode={compactMode}
|
||||
onCompactModeChange={setCompactMode}
|
||||
deleteLabel={deleteAllButtonLabel}
|
||||
deleteDisabled={disableControls || loading || deletingAll || files.length === 0}
|
||||
deleteLoading={deletingAll}
|
||||
onDelete={() =>
|
||||
handleDeleteAll({
|
||||
filter,
|
||||
problemOnly,
|
||||
disabledOnly,
|
||||
enabledOnly,
|
||||
onResetFilterToAll: () => setFilter('all'),
|
||||
onResetProblemOnly: () => setStatusFilterMode('all'),
|
||||
onResetDisabledOnly: () => setStatusFilterMode('all'),
|
||||
onResetEnabledOnly: () => setStatusFilterMode('all'),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorBanner} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={gridClasses} aria-hidden="true">
|
||||
{Array.from({ length: SKELETON_CARD_COUNT }, (_, index) => (
|
||||
<Skeleton key={index} height={206} rounded={14} />
|
||||
))}
|
||||
</div>
|
||||
) : isFirstRunEmpty ? (
|
||||
<EmptyState
|
||||
title={t('auth_files.empty_title')}
|
||||
description={t('auth_files.empty_desc')}
|
||||
action={
|
||||
<div className={styles.emptyActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUploadClick}
|
||||
disabled={disableControls || uploading}
|
||||
>
|
||||
{t('auth_files.upload_button')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/oauth')}>
|
||||
{t('auth_files.empty_oauth_link')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : isNoResults ? (
|
||||
<EmptyState
|
||||
title={t('auth_files.search_empty_title')}
|
||||
description={t('auth_files.search_empty_desc')}
|
||||
action={
|
||||
<Button variant="secondary" size="sm" onClick={clearFilters}>
|
||||
{t('auth_files.no_results_clear')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{pageItems.map((file, index) => (
|
||||
<AuthFileCard
|
||||
key={file.name}
|
||||
file={file}
|
||||
compact={compactMode}
|
||||
selected={selectedFiles.has(file.name)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
disableControls={disableControls}
|
||||
deleting={deleting}
|
||||
statusUpdating={statusUpdating}
|
||||
manualRefreshing={manualRefreshing}
|
||||
quotaFilterType={quotaFilterType}
|
||||
statusBarCache={statusBarCache}
|
||||
entranceDelayMs={cardEntranceDelay(index)}
|
||||
onShowModels={showModels}
|
||||
onDownload={handleDownload}
|
||||
onManualRefresh={handleManualRefresh}
|
||||
onOpenPrefixProxyEditor={openPrefixProxyEditor}
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleStatusToggle}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && sorted.length > pageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className={styles.pageInfo}>
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: sorted.length,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={styles.configGrid} ref={oauthSectionRef}>
|
||||
<OAuthExcludedCard
|
||||
disableControls={disableControls}
|
||||
excludedError={excludedError}
|
||||
excluded={excluded}
|
||||
onRetry={loadExcluded}
|
||||
onAdd={() => openExcludedEditor()}
|
||||
onEdit={openExcludedEditor}
|
||||
onDelete={deleteExcluded}
|
||||
/>
|
||||
|
||||
<OAuthModelAliasCard
|
||||
disableControls={disableControls}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
onRetry={loadModelAlias}
|
||||
onAdd={() => openModelAliasEditor()}
|
||||
onEditProvider={openModelAliasEditor}
|
||||
onDeleteProvider={deleteModelAlias}
|
||||
modelAliasError={modelAliasError}
|
||||
modelAlias={modelAlias}
|
||||
allProviderModels={allProviderModels}
|
||||
onUpdate={handleMappingUpdate}
|
||||
onDeleteLink={handleDeleteLink}
|
||||
onToggleFork={handleToggleFork}
|
||||
onRenameAlias={handleRenameAlias}
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthFileModelsModal
|
||||
open={modelsModalOpen}
|
||||
fileName={modelsFileName}
|
||||
fileType={modelsFileType}
|
||||
loading={modelsLoading}
|
||||
error={modelsError}
|
||||
models={modelsList}
|
||||
excluded={excluded}
|
||||
onClose={closeModelsModal}
|
||||
onCopyText={copyTextWithNotification}
|
||||
/>
|
||||
|
||||
<AuthFileDetailsSheet
|
||||
disableControls={disableControls}
|
||||
editor={prefixProxyEditor}
|
||||
updatedText={prefixProxyUpdatedText}
|
||||
dirty={prefixProxyDirty}
|
||||
onClose={closePrefixProxyEditor}
|
||||
onCopyText={copyTextWithNotification}
|
||||
onSave={handlePrefixProxySave}
|
||||
onChange={handlePrefixProxyChange}
|
||||
/>
|
||||
|
||||
<BatchActionBar
|
||||
selectionCount={selectionCount}
|
||||
selectablePageCount={selectablePageItems.length}
|
||||
selectableFilteredCount={selectableFilteredItems.length}
|
||||
disableControls={disableControls}
|
||||
batchStatusDisabled={batchStatusButtonsDisabled}
|
||||
onSelectPage={() => selectAllVisible(pageItems)}
|
||||
onSelectFiltered={() => selectAllVisible(sorted)}
|
||||
onInvertPage={() => invertVisibleSelection(pageItems)}
|
||||
onDeselectAll={deselectAll}
|
||||
onDownload={() => void batchDownload(selectedNames)}
|
||||
onEnable={() => batchSetStatus(selectedNames, true)}
|
||||
onDisable={() => batchSetStatus(selectedNames, false)}
|
||||
onDelete={() => batchDelete(selectedNames)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/features/authFiles/authFilesEvents.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const AUTH_FILES_CHANGED_EVENT = 'auth-files-changed';
|
||||
|
||||
export const notifyAuthFilesChanged = () => {
|
||||
window.dispatchEvent(new Event(AUTH_FILES_CHANGED_EVENT));
|
||||
};
|
||||
12
frontend/src/features/authFiles/cacheInvalidation.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
|
||||
type ModelsInvalidator = (names?: string[]) => void;
|
||||
|
||||
/** Invalidate every cache whose contents depend on an auth file's credentials. */
|
||||
export const invalidateAuthFileDerivedCaches = (
|
||||
invalidateModels: ModelsInvalidator,
|
||||
names?: string[]
|
||||
): void => {
|
||||
invalidateModels(names);
|
||||
useQuotaStore.getState().clearQuotaCache();
|
||||
};
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
|
||||
/* ============================================================
|
||||
* 凭证卡片 — statTile 配方(14px 圆角 / 1px 边框 / 82% 透感纸面)
|
||||
* 遥测内容一律 mono + tabular-nums;品牌色只出现在头像与类型徽章。
|
||||
* ============================================================ */
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||
transition:
|
||||
transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
|
||||
box-shadow var(--dur-hover, 200ms) var(--ease-out-strong, ease-out);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 12px 26px -14px rgb(0 0 0 / 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.cardCompact {
|
||||
gap: 10px;
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.cardSelected {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 55%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
.cardDisabled {
|
||||
background: color-mix(in srgb, var(--bg-primary) 55%, transparent);
|
||||
|
||||
.head,
|
||||
.fileName, /* 满宽文件名副行已移出 .head,必须单列 */
|
||||
.note,
|
||||
.health,
|
||||
.metaRow {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
/* 首次数据到达时的一次性级联入场;过滤/翻页/轮询不重播 */
|
||||
.cardEnter {
|
||||
animation: card-in 0.45s var(--ease-out-strong, ease-out) both;
|
||||
animation-delay: var(--card-delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 16px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 头部:勾选 + 品牌头像 + 身份 ---------- */
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.selection {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.avatarImage {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.identity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.badgeRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.5;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stateBadge {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.6;
|
||||
padding: 2px 8px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stateDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.stateActive {
|
||||
color: var(--success-badge-text);
|
||||
border-color: color-mix(in srgb, var(--viz-success) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--viz-success) 8%, transparent);
|
||||
|
||||
.stateDot {
|
||||
background: var(--viz-success);
|
||||
}
|
||||
}
|
||||
|
||||
.stateWarning {
|
||||
color: var(--amber-text);
|
||||
border-color: var(--amber-30);
|
||||
background: var(--amber-10);
|
||||
|
||||
.stateDot {
|
||||
background: var(--amber-color);
|
||||
}
|
||||
}
|
||||
|
||||
.stateDisabled {
|
||||
color: var(--text-tertiary);
|
||||
border-color: var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.stateVirtual {
|
||||
color: var(--text-secondary);
|
||||
border-style: dashed;
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
/* 主行:账号(email / 项目 ID)领衔 */
|
||||
.account {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--text-primary);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
/* 无账号线索时主行回落到文件名,沿用 mono 语汇。
|
||||
必须声明在 .account 之后——两者同为单类选择器,letter-spacing 靠源序决胜。 */
|
||||
.accountMono {
|
||||
font-family: $font-mono;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* 副行:满卡宽的文件名。email 在 codex 名字的中段,必须能换行才不丢信息,
|
||||
overflow-wrap + -webkit-box 的组合与本文件 .warning span 同配方。 */
|
||||
.fileName {
|
||||
margin: -4px 0 0;
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-tertiary);
|
||||
overflow-wrap: anywhere;
|
||||
@include text-ellipsis-multiline(2);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- 告警行 ---------- */
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--warning-text);
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
@include text-ellipsis-multiline(2);
|
||||
}
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 健康区:eyebrow + 计数 + 状态条 ---------- */
|
||||
|
||||
.health {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.healthHead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.healthLabel {
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.healthCounts {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-family: $font-mono;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.countOk {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.countFail {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
/* 绿色只给活的流量:有计数才点亮 */
|
||||
.countLive.countOk {
|
||||
color: var(--viz-success);
|
||||
}
|
||||
|
||||
.countLive.countFail {
|
||||
color: var(--viz-failure);
|
||||
}
|
||||
|
||||
/* ---------- ProviderStatusBar 注入样式(18 个契约类名) ---------- */
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statusBlocks {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.statusBlockWrapper {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.statusBlock {
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.statusBlockIdle {
|
||||
background: color-mix(in srgb, var(--text-quaternary) 26%, transparent);
|
||||
}
|
||||
|
||||
.statusBlockActive .statusBlock {
|
||||
outline: 1px solid var(--text-secondary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.statusRate {
|
||||
font-family: $font-mono;
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-quaternary);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.statusRateHigh {
|
||||
color: var(--viz-success);
|
||||
}
|
||||
|
||||
.statusRateMedium {
|
||||
color: var(--quota-medium-color);
|
||||
}
|
||||
|
||||
.statusRateLow {
|
||||
color: var(--viz-failure);
|
||||
}
|
||||
|
||||
.statusTooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 150px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--floating-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--floating-shadow);
|
||||
pointer-events: none;
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.statusTooltipLeft {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.statusTooltipRight {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tooltipTime {
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tooltipStats {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tooltipSuccess {
|
||||
color: var(--viz-success);
|
||||
}
|
||||
|
||||
.tooltipFailure {
|
||||
color: var(--viz-failure);
|
||||
}
|
||||
|
||||
.tooltipRate {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---------- 元数据行:size · modified · priority · WRR weight ---------- */
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 7px;
|
||||
font-family: $font-mono;
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.metaDivider {
|
||||
color: var(--text-quaternary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.metaPriority,
|
||||
.metaWeight {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-full;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metaPriority {
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--text-quaternary) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--text-quaternary) 24%, transparent);
|
||||
}
|
||||
|
||||
.metaWeight {
|
||||
color: var(--primary-active);
|
||||
background: var(--primary-10);
|
||||
border-color: var(--primary-30);
|
||||
}
|
||||
|
||||
.metaMetricLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: currentColor;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
/* ---------- 页脚动作区 ---------- */
|
||||
|
||||
.actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.actionsMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.utilityActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Button 会用 span 包裹 children;将包裹层也设为 flex,避免 SVG 按文本基线偏移。 */
|
||||
.actions :global(.btn > span) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.actions :global(.btn svg) {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 图标按钮:方形紧凑的安静描边风——危险动作静默存在,hover 才显色块 */
|
||||
.card .actions .iconButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card .actions .iconButton:global(.btn-danger) {
|
||||
color: var(--danger-color);
|
||||
border-color: color-mix(in srgb, var(--danger-color) 28%, transparent);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card .actions .iconButton:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.card .actions .iconButton:global(.btn-danger):hover:not(:disabled) {
|
||||
color: var(--danger-color);
|
||||
border-color: color-mix(in srgb, var(--danger-color) 45%, transparent);
|
||||
background: var(--destructive-10);
|
||||
}
|
||||
}
|
||||
|
||||
/* 按压反馈:所有可点元素 scale 0.96(reduced-motion 下豁免) */
|
||||
.actions :global(.btn) {
|
||||
transition:
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out),
|
||||
background-color $transition-fast,
|
||||
border-color $transition-fast,
|
||||
color $transition-fast;
|
||||
}
|
||||
|
||||
.actions :global(.btn:active:not(:disabled)) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.toggleWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actionsMain {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionsMain > :global(.btn) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggleWrap {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 降级 ---------- */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cardEnter {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.actions :global(.btn) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.actions :global(.btn:active:not(:disabled)) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
372
frontend/src/features/authFiles/components/AuthFileCard.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconDownload,
|
||||
IconInfo,
|
||||
IconModelCluster,
|
||||
IconRefreshCw,
|
||||
IconSettings,
|
||||
IconTrash2,
|
||||
} from '@/components/ui/icons';
|
||||
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { resolveAuthProvider } from '@/utils/quota';
|
||||
import { statusBarDataFromRecentRequests } from '@/utils/recentRequests';
|
||||
import { formatFileSize } from '@/utils/format';
|
||||
import {
|
||||
QUOTA_PROVIDER_TYPES,
|
||||
formatModified,
|
||||
getAuthFileIcon,
|
||||
getAuthFileStatusMessage,
|
||||
getThemeSurfaceIconBackground,
|
||||
hasAuthFileStatusWarning,
|
||||
getTypeColor,
|
||||
getTypeLabel,
|
||||
isRuntimeOnlyAuthFile,
|
||||
isThemeSurfaceIconProvider,
|
||||
normalizeProviderKey,
|
||||
supportsAuthFileManualRefresh,
|
||||
type QuotaProviderType,
|
||||
type ResolvedTheme,
|
||||
} from '@/features/authFiles/constants';
|
||||
import { deriveAuthFileIdentity } from '@/features/authFiles/identity';
|
||||
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
|
||||
import styles from './AuthFileCard.module.scss';
|
||||
|
||||
export type AuthFileCardProps = {
|
||||
file: AuthFileItem;
|
||||
compact: boolean;
|
||||
selected: boolean;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
disableControls: boolean;
|
||||
deleting: string | null;
|
||||
statusUpdating: Record<string, boolean>;
|
||||
manualRefreshing: Record<string, boolean>;
|
||||
quotaFilterType: QuotaProviderType | null;
|
||||
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||
/** 首屏一次性级联入场的延迟;null/undefined 表示不做入场动画。 */
|
||||
entranceDelayMs?: number | null;
|
||||
onShowModels: (file: AuthFileItem) => void;
|
||||
onDownload: (name: string) => void;
|
||||
onManualRefresh: (file: AuthFileItem) => void;
|
||||
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
|
||||
onDelete: (name: string) => void;
|
||||
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
|
||||
onToggleSelect: (name: string) => void;
|
||||
};
|
||||
|
||||
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||
const provider = resolveAuthProvider(file);
|
||||
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||||
return provider as QuotaProviderType;
|
||||
};
|
||||
|
||||
export function AuthFileCard(props: AuthFileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
compact,
|
||||
selected,
|
||||
resolvedTheme,
|
||||
disableControls,
|
||||
deleting,
|
||||
statusUpdating,
|
||||
manualRefreshing,
|
||||
quotaFilterType,
|
||||
statusBarCache,
|
||||
entranceDelayMs,
|
||||
onShowModels,
|
||||
onDownload,
|
||||
onManualRefresh,
|
||||
onOpenPrefixProxyEditor,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
onToggleSelect,
|
||||
} = props;
|
||||
|
||||
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
|
||||
const providerKey = normalizeProviderKey(String(file.type ?? file.provider ?? 'unknown'));
|
||||
const isAistudio = providerKey === 'aistudio';
|
||||
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||
const showManualRefreshButton = !isRuntimeOnly && supportsAuthFileManualRefresh(providerKey);
|
||||
const isManualRefreshing = manualRefreshing[file.name] === true;
|
||||
const typeColor = getTypeColor(providerKey, resolvedTheme);
|
||||
const typeLabel = getTypeLabel(t, providerKey);
|
||||
const providerIcon = getAuthFileIcon(providerKey, resolvedTheme);
|
||||
// 与 AI 提供商界面一致:Kimi 图标底座随主题切换颜色
|
||||
const useThemeSurfaceIcon = isThemeSurfaceIconProvider(providerKey);
|
||||
|
||||
const quotaType =
|
||||
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
|
||||
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly && !compact;
|
||||
|
||||
const successCount = file.successCount ?? 0;
|
||||
const failureCount = file.failureCount ?? 0;
|
||||
const authIndexKey = typeof file.authIndex === 'string' ? file.authIndex : null;
|
||||
const statusData =
|
||||
(authIndexKey && statusBarCache.get(authIndexKey)) ||
|
||||
statusBarDataFromRecentRequests(file.recentRequests ?? []);
|
||||
|
||||
const rawStatusMessage = getAuthFileStatusMessage(file);
|
||||
const hasStatusWarning = hasAuthFileStatusWarning(file);
|
||||
|
||||
const priorityValue = Number.isSafeInteger(file.priority) ? file.priority : undefined;
|
||||
const weightValue = Number.isSafeInteger(file.weight) ? file.weight : undefined;
|
||||
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
|
||||
// 主行显示账号(email/项目 ID),文件名降为满卡宽的 mono 副行
|
||||
const identity = deriveAuthFileIdentity(file);
|
||||
|
||||
const stateLabel = isRuntimeOnly
|
||||
? t('auth_files.type_virtual')
|
||||
: file.disabled
|
||||
? t('auth_files.health_status_disabled')
|
||||
: hasStatusWarning
|
||||
? t('auth_files.health_status_warning')
|
||||
: rawStatusMessage
|
||||
? t('auth_files.health_status_healthy')
|
||||
: t('auth_files.status_toggle_label');
|
||||
const stateBadgeClass = isRuntimeOnly
|
||||
? styles.stateVirtual
|
||||
: file.disabled
|
||||
? styles.stateDisabled
|
||||
: hasStatusWarning
|
||||
? styles.stateWarning
|
||||
: styles.stateActive;
|
||||
|
||||
// 挂载时捕获一次入场延迟:父级随后传 null 也不会中断已开始的动画
|
||||
const [mountEntranceDelayMs] = useState<number | null>(entranceDelayMs ?? null);
|
||||
const cardClasses = [
|
||||
styles.card,
|
||||
compact ? styles.cardCompact : '',
|
||||
selected ? styles.cardSelected : '',
|
||||
file.disabled ? styles.cardDisabled : '',
|
||||
mountEntranceDelayMs != null ? styles.cardEnter : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const cardStyle =
|
||||
mountEntranceDelayMs != null
|
||||
? ({ '--card-delay': `${mountEntranceDelayMs}ms` } as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article className={cardClasses} style={cardStyle}>
|
||||
<header className={styles.head}>
|
||||
{!isRuntimeOnly && (
|
||||
<SelectionCheckbox
|
||||
checked={selected}
|
||||
onChange={() => onToggleSelect(file.name)}
|
||||
className={styles.selection}
|
||||
aria-label={
|
||||
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
|
||||
}
|
||||
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={styles.avatar}
|
||||
style={
|
||||
useThemeSurfaceIcon
|
||||
? {
|
||||
backgroundColor: getThemeSurfaceIconBackground(resolvedTheme),
|
||||
color: typeColor.text,
|
||||
}
|
||||
: {
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||
}
|
||||
}
|
||||
>
|
||||
{providerIcon ? (
|
||||
<img src={providerIcon} alt="" className={styles.avatarImage} />
|
||||
) : (
|
||||
<span className={styles.avatarFallback}>{typeLabel.slice(0, 1).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.identity}>
|
||||
<div className={styles.badgeRow}>
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {}),
|
||||
}}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className={`${styles.stateBadge} ${stateBadgeClass}`}>
|
||||
<span className={styles.stateDot} aria-hidden="true" />
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`${styles.account} ${identity.kind === 'fileName' ? styles.accountMono : ''}`}
|
||||
title={identity.primary}
|
||||
>
|
||||
{identity.primary}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{identity.secondary && (
|
||||
<p className={styles.fileName} title={identity.fullName}>
|
||||
{identity.secondary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!compact && noteValue && (
|
||||
<p className={styles.note} title={noteValue}>
|
||||
{noteValue}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rawStatusMessage && hasStatusWarning && (
|
||||
<div className={styles.warning} title={rawStatusMessage}>
|
||||
<IconInfo className={styles.warningIcon} size={14} />
|
||||
<span>{rawStatusMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.health}>
|
||||
<div className={styles.healthHead}>
|
||||
<span className={styles.healthLabel}>{t('auth_files.health_status_label')}</span>
|
||||
<span className={styles.healthCounts}>
|
||||
<span
|
||||
className={`${styles.countOk} ${successCount > 0 ? styles.countLive : ''}`}
|
||||
title={t('stats.success')}
|
||||
>
|
||||
{t('stats.success')} {successCount}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.countFail} ${failureCount > 0 ? styles.countLive : ''}`}
|
||||
title={t('stats.failure')}
|
||||
>
|
||||
{t('stats.failure')} {failureCount}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<span title={t('auth_files.file_size')}>{file.size ? formatFileSize(file.size) : '-'}</span>
|
||||
<span className={styles.metaDivider} aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span title={t('auth_files.file_modified')}>{formatModified(file)}</span>
|
||||
{priorityValue !== undefined && (
|
||||
<>
|
||||
<span className={styles.metaDivider} aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className={styles.metaPriority} title={t('auth_files.priority_hint')}>
|
||||
<span className={styles.metaMetricLabel}>{t('auth_files.priority_display')}</span>
|
||||
<span>{priorityValue}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{weightValue !== undefined && (
|
||||
<>
|
||||
<span className={styles.metaDivider} aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className={styles.metaWeight} title={t('auth_files.weight_hint')}>
|
||||
<span className={styles.metaMetricLabel}>{t('auth_files.weight_display')}</span>
|
||||
<span>{weightValue}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQuotaLayout && quotaType && (
|
||||
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||
)}
|
||||
|
||||
<footer className={styles.actions}>
|
||||
<div className={styles.actionsMain}>
|
||||
{showModelsButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onShowModels(file)}
|
||||
title={t('auth_files.models_button')}
|
||||
disabled={disableControls}
|
||||
>
|
||||
<IconModelCluster size={14} />
|
||||
{t('auth_files.models_button')}
|
||||
</Button>
|
||||
)}
|
||||
{!isRuntimeOnly && (
|
||||
<div className={styles.utilityActions}>
|
||||
{showManualRefreshButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onManualRefresh(file)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.manual_refresh_button')}
|
||||
disabled={
|
||||
disableControls ||
|
||||
file.disabled ||
|
||||
statusUpdating[file.name] === true ||
|
||||
isManualRefreshing
|
||||
}
|
||||
>
|
||||
{isManualRefreshing ? <LoadingSpinner size={14} /> : <IconRefreshCw size={15} />}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDownload(file.name)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.download_button')}
|
||||
disabled={disableControls}
|
||||
>
|
||||
<IconDownload size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onOpenPrefixProxyEditor(file)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.prefix_proxy_button')}
|
||||
disabled={disableControls || isManualRefreshing}
|
||||
>
|
||||
<IconSettings size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(file.name)}
|
||||
className={styles.iconButton}
|
||||
title={t('auth_files.delete_button')}
|
||||
disabled={disableControls || deleting === file.name || isManualRefreshing}
|
||||
>
|
||||
{deleting === file.name ? <LoadingSpinner size={14} /> : <IconTrash2 size={15} />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRuntimeOnly && (
|
||||
<div className={styles.toggleWrap}>
|
||||
<span className={styles.toggleLabel}>{t('auth_files.status_toggle_label')}</span>
|
||||
<ToggleSwitch
|
||||
ariaLabel={t('auth_files.status_toggle_label')}
|
||||
checked={!file.disabled}
|
||||
disabled={disableControls || statusUpdating[file.name] === true || isManualRefreshing}
|
||||
onChange={(value) => onToggleStatus(file, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/* 凭证详情/编辑抽屉内容区 */
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 0;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--danger-color);
|
||||
background: var(--bg-error-light);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.jsonWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: $font-mono;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px var(--primary-10);
|
||||
}
|
||||
}
|
||||
|
||||
.textareaInvalid {
|
||||
border-color: var(--danger-color) !important;
|
||||
}
|
||||
|
||||
.invalidPreview {
|
||||
margin: 0;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-hover);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sheet } from '@/components/ui/Sheet';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import type {
|
||||
PrefixProxyEditorField,
|
||||
PrefixProxyEditorFieldValue,
|
||||
PrefixProxyEditorState,
|
||||
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||
import {
|
||||
supportsAuthFileUsingApi,
|
||||
supportsAuthFileWebsockets,
|
||||
} from '@/features/authFiles/constants';
|
||||
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
|
||||
import { AuthFileExcludedModelsField } from './AuthFileExcludedModelsField';
|
||||
import styles from './AuthFileDetailsSheet.module.scss';
|
||||
|
||||
/** API 边界归一化补写的派生字段——INFO 视图里只展示后端原始形状,避免重复噪音。 */
|
||||
const DERIVED_INFO_KEYS = [
|
||||
'successCount',
|
||||
'failureCount',
|
||||
'recentRequests',
|
||||
'runtimeOnly',
|
||||
'authIndex',
|
||||
'statusMessage',
|
||||
'modified',
|
||||
// 'email' 不在此列:后端原始键名与 camelCase 同形,删掉会藏起真实数据。
|
||||
'projectId',
|
||||
];
|
||||
|
||||
export type AuthFileDetailsSheetProps = {
|
||||
disableControls: boolean;
|
||||
editor: PrefixProxyEditorState | null;
|
||||
updatedText: string;
|
||||
dirty: boolean;
|
||||
onClose: () => void;
|
||||
onCopyText: (text: string) => void | Promise<void>;
|
||||
onSave: () => void;
|
||||
onChange: (field: PrefixProxyEditorField, value: PrefixProxyEditorFieldValue) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 凭证详情/编辑抽屉:替代旧的居中 Modal,与提供商工作台的 Sheet 模式一致。
|
||||
* 脏状态下关闭(Escape/遮罩/×/取消)先走确认对话框。
|
||||
*/
|
||||
export function AuthFileDetailsSheet(props: AuthFileDetailsSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { disableControls, editor, updatedText, dirty, onClose, onCopyText, onSave, onChange } =
|
||||
props;
|
||||
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
|
||||
|
||||
const confirmClose = useCallback((): boolean | Promise<boolean> => {
|
||||
if (!dirty || editor?.saving === true) return true;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
showConfirmation({
|
||||
title: t('providersPage.unsavedChanges.title'),
|
||||
message: t('providersPage.unsavedChanges.message'),
|
||||
variant: 'danger',
|
||||
confirmText: t('providersPage.unsavedChanges.discard'),
|
||||
cancelText: t('providersPage.unsavedChanges.keepEditing'),
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}, [dirty, editor?.saving, showConfirmation, t]);
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
void Promise.resolve(confirmClose()).then((ok) => {
|
||||
if (ok) onClose();
|
||||
});
|
||||
}, [confirmClose, onClose]);
|
||||
|
||||
const formatJsonText = (text: string) => {
|
||||
if (!text) return '';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
const previewText = formatJsonText(updatedText);
|
||||
const invalidContentPreview = editor?.invalidContentPreview ?? '';
|
||||
const fileInfoText = editor?.fileInfoText ?? '';
|
||||
const displayInfoText = useMemo(() => {
|
||||
if (!fileInfoText) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(fileInfoText) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const record = parsed as Record<string, unknown>;
|
||||
DERIVED_INFO_KEYS.forEach((key) => {
|
||||
delete record[key];
|
||||
});
|
||||
return JSON.stringify(record, null, 2);
|
||||
}
|
||||
} catch {
|
||||
/* 非 JSON 原样展示 */
|
||||
}
|
||||
return fileInfoText;
|
||||
}, [fileInfoText]);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={Boolean(editor)}
|
||||
onClose={onClose}
|
||||
confirmClose={confirmClose}
|
||||
size="md"
|
||||
closeDisabled={editor?.saving === true}
|
||||
eyebrow={t('auth_files.prefix_proxy_button')}
|
||||
title={editor?.fileName ?? ''}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancelClick}
|
||||
disabled={editor?.saving === true}
|
||||
>
|
||||
{dirty ? t('common.cancel') : t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!updatedText) return;
|
||||
void onCopyText(updatedText);
|
||||
}}
|
||||
disabled={editor?.saving === true || !updatedText}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
loading={editor?.saving === true}
|
||||
disabled={
|
||||
disableControls ||
|
||||
editor?.saving === true ||
|
||||
!dirty ||
|
||||
!editor?.json ||
|
||||
Boolean(editor?.headersTouched && editor.headersError) ||
|
||||
Boolean(editor?.weightError)
|
||||
}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{editor && (
|
||||
<div className={styles.editor}>
|
||||
{editor.loading ? (
|
||||
<div className={styles.loading}>
|
||||
<LoadingSpinner size={14} />
|
||||
<span>{t('auth_files.prefix_proxy_loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{editor.error && <div className={styles.error}>{editor.error}</div>}
|
||||
<div className={styles.jsonWrapper}>
|
||||
<label className={styles.label}>{t('auth_files.prefix_proxy_info_label')}</label>
|
||||
<textarea className={styles.textarea} rows={8} readOnly value={displayInfoText} />
|
||||
</div>
|
||||
<div className={styles.jsonWrapper}>
|
||||
<label className={styles.label}>
|
||||
{editor.json
|
||||
? t('auth_files.prefix_proxy_source_label')
|
||||
: t('auth_files.prefix_proxy_invalid_content_label')}
|
||||
</label>
|
||||
{editor.json ? (
|
||||
<textarea className={styles.textarea} rows={10} readOnly value={previewText} />
|
||||
) : (
|
||||
<pre className={styles.invalidPreview}>{invalidContentPreview}</pre>
|
||||
)}
|
||||
</div>
|
||||
{editor.json && (
|
||||
<div className={styles.fields}>
|
||||
<Input
|
||||
label={t('auth_files.prefix_label')}
|
||||
value={editor.prefix}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('prefix', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auth_files.proxy_url_label')}
|
||||
value={editor.proxyUrl}
|
||||
placeholder={t('auth_files.proxy_url_placeholder')}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('proxyUrl', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auth_files.priority_label')}
|
||||
value={editor.priority}
|
||||
placeholder={t('auth_files.priority_placeholder')}
|
||||
hint={t('auth_files.priority_hint')}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('priority', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('auth_files.weight_label')}
|
||||
type="number"
|
||||
step="1"
|
||||
max={MAX_CREDENTIAL_WEIGHT}
|
||||
value={editor.weight}
|
||||
placeholder="1"
|
||||
hint={t('auth_files.weight_hint')}
|
||||
error={editor.weightError ?? undefined}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('weight', e.target.value)}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.disable_cooling_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={editor.disableCooling}
|
||||
onChange={(value) => onChange('disableCooling', value)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
ariaLabel={t('auth_files.disable_cooling_label')}
|
||||
/>
|
||||
<div className="hint">{t('auth_files.disable_cooling_hint')}</div>
|
||||
</div>
|
||||
{supportsAuthFileWebsockets(editor.providerKey) && (
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.websockets_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={editor.websockets}
|
||||
onChange={(value) => onChange('websockets', value)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
ariaLabel={t('auth_files.websockets_label')}
|
||||
/>
|
||||
<div className="hint">{t('auth_files.websockets_hint')}</div>
|
||||
</div>
|
||||
)}
|
||||
{supportsAuthFileUsingApi(editor.providerKey) && (
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.using_api_label')}</label>
|
||||
<ToggleSwitch
|
||||
checked={editor.usingApi}
|
||||
onChange={(value) => onChange('usingApi', value)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
ariaLabel={t('auth_files.using_api_label')}
|
||||
/>
|
||||
<div className="hint">{t('auth_files.using_api_hint')}</div>
|
||||
</div>
|
||||
)}
|
||||
<AuthFileExcludedModelsField
|
||||
fileName={editor.fileName}
|
||||
value={editor.excludedModelsText}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(value) => onChange('excludedModelsText', value)}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label>{t('auth_files.headers_label')}</label>
|
||||
<textarea
|
||||
className={`input ${editor.headersError ? styles.textareaInvalid : ''}`}
|
||||
value={editor.headersText}
|
||||
placeholder={t('auth_files.headers_placeholder')}
|
||||
rows={4}
|
||||
aria-invalid={Boolean(editor.headersError)}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('headersText', e.target.value)}
|
||||
/>
|
||||
{editor.headersError && <div className="error-box">{editor.headersError}</div>}
|
||||
<div className="hint">{t('auth_files.headers_hint')}</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('auth_files.note_label')}
|
||||
value={editor.note}
|
||||
placeholder={t('auth_files.note_placeholder')}
|
||||
hint={t('auth_files.note_hint')}
|
||||
disabled={disableControls || editor.saving || !editor.json}
|
||||
onChange={(e) => onChange('note', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ExcludedModelsPicker,
|
||||
formatExcludedRulesText,
|
||||
parseExcludedRulesText,
|
||||
type ExcludedModelsCatalogState,
|
||||
} from '@/components/excludedModels';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||
|
||||
interface AuthFileExcludedModelsFieldProps {
|
||||
fileName: string;
|
||||
/** 换行分隔的规则文本——凭证编辑器的 dirty diff 依赖这个形状,不要改成数组。 */
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function AuthFileExcludedModelsField({
|
||||
fileName,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: AuthFileExcludedModelsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
// 凭证文件名可能含点/斜杠等字符,不适合直接当 HTML id。
|
||||
const labelId = `${useId()}-excluded-models-label`;
|
||||
const latestValueRef = useRef(value);
|
||||
const [models, setModels] = useState<AuthFileModelItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
latestValueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setModels([]);
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
|
||||
void authFilesApi
|
||||
.getModelsForAuthFile(fileName)
|
||||
.then((items) => {
|
||||
if (cancelled) return;
|
||||
const byId = new Map<string, AuthFileModelItem>();
|
||||
items.forEach((item) => {
|
||||
const id = item.id?.trim();
|
||||
if (id) byId.set(id.toLowerCase(), { ...item, id });
|
||||
});
|
||||
// 已配置但目录里没有的精确规则也塞进候选,否则它们会在列表里凭空消失。
|
||||
parseExcludedRulesText(latestValueRef.current).forEach((rule) => {
|
||||
if (!rule.includes('*') && !byId.has(rule.toLowerCase())) {
|
||||
byId.set(rule.toLowerCase(), { id: rule });
|
||||
}
|
||||
});
|
||||
setModels(
|
||||
[...byId.values()].sort((left, right) =>
|
||||
left.id.localeCompare(right.id, undefined, { sensitivity: 'base' })
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileName]);
|
||||
|
||||
const rules = useMemo(() => parseExcludedRulesText(value), [value]);
|
||||
const candidates = useMemo(
|
||||
() => models.map((model) => ({ id: model.id, displayName: model.display_name })),
|
||||
[models]
|
||||
);
|
||||
const catalogState: ExcludedModelsCatalogState = loading
|
||||
? 'loading'
|
||||
: loadFailed
|
||||
? 'error'
|
||||
: 'ready';
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<label id={labelId}>{t('auth_files.excluded_models_label')}</label>
|
||||
<ExcludedModelsPicker
|
||||
value={rules}
|
||||
onChange={(next) => onChange(formatExcludedRulesText(next))}
|
||||
candidates={candidates}
|
||||
catalogState={catalogState}
|
||||
disabled={disabled}
|
||||
labelledBy={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
|
||||
/* 「支持的模型」弹窗:可点击复制的 mono 模型清单 */
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: min(52dvh, 480px);
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color $transition-fast,
|
||||
border-color $transition-fast,
|
||||
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.itemExcluded {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.modelId {
|
||||
font-family: $font-mono;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.modelDisplayName {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-tertiary);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.modelType {
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
padding: 1px 6px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.excludedBadge {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--failure-badge-text);
|
||||
background: var(--failure-badge-bg);
|
||||
border: 1px solid var(--failure-badge-border);
|
||||
border-radius: $radius-full;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.item {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.item:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||
import { isModelExcluded } from '@/features/authFiles/constants';
|
||||
import styles from './AuthFileModelsModal.module.scss';
|
||||
|
||||
export type AuthFileModelsModalProps = {
|
||||
open: boolean;
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
loading: boolean;
|
||||
error: 'unsupported' | null;
|
||||
models: AuthFileModelItem[];
|
||||
excluded: Record<string, string[]>;
|
||||
onClose: () => void;
|
||||
onCopyText: (text: string) => void;
|
||||
};
|
||||
|
||||
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="hint">
|
||||
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
|
||||
</div>
|
||||
) : error === 'unsupported' ? (
|
||||
<EmptyState
|
||||
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
||||
description={t('auth_files.models_unsupported_desc', {
|
||||
defaultValue: '请更新 CLI Proxy API 到最新版本后重试',
|
||||
})}
|
||||
/>
|
||||
) : models.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
||||
description={t('auth_files.models_empty_desc', {
|
||||
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{models.map((model) => {
|
||||
const excludedModel = isModelExcluded(model.id, fileType, excluded);
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`${styles.item} ${excludedModel ? styles.itemExcluded : ''}`}
|
||||
onClick={() => {
|
||||
onCopyText(model.id);
|
||||
}}
|
||||
title={
|
||||
excludedModel
|
||||
? t('auth_files.models_excluded_hint', {
|
||||
defaultValue: '此 OAuth 模型已被禁用',
|
||||
})
|
||||
: t('common.copy', { defaultValue: '点击复制' })
|
||||
}
|
||||
>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||
{excludedModel && (
|
||||
<span className={styles.excludedBadge}>
|
||||
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
@use '../../../styles/mixins' as *;
|
||||
|
||||
/* ============================================================
|
||||
* 认证文件卡片的配额区外衣。
|
||||
*
|
||||
* 契约:quotaConfigs.renderQuotaItems 通过 helpers.styles 按名查找
|
||||
* 以下类(antigravity/claude/codex/kimi/xai 五条渲染路径共 24 个),
|
||||
* 另加本组件宿主类与 QuotaProgressBar 的 5 个注入类。
|
||||
* 漏名会静默渲染 class="undefined" —— 增删前先对照 quotaConfigs.ts。
|
||||
* ============================================================ */
|
||||
|
||||
/* ---------- 宿主 ---------- */
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
|
||||
}
|
||||
|
||||
.quotaMessage {
|
||||
font-family: $font-mono;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: 5px 4px;
|
||||
}
|
||||
|
||||
button.quotaMessageAction {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--danger-color);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.quotaCardActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quotaResetCreditButton {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- 配额行(全 provider 通用) ---------- */
|
||||
|
||||
.quotaRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quotaModel {
|
||||
min-width: 0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.quotaMeta {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.quotaPercent {
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quotaReset {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
/* Compact host: same contract class as QuotaBody.module.scss, tighter gutter. */
|
||||
.quotaResetRelative {
|
||||
color: var(--text-tertiary);
|
||||
|
||||
&::before {
|
||||
content: '·';
|
||||
margin: 0 3px;
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.quotaResetRelativeSoon {
|
||||
color: var(--warning-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quotaAmount {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---------- 进度条(QuotaProgressBar 注入,Meter 风 6px 轨道) ---------- */
|
||||
|
||||
.quotaBar {
|
||||
height: 6px;
|
||||
border-radius: $radius-full;
|
||||
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quotaBarFill {
|
||||
height: 100%;
|
||||
border-radius: $radius-full;
|
||||
transition: width 360ms var(--ease-out-strong, ease);
|
||||
}
|
||||
|
||||
.quotaBarFillHigh {
|
||||
background: var(--viz-success);
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background: var(--quota-medium-color);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
background: var(--viz-failure);
|
||||
}
|
||||
|
||||
/* ---------- 计划徽章(codexPlan*:antigravity/claude/xai 复用) ---------- */
|
||||
|
||||
.codexPlan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.codexPlanItem {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
|
||||
}
|
||||
|
||||
.codexPlanLabel {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.codexPlanValue {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.premiumPlanValue {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--amber-text);
|
||||
}
|
||||
|
||||
/* Codex Pro 20x 铂金(紧凑版):与 5x 金色值完全同构,只换色相为铂金钢蓝灰。
|
||||
*
|
||||
* 这一行里 5x 就是一枚变了色的 mono 文本(premiumPlanValue = amber 文字),
|
||||
* 所以 20x 也只是一枚变了色的 mono 文本。之前这里镶了整块切面宝石:
|
||||
* 15px 宽的切面在 11px 行里糊成噪点,还得吃掉 18px 左内边距把文字挤偏,
|
||||
* 而兄弟 chip 全是朴素文字 —— 一枚发光石头在其中只是吵,不是贵。
|
||||
*
|
||||
* 色值与配额页同族(液态铂金,2026-07-31 起替代 MC 钻石青):
|
||||
* 浅色 #526379 对白底 6.1:1;深色提亮成银蓝。钢灰在 mono 文本里偏安静,
|
||||
* 但这正是该档的性格 —— 铂金的贵是冷的,吵的留给 amber。 */
|
||||
.elitePlanValue {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #526379;
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .elitePlanValue {
|
||||
color: #c3d3e4;
|
||||
}
|
||||
|
||||
/* ---------- Antigravity 分组 ---------- */
|
||||
|
||||
.antigravityQuotaGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
& + & {
|
||||
margin-top: 3px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityQuotaGroupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.antigravityQuotaGroupTitle {
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.antigravityQuotaGroupDescription {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
/* ---------- Codex 重置积分 ---------- */
|
||||
|
||||
.codexResetCredits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
.codexResetCreditsTitle {
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.codexResetCreditRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* The compact host's credit rows have no border of their own, so the emphasis
|
||||
is carried by a left rule and a tint rather than a border-colour swap. */
|
||||
.codexResetCreditRowSoon {
|
||||
padding: 2px 6px;
|
||||
margin: 0 -6px;
|
||||
border-left: 2px solid var(--warning-border);
|
||||
border-radius: $radius-sm;
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
.codexResetCreditLabel {
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.codexResetCreditTime {
|
||||
flex-shrink: 0;
|
||||
font-family: $font-mono;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.codexResetCreditsError {
|
||||
font-size: 11px;
|
||||
color: var(--danger-color);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
.quotaRowHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.quotaModel {
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 降级 ---------- */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quotaBarFill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||