Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

32
frontend/.github/workflows/ci.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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

View 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

File diff suppressed because one or more lines are too long

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

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

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

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

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

View file

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

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

View file

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

View file

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

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

View file

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

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

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

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

File diff suppressed because it is too large Load diff

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

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

View file

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

View file

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

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

View file

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

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

View file

@ -0,0 +1,2 @@
export { ModelMappingDiagram } from './ModelMappingDiagram';
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';

View 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> = {};
/**
* (01) 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>
);
}

View file

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

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

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

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

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

View file

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

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

View file

@ -0,0 +1 @@
export { Collapsible } from './Collapsible';

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,2 @@
export { Sheet } from './Sheet';
export type { SheetSize } from './Sheet';

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

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

View file

@ -0,0 +1 @@
export { Skeleton } from './Skeleton';

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

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

View file

@ -0,0 +1 @@
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table';

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

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

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

View 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(',');

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

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

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

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

View file

@ -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.96reduced-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;
}
}

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more