Big update

This commit is contained in:
Alois 2026-08-27 15:02:32 +02:00
commit 2e6afc460b
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
474 changed files with 934 additions and 86159 deletions

View file

@ -1,59 +1,321 @@
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';
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { api, type CodexAccount } from './api';
import { fetchCodexQuota, type CodexQuota } from './codexQuota';
const SESSION_KEY = 'vibe-proxy-management-key';
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Something went wrong';
}
function statusFor(account: CodexAccount): { label: string; tone: string } {
if (account.disabled) return { label: 'Disabled', tone: 'muted' };
if (account.unavailable || account.status === 'error') return { label: 'Unavailable', tone: 'bad' };
return { label: account.status || 'Ready', tone: 'good' };
}
function Login({ onLogin }: { onLogin: (key: string) => void }) {
const [key, setKey] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
const managementKey = key.trim();
if (!managementKey) return;
setLoading(true);
setError('');
try {
await api.listAccounts(managementKey);
sessionStorage.setItem(SESSION_KEY, managementKey);
onLogin(managementKey);
} catch (loginError) {
setError(errorMessage(loginError));
} finally {
setLoading(false);
}
}
function RootShell() {
return (
<>
<NotificationContainer />
<ConfirmationModal />
<Outlet />
</>
<main className="login-shell">
<section className="login-card">
<div className="mark" aria-hidden="true">V</div>
<p className="eyebrow">Vibe Proxy</p>
<h1>Account management</h1>
<p className="lede">Sign in with the management key for this server.</p>
<form onSubmit={submit}>
<label htmlFor="management-key">Management key</label>
<input
id="management-key"
autoFocus
autoComplete="current-password"
type="password"
value={key}
onChange={(event) => setKey(event.target.value)}
placeholder="Enter management key"
/>
{error && <p className="form-error" role="alert">{error}</p>}
<button className="primary wide" disabled={loading || !key.trim()}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</section>
</main>
);
}
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} />;
function QuotaView({ quota }: { quota: CodexQuota }) {
return (
<div className="quota">
{quota.planType && <span className="plan">{quota.planType}</span>}
{quota.windows.length === 0 ? (
<p className="secondary">No quota windows returned.</p>
) : (
quota.windows.map((window) => (
<div className="quota-row" key={window.id}>
<div className="quota-label">
<span>{window.label}</span>
<span>{window.remaining === null ? '--' : `${Math.round(window.remaining)}%`}</span>
</div>
<div className="meter" aria-label={`${window.label} quota remaining`}>
<span style={{ width: `${window.remaining ?? 0}%` }} />
</div>
{window.resetAt && (
<small>Resets {new Date(window.resetAt).toLocaleString()}</small>
)}
</div>
))
)}
</div>
);
}
export default App;
function AccountCard({
account,
managementKey,
onDelete,
}: {
account: CodexAccount;
managementKey: string;
onDelete: () => void;
}) {
const [quota, setQuota] = useState<CodexQuota>();
const [quotaError, setQuotaError] = useState('');
const [loadingQuota, setLoadingQuota] = useState(false);
const [deleting, setDeleting] = useState(false);
const status = statusFor(account);
async function refreshQuota() {
setLoadingQuota(true);
setQuotaError('');
try {
setQuota(await fetchCodexQuota(account, managementKey));
} catch (error) {
setQuotaError(errorMessage(error));
} finally {
setLoadingQuota(false);
}
}
async function remove() {
if (!window.confirm(`Delete ${account.email || 'this Codex account'}?`)) return;
setDeleting(true);
try {
await api.deleteAccount(account.name, managementKey);
onDelete();
} catch (error) {
setQuotaError(errorMessage(error));
setDeleting(false);
}
}
return (
<article className="account-card">
<div className="account-head">
<div className="account-identity">
<div className="avatar" aria-hidden="true">{(account.email || 'C')[0].toUpperCase()}</div>
<div>
<h2>{account.email || 'Email unavailable'}</h2>
<span className={`status ${status.tone}`}>{status.label}</span>
</div>
</div>
<button className="danger" onClick={remove} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
{account.statusMessage && <p className="account-message">{account.statusMessage}</p>}
{quota && <QuotaView quota={quota} />}
{quotaError && <p className="form-error" role="alert">{quotaError}</p>}
<button className="secondary-button" onClick={refreshQuota} disabled={loadingQuota || account.disabled}>
{loadingQuota ? 'Refreshing quota...' : quota ? 'Refresh quota' : 'View quota'}
</button>
</article>
);
}
function Management({ managementKey, onLogout }: { managementKey: string; onLogout: () => void }) {
const [accounts, setAccounts] = useState<CodexAccount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [oauth, setOauth] = useState<{ url: string; state: string }>();
const [callbackUrl, setCallbackUrl] = useState('');
const [oauthStatus, setOauthStatus] = useState('');
const [adding, setAdding] = useState(false);
const pollRef = useRef<number | undefined>(undefined);
const loadAccounts = useCallback(async () => {
setError('');
try {
setAccounts(await api.listAccounts(managementKey));
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [managementKey]);
const stopPolling = useCallback(() => {
if (pollRef.current !== undefined) window.clearInterval(pollRef.current);
pollRef.current = undefined;
}, []);
useEffect(() => {
void loadAccounts();
return stopPolling;
}, [loadAccounts, stopPolling]);
function pollAuth(state: string) {
stopPolling();
pollRef.current = window.setInterval(async () => {
try {
const result = await api.authStatus(state, managementKey);
if (result.status === 'ok') {
stopPolling();
setOauth(undefined);
setCallbackUrl('');
setOauthStatus('Account added.');
setAdding(false);
await loadAccounts();
} else if (result.status === 'error') {
stopPolling();
setOauthStatus(result.error || 'Authorization failed.');
setAdding(false);
}
} catch (pollError) {
stopPolling();
setOauthStatus(errorMessage(pollError));
setAdding(false);
}
}, 2500);
}
async function addAccount() {
setAdding(true);
setOauthStatus('');
try {
const result = await api.startCodexAuth(managementKey);
if (!result.state) throw new Error('The server did not return an OAuth state.');
setOauth({ url: result.url, state: result.state });
window.open(result.url, '_blank', 'noopener,noreferrer');
pollAuth(result.state);
} catch (oauthError) {
setOauthStatus(errorMessage(oauthError));
setAdding(false);
}
}
async function submitCallback(event: FormEvent) {
event.preventDefault();
if (!callbackUrl.trim()) return;
try {
await api.submitCallback(callbackUrl.trim(), managementKey);
setOauthStatus('Callback submitted. Waiting for the account...');
} catch (callbackError) {
setOauthStatus(errorMessage(callbackError));
}
}
return (
<main className="management-shell">
<header className="topbar">
<a className="brand" href="/" aria-label="Vibe Proxy home">
<span className="mark small" aria-hidden="true">V</span>
<span>Vibe Proxy</span>
</a>
<button className="text-button" onClick={onLogout}>Sign out</button>
</header>
<section className="page-intro">
<div>
<p className="eyebrow">OpenAI Codex</p>
<h1>Accounts</h1>
<p className="lede">Connect accounts and check their current usage limits.</p>
</div>
<button className="primary" onClick={addAccount} disabled={adding}>
{adding ? 'Waiting for authorization...' : 'Add account'}
</button>
</section>
{oauth && (
<section className="oauth-panel">
<div>
<strong>Finish authorization in the OpenAI window.</strong>
<p>If it did not open, <a href={oauth.url} target="_blank" rel="noreferrer">open the authorization link</a>.</p>
</div>
<form onSubmit={submitCallback}>
<label htmlFor="callback-url">Remote server? Paste the full callback URL</label>
<div className="inline-form">
<input
id="callback-url"
value={callbackUrl}
onChange={(event) => setCallbackUrl(event.target.value)}
placeholder="http://localhost:1455/auth/callback?code=..."
/>
<button className="secondary-button" disabled={!callbackUrl.trim()}>Submit</button>
</div>
</form>
</section>
)}
{oauthStatus && <p className="notice">{oauthStatus}</p>}
{error && <p className="form-error" role="alert">{error}</p>}
<section className="account-list" aria-live="polite">
{loading ? (
<div className="empty">Loading accounts...</div>
) : accounts.length === 0 ? (
<div className="empty">
<h2>No Codex accounts yet</h2>
<p>Add an OpenAI account to start routing Codex requests.</p>
</div>
) : (
accounts.map((account) => (
<AccountCard
key={`${account.name}:${String(account.auth_index ?? account.authIndex ?? '')}`}
account={account}
managementKey={managementKey}
onDelete={loadAccounts}
/>
))
)}
</section>
</main>
);
}
export default function App() {
const [managementKey, setManagementKey] = useState(() => sessionStorage.getItem(SESSION_KEY) || '');
function logout() {
sessionStorage.removeItem(SESSION_KEY);
setManagementKey('');
}
return managementKey ? (
<Management managementKey={managementKey} onLogout={logout} />
) : (
<Login onLogin={setManagementKey} />
);
}

95
frontend/src/api.ts Normal file
View file

@ -0,0 +1,95 @@
const API_ROOT = '/v0/management';
export interface CodexAccount {
name: string;
type?: string;
provider?: string;
email?: string;
disabled?: boolean;
unavailable?: boolean;
status?: string;
statusMessage?: string;
status_message?: string;
authIndex?: string | number | null;
auth_index?: string | number | null;
[key: string]: unknown;
}
async function request<T>(path: string, managementKey: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_ROOT}${path}`, {
...init,
headers: {
Authorization: `Bearer ${managementKey}`,
'Content-Type': 'application/json',
...init?.headers,
},
});
const text = await response.text();
let body: unknown;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = text;
}
if (!response.ok) {
const detail =
body && typeof body === 'object' && 'error' in body
? String((body as { error: unknown }).error)
: typeof body === 'string'
? body
: response.statusText;
throw new Error(detail || `Request failed with HTTP ${response.status}`);
}
return body as T;
}
function isCodexAccount(account: CodexAccount): boolean {
return [account.type, account.provider].some((value) => String(value || '').toLowerCase() === 'codex');
}
export const api = {
async listAccounts(managementKey: string): Promise<CodexAccount[]> {
const result = await request<{ files?: CodexAccount[] }>('/auth-files', managementKey);
return (result.files || [])
.filter(isCodexAccount)
.map((account) => ({
...account,
email: typeof account.email === 'string' ? account.email.trim() : undefined,
statusMessage: account.statusMessage || account.status_message,
}));
},
deleteAccount(name: string, managementKey: string) {
return request(`/auth-files?name=${encodeURIComponent(name)}`, managementKey, {
method: 'DELETE',
});
},
startCodexAuth(managementKey: string) {
return request<{ url: string; state?: string }>('/codex-auth-url?is_webui=true', managementKey);
},
authStatus(state: string, managementKey: string) {
return request<{ status: 'ok' | 'wait' | 'error'; error?: string }>(
`/get-auth-status?state=${encodeURIComponent(state)}`,
managementKey,
);
},
submitCallback(redirectUrl: string, managementKey: string) {
return request('/oauth-callback', managementKey, {
method: 'POST',
body: JSON.stringify({ provider: 'codex', redirect_url: redirectUrl }),
});
},
getCodexQuota(authIndex: string, managementKey: string) {
return request<Record<string, unknown>>('/codex-quota', managementKey, {
method: 'POST',
body: JSON.stringify({ auth_index: authIndex }),
});
},
};

View file

@ -1,28 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 752 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 756 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 829 B

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 829 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

122
frontend/src/codexQuota.ts Normal file
View file

@ -0,0 +1,122 @@
import { api, type CodexAccount } from './api';
interface UsageWindow {
used_percent?: unknown;
usedPercent?: unknown;
reset_at?: unknown;
resetAt?: unknown;
reset_after_seconds?: unknown;
resetAfterSeconds?: unknown;
}
interface RateLimit {
primary_window?: UsageWindow | null;
primaryWindow?: UsageWindow | null;
secondary_window?: UsageWindow | null;
secondaryWindow?: UsageWindow | null;
}
function normalizeAuthIndex(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) return value.toString();
if (typeof value === 'string') return value.trim() || null;
return null;
}
function normalizeNumberValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function parseCodexUsagePayload(payload: unknown): Record<string, unknown> | null {
if (typeof payload === 'string' && payload.trim()) {
try {
return JSON.parse(payload) as Record<string, unknown>;
} catch {
return null;
}
}
return payload !== null && typeof payload === 'object'
? (payload as Record<string, unknown>)
: null;
}
export interface CodexQuota {
planType: string | null;
windows: Array<{ id: string; label: string; remaining: number | null; resetAt: number | null }>;
}
function resetAt(window: UsageWindow): number | null {
const absolute = normalizeNumberValue(window.reset_at ?? window.resetAt);
if (absolute !== null) return absolute < 1e12 ? absolute * 1000 : absolute;
const offset = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds);
return offset === null ? null : Date.now() + offset * 1000;
}
function addRateLimit(
result: CodexQuota['windows'],
limit: RateLimit | null | undefined,
prefix: string,
labels: [string, string],
) {
const windows = [limit?.primary_window ?? limit?.primaryWindow, limit?.secondary_window ?? limit?.secondaryWindow];
windows.forEach((window, index) => {
if (!window) return;
const used = normalizeNumberValue(window.used_percent ?? window.usedPercent);
result.push({
id: `${prefix}-${index}`,
label: labels[index],
remaining: used === null ? null : Math.max(0, Math.min(100, 100 - used)),
resetAt: resetAt(window),
});
});
}
export async function fetchCodexQuota(
account: CodexAccount,
managementKey: string,
): Promise<CodexQuota> {
const authIndex = normalizeAuthIndex(account.auth_index ?? account.authIndex);
if (!authIndex) throw new Error('This account has no auth index, so quota cannot be queried.');
const response = await api.getCodexQuota(authIndex, managementKey);
const statusCode = Number(response.status_code || 0);
if (statusCode < 200 || statusCode >= 300) {
throw new Error(`Quota request failed with HTTP ${statusCode || 'unknown'}`);
}
const payload = parseCodexUsagePayload(response.body);
if (!payload) throw new Error('The quota response was empty or invalid.');
const source = payload;
const windows: CodexQuota['windows'] = [];
addRateLimit(
windows,
(source.rate_limit ?? source.rateLimit) as RateLimit | undefined,
'codex',
['5-hour limit', 'Weekly limit'],
);
addRateLimit(
windows,
(source.code_review_rate_limit ?? source.codeReviewRateLimit) as RateLimit | undefined,
'review',
['Code review 5-hour limit', 'Code review weekly limit'],
);
return {
planType:
typeof source.plan_type === 'string'
? source.plan_type
: typeof source.planType === 'string'
? source.planType
: typeof account.plan_type === 'string'
? account.plan_type
: typeof account.planType === 'string'
? account.planType
: null,
windows,
};
}

View file

@ -1,69 +0,0 @@
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

@ -1,85 +0,0 @@
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

@ -1,54 +0,0 @@
@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

@ -1,457 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,83 +0,0 @@
@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

@ -1,77 +0,0 @@
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

@ -1,102 +0,0 @@
.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

@ -1,63 +0,0 @@
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

@ -1,277 +0,0 @@
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

@ -1,477 +0,0 @@
.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

@ -1,319 +0,0 @@
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

@ -1,219 +0,0 @@
/**
*
*
* `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

@ -1,33 +0,0 @@
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

@ -1,361 +0,0 @@
@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

@ -1,700 +0,0 @@
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

@ -1,251 +0,0 @@
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

@ -1,114 +0,0 @@
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

@ -1,277 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -1,19 +0,0 @@
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

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

View file

@ -1,155 +0,0 @@
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

@ -1,191 +0,0 @@
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

@ -1,258 +0,0 @@
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

@ -1,190 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,96 +0,0 @@
@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

@ -1,54 +0,0 @@
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

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

View file

@ -1,25 +0,0 @@
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

@ -1,65 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,220 +0,0 @@
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

@ -1,124 +0,0 @@
@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

@ -1,342 +0,0 @@
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

@ -1,87 +0,0 @@
@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

@ -1,50 +0,0 @@
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

@ -1,150 +0,0 @@
@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

@ -1,261 +0,0 @@
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

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

View file

@ -1,31 +0,0 @@
@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

@ -1,19 +0,0 @@
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

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

View file

@ -1,70 +0,0 @@
@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

@ -1,106 +0,0 @@
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

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

View file

@ -1,58 +0,0 @@
.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

@ -1,48 +0,0 @@
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

@ -1,503 +0,0 @@
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

@ -1,95 +0,0 @@
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

@ -1,102 +0,0 @@
@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

@ -1,808 +0,0 @@
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

@ -1,5 +0,0 @@
export const AUTH_FILES_CHANGED_EVENT = 'auth-files-changed';
export const notifyAuthFilesChanged = () => {
window.dispatchEvent(new Event(AUTH_FILES_CHANGED_EVENT));
};

View file

@ -1,12 +0,0 @@
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

@ -1,593 +0,0 @@
@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

@ -1,372 +0,0 @@
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

@ -1,89 +0,0 @@
/* 凭证详情/编辑抽屉内容区 */
.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

@ -1,281 +0,0 @@
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

@ -1,101 +0,0 @@
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

@ -1,90 +0,0 @@
@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

@ -1,90 +0,0 @@
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

@ -1,330 +0,0 @@
@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;
}
}

View file

@ -1,200 +0,0 @@
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
captureQuotaCacheGeneration,
commitIfQuotaCacheCurrent,
useNotificationStore,
useQuotaStore,
} from '@/stores';
import type { AuthFileItem } from '@/types';
import { getStatusFromError, resolveQuotaErrorMessage } from '@/utils/quota';
import { isRuntimeOnlyAuthFile, type QuotaProviderType } from '@/features/authFiles/constants';
import { Button } from '@/components/ui/Button';
import { IconRefreshCw } from '@/components/ui/icons';
import { bindQuotaClasses } from '@/features/quota/types';
import { QUOTA_ADAPTERS, type QuotaCardState } from '@/features/quota/providers';
import styles from './AuthFileQuota.module.scss';
/** 认证文件卡片外衣:紧凑额度样式绑定成类型化契约(缺键在模块初始化即抛)。 */
const compactQuotaClasses = bindQuotaClasses(styles, 'AuthFileQuota.module.scss');
const assertNever = (value: never): never => {
throw new Error(`Unsupported quota type: ${value}`);
};
type QuotaMapUpdater = (
updater: (prev: Record<string, QuotaCardState>) => Record<string, QuotaCardState>
) => void;
export type AuthFileQuotaSectionProps = {
file: AuthFileItem;
quotaType: QuotaProviderType;
disableControls: boolean;
};
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const { file, quotaType, disableControls } = props;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const [resettingQuota, setResettingQuota] = useState(false);
const adapter = QUOTA_ADAPTERS[quotaType];
const quota = useQuotaStore((state) => {
if (quotaType === 'antigravity')
return state.antigravityQuota[file.name] as QuotaCardState | undefined;
if (quotaType === 'claude') return state.claudeQuota[file.name] as QuotaCardState | undefined;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaCardState | undefined;
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaCardState | undefined;
if (quotaType === 'xai') return state.xaiQuota[file.name] as QuotaCardState | undefined;
return assertNever(quotaType);
});
const updateQuotaState = useQuotaStore(
(state) => state[adapter.storeSetter] as unknown as QuotaMapUpdater
);
const refreshQuotaForFile = useCallback(async () => {
if (disableControls) return;
if (isRuntimeOnlyAuthFile(file)) return;
if (file.disabled) return;
if (quota?.status === 'loading') return;
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaState((prev) => ({
...prev,
[file.name]: adapter.buildLoadingState(),
}));
try {
const data = await adapter.fetchQuota(file, t);
commitIfQuotaCacheCurrent(cacheGeneration, () => {
updateQuotaState((prev) => ({
...prev,
[file.name]: adapter.buildSuccessState(data),
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
commitIfQuotaCacheCurrent(cacheGeneration, () => {
updateQuotaState((prev) => ({
...prev,
[file.name]: adapter.buildErrorState(message, status),
}));
showNotification(
t('auth_files.quota_refresh_failed', { name: file.name, message }),
'error'
);
});
}
}, [adapter, disableControls, file, quota?.status, showNotification, t, updateQuotaState]);
const resetQuotaForFile = useCallback(() => {
if (disableControls) return;
if (isRuntimeOnlyAuthFile(file)) return;
if (file.disabled) return;
if (quota?.status === 'loading') return;
if (resettingQuota) return;
const resetQuota = adapter.resetQuota;
if (!resetQuota) return;
showConfirmation({
title: t('codex_quota.reset_confirm_title'),
message: t('codex_quota.reset_confirm_message', { name: file.name }),
confirmText: t('codex_quota.reset_confirm_button'),
variant: 'primary',
onConfirm: async () => {
const cacheGeneration = captureQuotaCacheGeneration();
setResettingQuota(true);
try {
const data = await resetQuota(file, t);
commitIfQuotaCacheCurrent(cacheGeneration, () => {
updateQuotaState((prev) => ({
...prev,
[file.name]: adapter.buildSuccessState(data),
}));
showNotification(t('codex_quota.reset_success', { name: file.name }), 'success');
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
commitIfQuotaCacheCurrent(cacheGeneration, () => {
showNotification(t('codex_quota.reset_failed', { name: file.name, message }), 'error');
});
} finally {
setResettingQuota(false);
}
},
});
}, [
adapter,
disableControls,
file,
quota?.status,
resettingQuota,
showConfirmation,
showNotification,
t,
updateQuotaState,
]);
const quotaStatus = quota?.status ?? 'idle';
const canRefreshQuota = !disableControls && !file.disabled && !resettingQuota;
const canUseResetQuota = canRefreshQuota && quotaStatus !== 'loading';
const showResetQuotaAction = quota !== undefined && Boolean(adapter.canResetQuota?.(quota));
const resetQuotaAction =
adapter.resetQuota && showResetQuotaAction ? (
<Button
type="button"
variant="secondary"
size="sm"
className={styles.quotaResetCreditButton}
onClick={() => resetQuotaForFile()}
disabled={!canUseResetQuota}
loading={resettingQuota}
title={t('codex_quota.reset_button')}
aria-label={t('codex_quota.reset_button')}
>
{!resettingQuota && <IconRefreshCw size={14} />}
{t('codex_quota.reset_button')}
</Button>
) : undefined;
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
return (
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${adapter.i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={() => void refreshQuotaForFile()}
disabled={!canRefreshQuota}
>
{t(`${adapter.i18nPrefix}.idle`)}
</button>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${adapter.i18nPrefix}.load_failed`, {
message: quotaErrorMessage,
})}
</div>
) : quota ? (
<adapter.Body quota={quota} classes={compactQuotaClasses} />
) : (
<div className={styles.quotaMessage}>{t(`${adapter.i18nPrefix}.idle`)}</div>
)}
{quotaStatus !== 'idle' && resetQuotaAction && (
<div className={styles.quotaCardActions}>{resetQuotaAction}</div>
)}
</div>
);
}

View file

@ -1,283 +0,0 @@
@use '../../../styles/mixins' as *;
/* 工作区工具栏:高频操作区,无入场动画,反馈即时 */
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
}
/* ---------- 搜索 ---------- */
.search {
flex: 1 1 220px;
min-width: 180px;
max-width: 340px;
/* Input 组件外层是 .form-group去掉其默认下边距 */
:global(.form-group) {
margin-bottom: 0;
}
:global(.input) {
height: 36px;
border-radius: 10px;
font-size: 13px;
}
}
.searchIcon {
color: var(--text-quaternary);
}
/* ---------- 状态分段控件 ---------- */
.segmented {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.segment {
border: 0;
background: none;
cursor: pointer;
padding: 6px 12px;
border-radius: $radius-full;
font-size: 12px;
font-weight: 550;
line-height: 1.3;
color: var(--text-secondary);
white-space: nowrap;
transition:
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);
&:active {
transform: scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -1px;
}
}
@media (hover: hover) and (pointer: fine) {
.segment:hover {
color: var(--text-primary);
}
}
.segmentActive {
color: var(--text-primary);
font-weight: 650;
background: var(--bg-primary);
box-shadow: var(--shadow);
}
.segmentProblem.segmentActive {
color: var(--danger-color);
}
/* ---------- 排序 ---------- */
.sort {
min-width: 120px;
}
/* ---------- 显示设置 ---------- */
.display {
position: relative;
}
.displayButton {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 550;
color: var(--text-secondary);
white-space: nowrap;
transition:
color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
&:active {
transform: scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
}
@media (hover: hover) and (pointer: fine) {
.displayButton:hover {
color: var(--text-primary);
border-color: var(--border-hover);
}
}
.displayButtonActive {
color: var(--text-primary);
border-color: var(--border-hover);
background: var(--bg-primary);
}
.popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: $z-dropdown;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 216px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--floating-surface);
box-shadow: var(--floating-shadow);
transform-origin: top right;
animation: toolbar-popover-in var(--dur-hover, 200ms) var(--ease-out-strong, ease-out) both;
}
@keyframes toolbar-popover-in {
from {
opacity: 0;
transform: scale(0.95);
}
}
.popoverRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12.5px;
color: var(--text-secondary);
}
.pageSizeInput {
width: 64px;
height: 30px;
padding: 0 8px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-family: $font-mono;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
text-align: right;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-10);
}
}
/* ---------- 删除筛选结果(工具栏右端的 ghost danger ---------- */
.deleteAction {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: $radius-full;
border: 1px solid transparent;
background: none;
cursor: pointer;
font-size: 12px;
font-weight: 550;
color: var(--danger-color);
white-space: nowrap;
transition:
background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
border-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out),
transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out);
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&:active:not(:disabled) {
transform: scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--danger-color);
outline-offset: 1px;
}
}
@media (hover: hover) and (pointer: fine) {
.deleteAction:hover:not(:disabled) {
background: var(--destructive-10);
border-color: var(--destructive-30);
}
}
/* ---------- 响应式 ---------- */
@media (max-width: 900px) {
.search {
flex-basis: 100%;
max-width: none;
}
.deleteAction {
margin-left: 0;
}
}
@include mobile {
.segmented {
overflow-x: auto;
max-width: 100%;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.segment,
.displayButton,
.deleteAction {
transition: none;
}
.segment:active,
.displayButton:active,
.deleteAction:active:not(:disabled) {
transform: none;
}
.popover {
animation: none;
}
}

View file

@ -1,191 +0,0 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconSearch, IconSlidersHorizontal, IconTrash2 } from '@/components/ui/icons';
import {
MAX_CARD_PAGE_SIZE,
MIN_CARD_PAGE_SIZE,
} from '@/features/authFiles/constants';
import type {
AuthFilesSortMode,
AuthFilesStatusFilterMode,
} from '@/features/authFiles/uiState';
import styles from './AuthFilesToolbar.module.scss';
export type AuthFilesToolbarProps = {
search: string;
onSearchChange: (value: string) => void;
statusFilterMode: AuthFilesStatusFilterMode;
statusFilterOptions: Array<{ value: AuthFilesStatusFilterMode; label: string }>;
onStatusFilterChange: (mode: AuthFilesStatusFilterMode) => void;
sortMode: AuthFilesSortMode;
sortOptions: Array<{ value: string; label: string }>;
onSortModeChange: (value: string) => void;
pageSizeInput: string;
onPageSizeInputChange: (event: ChangeEvent<HTMLInputElement>) => void;
onPageSizeCommit: (rawValue: string) => void;
compactMode: boolean;
onCompactModeChange: (value: boolean) => void;
deleteLabel: string;
deleteDisabled: boolean;
deleteLoading: boolean;
onDelete: () => void;
};
/**
* · · · popover
*
*/
export function AuthFilesToolbar(props: AuthFilesToolbarProps) {
const {
search,
onSearchChange,
statusFilterMode,
statusFilterOptions,
onStatusFilterChange,
sortMode,
sortOptions,
onSortModeChange,
pageSizeInput,
onPageSizeInputChange,
onPageSizeCommit,
compactMode,
onCompactModeChange,
deleteLabel,
deleteDisabled,
deleteLoading,
onDelete,
} = props;
const { t } = useTranslation();
const [displaySettingsOpen, setDisplaySettingsOpen] = useState(false);
const displaySettingsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!displaySettingsOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!displaySettingsRef.current?.contains(event.target as Node)) {
setDisplaySettingsOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setDisplaySettingsOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [displaySettingsOpen]);
return (
<div className={styles.toolbar}>
<div className={styles.search}>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t('auth_files.search_placeholder')}
aria-label={t('auth_files.search_label')}
rightElement={<IconSearch className={styles.searchIcon} size={16} />}
/>
</div>
<div
className={styles.segmented}
role="group"
aria-label={t('auth_files.problem_filter_label')}
>
{statusFilterOptions.map((option) => {
const isActive = statusFilterMode === option.value;
const isProblem = option.value === 'problem';
return (
<button
key={option.value}
type="button"
className={`${styles.segment} ${isActive ? styles.segmentActive : ''} ${
isProblem ? styles.segmentProblem : ''
}`}
aria-pressed={isActive}
onClick={() => onStatusFilterChange(option.value)}
>
{option.label}
</button>
);
})}
</div>
<div className={styles.sort}>
<Select
value={sortMode}
options={sortOptions}
onChange={onSortModeChange}
ariaLabel={t('auth_files.sort_label')}
size="sm"
/>
</div>
<div className={styles.display} ref={displaySettingsRef}>
<button
type="button"
className={`${styles.displayButton} ${displaySettingsOpen ? styles.displayButtonActive : ''}`}
aria-expanded={displaySettingsOpen}
aria-controls="auth-files-display-settings"
title={t('auth_files.display_options_label')}
onClick={() => setDisplaySettingsOpen((open) => !open)}
>
<IconSlidersHorizontal size={15} />
<span>{t('auth_files.display_options_label')}</span>
</button>
{displaySettingsOpen && (
<div id="auth-files-display-settings" className={styles.popover}>
<div className={styles.popoverRow}>
<label htmlFor="auth-files-page-size">{t('auth_files.page_size_label')}</label>
<input
id="auth-files-page-size"
className={styles.pageSizeInput}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSizeInput}
onChange={onPageSizeInputChange}
onBlur={(e) => onPageSizeCommit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
/>
</div>
<div className={styles.popoverRow}>
<span>{t('auth_files.compact_mode_label')}</span>
<ToggleSwitch
checked={compactMode}
onChange={onCompactModeChange}
ariaLabel={t('auth_files.compact_mode_label')}
/>
</div>
</div>
)}
</div>
<button
type="button"
className={styles.deleteAction}
onClick={onDelete}
disabled={deleteDisabled}
>
{deleteLoading ? <LoadingSpinner size={13} /> : <IconTrash2 size={14} />}
{deleteLabel}
</button>
</div>
);
}

View file

@ -1,85 +0,0 @@
@use '../../../styles/mixins' as *;
/* 悬浮批量操作条:底部居中的玻璃工具栏(--content-center-x 对齐内容列中心) */
.container {
position: fixed;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: var(--content-center-x, 50%);
transform: translateX(-50%);
width: min(960px, calc(100vw - 24px));
z-index: $z-dropdown;
pointer-events: none;
}
.bar {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 16px;
padding: 10px 14px;
border-radius: 16px;
--glass-blur: 14px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
backdrop-filter: var(--glass-backdrop-filter);
box-shadow: var(--shadow-lg);
}
.left,
.right {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.count {
font-family: $font-mono;
font-size: 12px;
font-weight: 650;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
padding-right: 4px;
white-space: nowrap;
}
.bar :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;
}
.bar :global(.btn:active:not(:disabled)) {
transform: scale(0.96);
}
@include mobile {
.container {
width: calc(100vw - 16px);
}
.bar {
flex-direction: column;
align-items: stretch;
}
.left,
.right {
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
.bar :global(.btn) {
transition: none;
}
.bar :global(.btn:active:not(:disabled)) {
transform: none;
}
}

View file

@ -1,213 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { animate } from 'motion/mini';
import { Button } from '@/components/ui/Button';
import { prefersReducedMotion } from '@/hooks/motion';
import { useActionBarHeightVar } from '@/hooks/useActionBarHeightVar';
import styles from './BatchActionBar.module.scss';
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BASE_TRANSFORM = 'translateX(-50%)';
const HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
export type BatchActionBarProps = {
selectionCount: number;
selectablePageCount: number;
selectableFilteredCount: number;
disableControls: boolean;
batchStatusDisabled: boolean;
onSelectPage: () => void;
onSelectFiltered: () => void;
onInvertPage: () => void;
onDeselectAll: () => void;
onDownload: () => void;
onEnable: () => void;
onDisable: () => void;
onDelete: () => void;
};
/**
* portal body
* - >0 0.28s 退0.22s
* - reduced-motion translateX(-50%)
* - --auth-files-action-bar-height
*/
export function BatchActionBar(props: BatchActionBarProps) {
const {
selectionCount,
selectablePageCount,
selectableFilteredCount,
disableControls,
batchStatusDisabled,
onSelectPage,
onSelectFiltered,
onInvertPage,
onDeselectAll,
onDownload,
onEnable,
onDisable,
onDelete,
} = props;
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const animationRef = useRef<ReturnType<typeof animate> | null>(null);
const selectionCountRef = useRef(selectionCount);
const previousCountRef = useRef(0);
useActionBarHeightVar(containerRef, '--auth-files-action-bar-height', visible);
useEffect(() => {
selectionCountRef.current = selectionCount;
if (selectionCount > 0) {
setVisible(true);
}
}, [selectionCount]);
useLayoutEffect(() => {
if (!visible) return;
const currentCount = selectionCount;
const previousCount = previousCountRef.current;
const el = containerRef.current;
if (!el) return;
animationRef.current?.stop();
animationRef.current = null;
const reduced = prefersReducedMotion();
if (currentCount > 0 && previousCount === 0) {
if (reduced) {
el.style.transform = BASE_TRANSFORM;
animationRef.current = animate(
el,
{ opacity: [0, 1] },
{
duration: 0.15,
ease: 'linear',
onComplete: () => {
el.style.opacity = '1';
},
}
);
} else {
animationRef.current = animate(
el,
{ transform: [HIDDEN_TRANSFORM, BASE_TRANSFORM], opacity: [0, 1] },
{
duration: 0.28,
ease: easePower3Out,
onComplete: () => {
el.style.transform = BASE_TRANSFORM;
el.style.opacity = '1';
},
}
);
}
} else if (currentCount === 0 && previousCount > 0) {
const finishExit = () => {
if (selectionCountRef.current === 0) {
setVisible(false);
}
};
if (reduced) {
el.style.transform = BASE_TRANSFORM;
animationRef.current = animate(
el,
{ opacity: [1, 0] },
{ duration: 0.12, ease: 'linear', onComplete: finishExit }
);
} else {
animationRef.current = animate(
el,
{ transform: [BASE_TRANSFORM, HIDDEN_TRANSFORM], opacity: [1, 0] },
{ duration: 0.22, ease: easePower2In, onComplete: finishExit }
);
}
}
previousCountRef.current = currentCount;
}, [visible, selectionCount]);
useEffect(
() => () => {
animationRef.current?.stop();
animationRef.current = null;
},
[]
);
if (!visible || typeof document === 'undefined') return null;
return createPortal(
<div className={styles.container} ref={containerRef}>
<div
className={styles.bar}
role="toolbar"
aria-label={t('auth_files.batch_toolbar_label')}
aria-orientation="horizontal"
>
<div className={styles.left}>
<span className={styles.count} aria-live="polite">
{t('auth_files.batch_selected', { count: selectionCount })}
</span>
<Button
variant="secondary"
size="sm"
onClick={onSelectPage}
disabled={selectablePageCount === 0}
>
{t('auth_files.batch_select_page')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={onSelectFiltered}
disabled={selectableFilteredCount === 0}
>
{t('auth_files.batch_select_filtered')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onInvertPage}
disabled={selectablePageCount === 0}
>
{t('auth_files.batch_invert_page')}
</Button>
<Button variant="ghost" size="sm" onClick={onDeselectAll}>
{t('auth_files.batch_deselect')}
</Button>
</div>
<div className={styles.right}>
<Button
variant="secondary"
size="sm"
onClick={onDownload}
disabled={disableControls || selectionCount === 0}
>
{t('auth_files.batch_download')}
</Button>
<Button size="sm" onClick={onEnable} disabled={batchStatusDisabled}>
{t('auth_files.batch_enable')}
</Button>
<Button variant="secondary" size="sm" onClick={onDisable} disabled={batchStatusDisabled}>
{t('auth_files.batch_disable')}
</Button>
<Button
variant="danger"
size="sm"
onClick={onDelete}
disabled={disableControls || selectionCount === 0}
>
{t('common.delete')}
</Button>
</div>
</div>
</div>,
document.body
);
}

View file

@ -1,168 +0,0 @@
@use '../../../styles/mixins' as *;
/* ============================================================
* OAuth 配置双卡排除模型 / 模型别名共用外衣panel 配方
* 16px 圆角 / 1px 边框 / 82% 透感纸面列表行走 mono 遥测
* ============================================================ */
.panel {
display: flex;
flex-direction: column;
gap: 14px;
padding: clamp(16px, 2vw, 20px);
border-radius: 16px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
}
.panelHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.panelTitle {
margin: 0;
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.panelExtra {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.panelBody {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
/* ---------- 视图切换(列表/图谱) ---------- */
.viewModeSwitch {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: $radius-md;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
/* ---------- provider 行 ---------- */
.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 45%, transparent);
transition:
background-color $transition-fast,
border-color $transition-fast;
}
@media (hover: hover) and (pointer: fine) {
.item:hover {
border-color: var(--border-hover);
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
}
}
.itemInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.itemProvider {
font-family: $font-mono;
font-size: 12.5px;
font-weight: 650;
color: var(--text-primary);
@include text-ellipsis;
}
.itemCount {
font-size: 11.5px;
color: var(--text-tertiary);
}
.itemActions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ---------- 别名图谱区 ---------- */
.aliasChartSection {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.aliasChartHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.aliasChartTitle {
margin: 0;
font-family: $font-mono;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.aliasChart {
min-width: 0;
}
/* ---------- 按压反馈 ---------- */
.panel :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;
}
.panel :global(.btn:active:not(:disabled)) {
transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.panel :global(.btn) {
transition: none;
}
.panel :global(.btn:active:not(:disabled)) {
transform: none;
}
}

View file

@ -1,77 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import type { OAuthConfigLoadError } from '@/features/authFiles/constants';
import styles from './OAuthConfigPanels.module.scss';
export type OAuthExcludedCardProps = {
disableControls: boolean;
excludedError: OAuthConfigLoadError;
excluded: Record<string, string[]>;
onRetry: () => void | Promise<void>;
onAdd: () => void;
onEdit: (provider: string) => void;
onDelete: (provider: string) => void;
};
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
const { t } = useTranslation();
const { disableControls, excludedError, excluded, onRetry, onAdd, onEdit, onDelete } = props;
return (
<section className={styles.panel}>
<header className={styles.panelHead}>
<h3 className={styles.panelTitle}>{t('oauth_excluded.title')}</h3>
<div className={styles.panelExtra}>
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError !== null}>
{t('oauth_excluded.add')}
</Button>
</div>
</header>
<div className={styles.panelBody}>
{excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : excludedError === 'load' ? (
<EmptyState
title={t('notification.refresh_failed')}
action={
<Button variant="secondary" size="sm" onClick={() => void onRetry()}>
{t('common.refresh')}
</Button>
}
/>
) : excludedError === 'loading' ? (
<EmptyState title={t('common.loading')} />
) : Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className={styles.list}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className={styles.item}>
<div className={styles.itemInfo}>
<div className={styles.itemProvider}>{provider}</div>
<div className={styles.itemCount}>
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
</div>
<div className={styles.itemActions}>
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}

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