refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
@@ -1,16 +0,0 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
}
describe('BrandIcon architecture boundaries', () => {
it('keeps brand registry rules outside the React render component', () => {
const source = readSource('./BrandIcon.tsx');
expect(source).toContain("from './brandRegistry.js'");
expect(source).not.toContain('const BRAND_DEFINITIONS');
expect(source).not.toContain('function getBrand(');
});
});
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { BrandIcon } from './BrandIcon.js';
describe('BrandIcon rendering', () => {
it('does not force a colored background shell for recognized brands', () => {
const root = create(<BrandIcon model="nvidia/vila" size={44} />);
const wrappers = root.root.findAll((node) => node.type === 'div');
const brandedWrapper = wrappers.find((node) => node.props.style?.width === 44 && node.props.style?.height === 44);
expect(brandedWrapper).toBeDefined();
expect(brandedWrapper?.props.style?.background).not.toBe('linear-gradient(135deg, #76b900, #4a8c0b)');
});
});
-98
View File
@@ -1,98 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getBrand } from './BrandIcon.js';
describe('getBrand', () => {
it('detects simple prefixed model names', () => {
expect(getBrand('claude-opus-4-6')?.name).toBe('Anthropic');
expect(getBrand('gpt-4o-mini')?.name).toBe('OpenAI');
});
it('detects brand for regex and wrapped model patterns', () => {
expect(getBrand('re:^claude-(opus|sonnet)-4-5$')?.name).toBe('Anthropic');
expect(getBrand('[Summer] gpt-5.2-codex')?.name).toBe('OpenAI');
});
it('detects brand from namespaced model paths', () => {
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google');
});
it('detects issue #38 existing-brand additions', () => {
expect(getBrand('google/shieldgemma-9b')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('PaLM-2')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('imagen3-fast')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('veo3-pro')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('davinci-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('babbage-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('codex-mini-2025-05-16')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('ds-chat')).toMatchObject({ name: 'DeepSeek', icon: expect.any(String) });
expect(getBrand('tongyi-deepresearch-30b-a3b')).toMatchObject({ name: '通义千问', icon: expect.any(String) });
expect(getBrand('microsoft/kosmos-2')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('phi4')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('stablediffusion3.5-l')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('sd3-medium')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('tencent-hunyuanvideo-hd')).toMatchObject({ name: '腾讯混元', icon: expect.any(String) });
expect(getBrand('mini2.1')).toMatchObject({ name: 'MiniMax', icon: expect.any(String) });
expect(getBrand('stepfun-ai/step3')).toMatchObject({ name: '阶跃星辰', icon: expect.any(String) });
});
it('detects issue #38 newly added brands with registered icons', () => {
expect(getBrand('nvidia/vila')).toMatchObject({ name: 'NVIDIA', icon: expect.any(String) });
expect(getBrand('ibm/granite-3.3-8b-instruct')).toMatchObject({ name: 'IBM', icon: expect.any(String) });
expect(getBrand('BAAI/bge-m3')).toMatchObject({ name: 'BAAI', icon: expect.any(String) });
expect(getBrand('bytedance/seed-oss-36b-instruct')).toMatchObject({ name: 'ByteDance', icon: expect.any(String) });
expect(getBrand('internlm/internlm2_5-7b-chat')).toMatchObject({ name: 'InternLM', icon: expect.any(String) });
expect(getBrand('mj_turbo')).toMatchObject({ name: 'Midjourney', icon: expect.any(String) });
expect(getBrand('deepl-zh-en')).toMatchObject({ name: 'DeepL', icon: expect.any(String) });
expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) });
});
it('detects new provider and platform brands with lobe-hub icons', () => {
expect(getBrand('openrouter/openrouter-auto')).toMatchObject({ name: 'OpenRouter', icon: expect.any(String) });
expect(getBrand('groq/compound-beta')).toMatchObject({ name: 'Groq', icon: expect.any(String) });
expect(getBrand('deepinfra/deepinfra-chat')).toMatchObject({ name: 'DeepInfra', icon: expect.any(String) });
expect(getBrand('fireworks-ai/firefunction-v2')).toMatchObject({ name: 'Fireworks', icon: expect.any(String) });
expect(getBrand('together/together-chat')).toMatchObject({ name: 'Together AI', icon: expect.any(String) });
expect(getBrand('replicate/recraft-v3')).toMatchObject({ name: 'Replicate', icon: expect.any(String) });
expect(getBrand('cerebras/cerebras-chat')).toMatchObject({ name: 'Cerebras', icon: expect.any(String) });
expect(getBrand('jamba-1.5-mini')).toMatchObject({ name: 'AI21 Labs', icon: expect.any(String) });
expect(getBrand('baichuan-m1-14b-instruct')).toMatchObject({ name: '百川智能', icon: expect.any(String) });
expect(getBrand('sensenova/sensenova-v6')).toMatchObject({ name: 'SenseNova', icon: expect.any(String) });
expect(getBrand('bedrock/us.amazon.nova-pro-v1:0')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) });
expect(getBrand('amazon/nova-premier-v1')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) });
expect(getBrand('dashscope/wanx2.1-t2i-turbo')).toMatchObject({ name: '百炼', icon: expect.any(String) });
});
it('recognizes user-reported uncategorized brands when current lobe-hub icons exist', () => {
expect(getBrand('relace/relace-search')).toMatchObject({ name: 'Relace', icon: expect.any(String) });
expect(getBrand('xiaomi/mimo-v2-pro')).toMatchObject({ name: 'Xiaomi MiMo', icon: expect.any(String) });
expect(getBrand('arcee-ai/trinity-mini')).toMatchObject({ name: 'Arcee', icon: expect.any(String) });
expect(getBrand('aion-labs/aion-2.0')).toMatchObject({ name: 'AionLabs', icon: expect.any(String) });
expect(getBrand('allenai/olmo-3-32b-think')).toMatchObject({ name: 'AI2', icon: expect.any(String) });
expect(getBrand('deepcogito/cogito-v2.1-671b')).toMatchObject({ name: 'DeepCogito', icon: expect.any(String) });
expect(getBrand('essentialai/rnj-1-instruct')).toMatchObject({ name: 'Essential AI', icon: expect.any(String) });
expect(getBrand('inflection/inflection-3-pi')).toMatchObject({ name: 'Inflection', icon: expect.any(String) });
expect(getBrand('liquid/lfm-2-24b-a2b')).toMatchObject({ name: 'Liquid AI', icon: expect.any(String) });
expect(getBrand('LongCat-Flash-Lite')).toMatchObject({ name: 'LongCat', icon: expect.any(String) });
expect(getBrand('morph/morph-v3-fast')).toMatchObject({ name: 'Morph', icon: expect.any(String) });
expect(getBrand('nousresearch/hermes-4-70b')).toMatchObject({ name: 'Nous Research', icon: expect.any(String) });
expect(getBrand('inception/mercury')).toMatchObject({ name: 'Inception', icon: expect.any(String) });
expect(getBrand('upstage/solar-pro-3')).toMatchObject({ name: 'Upstage', icon: expect.any(String) });
expect(getBrand('2zai')).toMatchObject({ name: 'Z.ai', icon: expect.any(String) });
});
it('prefers intrinsic model vendors over upstream hosting platforms', () => {
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
expect(getBrand('deepinfra/meta-llama/llama-3.3-70b-instruct')?.name).toBe('Meta');
expect(getBrand('vertexai/google/gemini-2.5-pro')?.name).toBe('Google');
expect(getBrand('azureai/gpt-4o')?.name).toBe('OpenAI');
});
it('returns null for unknown model names', () => {
expect(getBrand('totally-unknown-model')).toBeNull();
});
it('does not misclassify GPTQ llama variants as OpenAI', () => {
expect(getBrand('TheBloke/Llama-2-7B-GPTQ')?.name).toBe('Meta');
});
});
-184
View File
@@ -1,184 +0,0 @@
import React, { useEffect, useState, type CSSProperties } from 'react';
import {
avatarLetters,
getBrand,
getBrandIconUrl,
hashColor,
normalizeBrandIconKey,
type BrandInfo,
} from './brandRegistry.js';
export type { BrandInfo } from './brandRegistry.js';
export {
getBrand,
getBrandIconUrl,
hashColor,
normalizeBrandIconKey,
} from './brandRegistry.js';
const BRAND_ICON_VERSION = '1.83.0';
const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`;
const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`;
export function useIconCdn() {
const [isDark, setIsDark] = useState(() => {
if (typeof document === 'undefined') return false;
return document.documentElement.getAttribute('data-theme') === 'dark';
});
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined;
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
}
type BrandGlyphProps = {
brand?: Pick<BrandInfo, 'name' | 'icon'> | null;
model?: string | null;
icon?: string | null;
alt?: string;
size?: number;
fallbackText?: string | null;
style?: CSSProperties;
};
export function BrandGlyph({ brand, model, icon, alt, size = 16, fallbackText, style }: BrandGlyphProps) {
const cdn = useIconCdn();
const resolvedBrand = brand || (model ? getBrand(model) : null);
const resolvedIcon = normalizeBrandIconKey(icon || resolvedBrand?.icon || null);
const [imgError, setImgError] = useState(false);
useEffect(() => {
setImgError(false);
}, [resolvedIcon]);
if (resolvedIcon && !imgError) {
const src = getBrandIconUrl(resolvedIcon, cdn);
if (src) {
return (
<img
src={src}
alt={alt || resolvedBrand?.name || model || 'brand'}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
...style,
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
}
const fallback = (fallbackText ?? resolvedBrand?.name ?? model ?? '').trim();
if (!fallback) return null;
return (
<span
aria-hidden="true"
style={{
width: size,
height: size,
borderRadius: Math.max(4, Math.round(size * 0.33)),
background: hashColor(fallback),
color: 'white',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: Math.max(9, Math.round(size * 0.5)),
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
overflow: 'hidden',
...style,
}}
>
{avatarLetters(fallback)}
</span>
);
}
export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) {
const brand = getBrand(model);
if (brand) {
return (
<div style={{
width: size,
height: size,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: 'transparent',
}}
>
<BrandGlyph brand={brand} size={size} fallbackText={brand.name} />
</div>
);
}
return (
<div className="model-card-avatar" style={{ width: size, height: size, background: hashColor(model), fontSize: size > 32 ? 16 : 10 }}>
{avatarLetters(model)}
</div>
);
}
export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) {
const brand = getBrand(model);
if (!brand) return null;
return <BrandGlyph brand={brand} size={size} fallbackText={brand.name} />;
}
export function ModelBadge({ model, style }: { model: string; style?: CSSProperties }) {
const brand = getBrand(model);
const badgeColors: Record<string, { bg: string; border: string; text: string }> = {
OpenAI: { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' },
Anthropic: { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' },
Google: { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' },
DeepSeek: { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' },
'Jina AI': { bg: 'rgba(17,24,39,0.08)', border: 'rgba(17,24,39,0.16)', text: '#111827' },
Microsoft: { bg: 'rgba(0,164,239,0.08)', border: 'rgba(0,164,239,0.18)', text: '#0f62fe' },
NVIDIA: { bg: 'rgba(118,185,0,0.10)', border: 'rgba(118,185,0,0.18)', text: '#4a8c0b' },
xAI: { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' },
};
const brandName = brand?.name || '';
const colors = badgeColors[brandName] || {
bg: 'var(--color-primary-light)',
border: 'rgba(79,70,229,0.15)',
text: 'var(--color-primary)',
};
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 10px 2px 6px',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
background: colors.bg,
color: colors.text,
border: `1px solid ${colors.border}`,
whiteSpace: 'nowrap',
...style,
}}
>
<InlineBrandIcon model={model} size={14} />
{model}
</span>
);
}
-90
View File
@@ -1,90 +0,0 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
maxWidth?: number;
bodyStyle?: React.CSSProperties;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
};
export default function CenteredModal({
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
closeOnBackdrop = false,
closeOnEscape = false,
showCloseButton = true,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
const canUsePortal = typeof document !== 'undefined'
&& !!document.body
&& typeof document.body.appendChild === 'function'
&& typeof document.body.removeChild === 'function';
useEffect(() => {
if (!open || !canUsePortal) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [canUsePortal, open]);
useEffect(() => {
if (!open || !closeOnEscape || !canUsePortal) return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [canUsePortal, closeOnEscape, open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={closeOnBackdrop ? onClose : undefined}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div className="modal-title">{title}</div>
{showCloseButton ? (
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label="关闭弹框"
>
×
</button>
) : null}
</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
);
return canUsePortal ? createPortal(modal, document.body) : modal;
}
-124
View File
@@ -1,124 +0,0 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const toast = useToast();
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
const handleSubmit = async () => {
setError('');
if (!oldToken || !newToken || !confirmToken) {
setError('请填写所有字段');
return;
}
if (newToken !== confirmToken) {
setError('两次输入的新 Token 不一致');
return;
}
if (newToken.length < 6) {
setError('新 Token 至少 6 个字符');
return;
}
setSaving(true);
try {
const res = await api.changeAuthToken(oldToken, newToken);
if (res.success) {
toast.success('Token 已更新,请使用新 Token 重新登录');
persistAuthSession(localStorage, newToken);
onClose();
setOldToken('');
setNewToken('');
setConfirmToken('');
} else {
setError(res.message || '更新失败');
}
} catch (e: any) {
setError(e.message || '更新失败');
} finally {
setSaving(false);
}
};
if (!presence.shouldRender) return null;
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">修改管理员 Token</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
-49
View File
@@ -1,49 +0,0 @@
import React from 'react';
import CenteredModal from './CenteredModal.js';
type DeleteConfirmModalProps = {
open: boolean;
title?: string;
description: React.ReactNode;
confirmText?: string;
cancelText?: string;
loading?: boolean;
onConfirm: () => void;
onClose: () => void;
};
export default function DeleteConfirmModal({
open,
title = '确认删除',
description,
confirmText = '确认删除',
cancelText = '取消',
loading = false,
onConfirm,
onClose,
}: DeleteConfirmModalProps) {
return (
<CenteredModal
open={open}
onClose={onClose}
title={title}
maxWidth={560}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
{loading
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
: confirmText}
</button>
</>
)}
>
<div className="alert alert-error" style={{ margin: 0 }}>
<div className="alert-title">此操作不可撤销</div>
<div style={{ marginTop: 6, fontSize: 13 }}>{description}</div>
</div>
</CenteredModal>
);
}
-15
View File
@@ -1,15 +0,0 @@
import React from 'react';
type MobileBatchBarProps = {
info: React.ReactNode;
children: React.ReactNode;
};
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return (
<div className="mobile-actions-bar mobile-batch-bar">
<span className="mobile-actions-info">{info}</span>
<div className="mobile-actions-row">{children}</div>
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
import React from 'react';
type MobileCardProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
headerActions?: React.ReactNode;
footerActions?: React.ReactNode;
compact?: boolean;
className?: string;
bodyClassName?: string;
children: React.ReactNode;
};
type MobileFieldProps = {
label: React.ReactNode;
value: React.ReactNode;
stacked?: boolean;
};
export function MobileCard({
title,
subtitle,
actions,
headerActions,
footerActions,
compact = false,
className = '',
bodyClassName = '',
children,
}: MobileCardProps) {
const resolvedHeaderActions = headerActions ?? actions;
const cardClassName = ['mobile-card', compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
const cardBodyClassName = ['mobile-card-body', bodyClassName].filter(Boolean).join(' ');
return (
<div className={cardClassName}>
<div className="mobile-card-header">
<div className="mobile-card-title-block">
<div className="mobile-card-title">{title}</div>
{subtitle ? <div className="mobile-card-subtitle">{subtitle}</div> : null}
</div>
{resolvedHeaderActions ? <div className="mobile-card-header-actions">{resolvedHeaderActions}</div> : null}
</div>
<div className={cardBodyClassName}>{children}</div>
{footerActions ? <div className="mobile-card-footer-actions">{footerActions}</div> : null}
</div>
);
}
export function MobileField({ label, value, stacked = false }: MobileFieldProps) {
return (
<div className={`mobile-field${stacked ? ' is-stacked' : ''}`}>
<div className="mobile-field-label">{label}</div>
<div className="mobile-field-value">{value}</div>
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
type MobileDrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
};
function MobileDrawer({
open,
onClose,
children,
title,
closeLabel = '关闭导航',
side = 'left',
}: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const titleId = useId();
const labelledBy = title ? titleId : undefined;
useEffect(() => {
if (open) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsClosing(false);
}, 280);
return () => clearTimeout(timer);
}
}, [open]);
const handleClose = useCallback(() => {
setIsClosing(true);
onClose();
}, [onClose]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleClose, open]);
if (!shouldRender) return null;
const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
<div
className="mobile-drawer-backdrop"
onClick={handleClose}
aria-hidden="true"
/>
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
<div className="mobile-drawer-toolbar">
{title ? (
<div className="mobile-drawer-title" id={titleId}>
{title}
</div>
) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
×
</button>
</div>
{children}
</div>
</div>
);
const portalTarget = typeof document !== 'undefined' ? document.body : null;
return portalTarget ? createPortal(drawer, portalTarget) : drawer;
}
export { MobileDrawer };
export default MobileDrawer;
-24
View File
@@ -1,24 +0,0 @@
import React from 'react';
import MobileDrawer from './MobileDrawer.js';
type MobileFilterSheetProps = {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
};
export default function MobileFilterSheet({
open,
onClose,
title = '筛选',
children,
}: MobileFilterSheetProps) {
return (
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
<div className="mobile-filter-panel">
{children}
</div>
</MobileDrawer>
);
}
@@ -1,94 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => <div>mock-chart</div>,
}));
vi.mock('./BrandIcon.js', () => ({
InlineBrandIcon: () => <span>brand-icon</span>,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('ModelAnalysisPanel token summaries', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
globalThis.getComputedStyle = vi.fn(() => ({
getPropertyValue: () => '#9ca3af',
})) as unknown as typeof getComputedStyle;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders total token summaries with compact units', () => {
let root!: WebTestRenderer;
act(() => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
});
const rendered = collectText(root!.root);
expect(rendered).toContain('总 Tokens');
expect(rendered).toContain('611.5M');
root?.unmount();
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
let root!: WebTestRenderer;
await expect(act(async () => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
})).resolves.toBeUndefined();
root?.unmount();
});
});
-288
View File
@@ -1,288 +0,0 @@
import { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
import { useThemeLabelColor } from './useThemeLabelColor.js';
type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
interface SpendDistributionItem { model: string; spend: number; calls: number; }
interface SpendTrendItem { day: string; spend: number; }
interface CallsDistributionItem { model: string; calls: number; share: number; }
interface CallRankingItem { model: string; calls: number; successRate: number; avgLatencyMs: number; spend: number; tokens: number; }
interface ModelAnalysisData {
totals?: { spend?: number; calls?: number; tokens?: number };
spendDistribution?: SpendDistributionItem[];
spendTrend?: SpendTrendItem[];
callsDistribution?: CallsDistributionItem[];
callRanking?: CallRankingItem[];
}
interface ModelAnalysisPanelProps {
data?: ModelAnalysisData | null;
}
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
{ key: 'spend', label: '消耗分布', icon: '💰' },
{ key: 'trend', label: '消耗趋势', icon: '📈' },
{ key: 'calls', label: '调用分布', icon: '🔄' },
{ key: 'rank', label: '排行榜', icon: '🏆' },
];
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function formatCurrency(value: number): string {
const n = toSafeNumber(value);
if (n >= 1000) return `$${n.toFixed(2)}`;
if (n >= 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(6)}`;
}
function formatPercent(value: number): string {
return `${toSafeNumber(value).toFixed(1)}%`;
}
function EmptyBlock() {
return (
<div className="empty-state" style={{ padding: 28 }}>
<div className="empty-state-title">暂无模型调用数据</div>
<div className="empty-state-desc">等待代理流量进入后会自动生成统计图表</div>
</div>
);
}
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const [activeTab, setActiveTab] = useState<TabKey>('spend');
const labelColor = useThemeLabelColor();
const totals = {
spend: toSafeNumber(data?.totals?.spend),
calls: toSafeNumber(data?.totals?.calls),
tokens: toSafeNumber(data?.totals?.tokens),
};
const spendDistribution = (data?.spendDistribution || []).slice(0, 10);
const spendTrend = data?.spendTrend || [];
const callsDistribution = (data?.callsDistribution || []).slice(0, 10);
const callRanking = (data?.callRanking || []).slice(0, 10);
const hasData = totals.calls > 0
|| spendDistribution.length > 0
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
const spendBarSpec = useMemo(() => ({
type: 'bar' as const,
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
xField: 'value', yField: 'model', direction: 'horizontal' as const,
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
animation: true, background: 'transparent',
}), [spendDistribution, labelColor]);
const trendSpec = useMemo(() => ({
type: 'area' as const,
data: [{ id: 'data', values: spendTrend.map(d => ({ day: d.day, spend: toSafeNumber(d.spend) })) }],
xField: 'day', yField: 'spend',
line: { style: { lineWidth: 2.5, curveType: 'monotone' as const, stroke: '#4f46e5' } },
area: { style: { fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 0, y1: 1, stops: [{ offset: 0, color: 'rgba(79,70,229,0.25)' }, { offset: 1, color: 'rgba(79,70,229,0.02)' }] }, curveType: 'monotone' as const } },
point: { visible: true, style: { size: 7, fill: '#4f46e5', stroke: '#fff', lineWidth: 2 } },
axes: [{ orient: 'bottom' as const, label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'left' as const, label: { style: { fontSize: 11, fill: labelColor } } }],
tooltip: { mark: { content: [{ key: () => '消耗', value: (datum: any) => formatCurrency(datum?.spend ?? 0) }] } },
animation: true, background: 'transparent',
}), [spendTrend, labelColor]);
const callsPieSpec = useMemo(() => ({
type: 'pie' as const,
data: [{ id: 'data', values: callsDistribution.map(d => ({ model: d.model, calls: toSafeNumber(d.calls) })) }],
valueField: 'calls', categoryField: 'model',
outerRadius: 0.8, innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
legends: { visible: false },
animation: true,
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
background: 'transparent',
}), [callsDistribution, labelColor]);
if (!hasData) return <EmptyBlock />;
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
<div className="stat-summary-card stat-summary-purple">
<div className="stat-summary-card-label">总消耗</div>
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
</div>
<div className="stat-summary-card stat-summary-blue">
<div className="stat-summary-card-label">总调用</div>
<div className="stat-summary-card-value">{Math.round(totals.calls).toLocaleString()}</div>
</div>
<div className="stat-summary-card stat-summary-green">
<div className="stat-summary-card-label">总 Tokens</div>
<div className="stat-summary-card-value">{formatCompactTokenMetric(totals.tokens)}</div>
</div>
</div>
{/* Pill Tabs */}
<div style={{ marginBottom: 16 }}>
<div className="pill-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
</div>
{/* Chart Content */}
{activeTab === 'spend' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={spendBarSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{spendDistribution.map(d => (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
</span>
))}
</div>
</div>
)}
{activeTab === 'trend' && (
<div style={{ height: 300 }}>
<VChart spec={trendSpec} />
</div>
)}
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{callsDistribution.map((d, idx) => {
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
return (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
<InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
</span>
);
})}
</div>
</div>
)}
{activeTab === 'rank' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latSec = latMs / 1000;
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
let latColor: string;
let latBg: string;
if (latSec <= 15) {
// green gradient: 0s=#22c55e → 15s=blend towards yellow
const t = Math.min(latSec / 15, 1);
const r = Math.round(34 + t * (245 - 34));
const g = Math.round(197 + t * (158 - 197));
const b = Math.round(94 + t * (11 - 94));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else if (latSec <= 60) {
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
const t = Math.min((latSec - 15) / 45, 1);
const r = Math.round(245 + t * (239 - 245));
const g = Math.round(158 + t * (68 - 158));
const b = Math.round(11 + t * (68 - 11));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else {
latColor = '#ef4444';
latBg = 'rgba(239,68,68,0.08)';
}
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
return (
<tr key={item.model}>
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
background: index < 3
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
-93
View File
@@ -1,93 +0,0 @@
import { describe, expect, it } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
function collectText(node: ReturnType<typeof create>['root']): string {
return node.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
}
function collectInstanceText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectInstanceText(child);
}).join('');
}
describe('ModernSelect', () => {
it('renders icon nodes for the selected option', () => {
const root = create(
<ModernSelect
value="nvidia"
onChange={() => {}}
options={[
{
value: 'nvidia',
label: 'NVIDIA',
description: 'NVIDIA 品牌图标',
iconNode: <span>🟢</span>,
} as any,
]}
/>,
);
expect(collectText(root.root)).toContain('🟢');
expect(collectText(root.root)).toContain('NVIDIA');
});
it('filters searchable options by label and description keywords', async () => {
const root = create(
<ModernSelect
searchable
searchPlaceholder="筛选站点"
value=""
onChange={() => {}}
options={[
{ value: 'alpha', label: 'Alpha Mirror', description: 'https://alpha.example.com' },
{ value: 'beta', label: 'Beta Relay (codex)', description: 'https://beta.example.com' },
{ value: 'gamma', label: 'Gamma API', description: 'https://third.example.com' },
]}
/>,
);
const trigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
await act(async () => {
trigger.props.onClick();
});
const searchInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '筛选站点'
));
await act(async () => {
searchInput.props.onChange({ target: { value: 'codex' } });
});
let optionButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
await act(async () => {
searchInput.props.onChange({ target: { value: 'third.example.com' } });
});
optionButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
});
});
-202
View File
@@ -1,202 +0,0 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
'data-testid'?: string;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
searchable?: boolean;
searchPlaceholder?: string;
};
export default function ModernSelect({
value,
onChange,
options,
'data-testid': dataTestId,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
searchable = false,
searchPlaceholder = 'Search...',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
const visibleOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!searchable || !query) return options;
return options.filter((item) => {
const haystack = [
item.label,
item.description,
item.value,
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [options, searchQuery, searchable]);
useEffect(() => {
if (!open) return;
if (typeof document === 'undefined') return;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (!open && searchQuery) {
setSearchQuery('');
}
}, [open, searchQuery]);
const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconNode) {
return item.iconNode;
}
if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
}
if (item.iconText) {
return <span className="modern-select-option-icon-text">{item.iconText}</span>;
}
return null;
};
return (
<div
ref={rootRef}
data-testid={dataTestId}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
aria-expanded={open}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected ? (
<span className="modern-select-value-content">
{renderOptionIcon(selected)}
<span>{selected.label}</span>
</span>
) : (
placeholder
)}
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
{searchable && (
<div className="modern-select-search-shell">
<input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={searchPlaceholder}
className="modern-select-search-input"
/>
</div>
)}
{visibleOptions.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
visibleOptions.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
disabled={item.disabled}
>
<div className="modern-select-option-main">
{renderOptionIcon(item)}
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
);
}
-173
View File
@@ -1,173 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
const levelColors: Record<string, string> = {
info: 'var(--color-info)',
warning: 'var(--color-warning)',
error: 'var(--color-danger)',
};
const typeLabels: Record<string, string> = {
checkin: '签到',
balance: '余额',
token: '令牌',
proxy: '代理',
status: '状态',
site_notice: '站点公告',
};
export default function NotificationPanel({
open,
onClose,
anchorRef,
onUnreadCountChange,
}: {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
onUnreadCountChange?: (count: number) => void;
}) {
const { t: tr } = useI18n();
const presence = useAnimatedVisibility(open, 160);
const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<string>('');
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const load = useCallback(async () => {
setLoading(true);
try {
const params = filter ? `type=${filter}` : '';
const data = await api.getEvents(params);
setEvents(data);
// Auto mark all as read on open
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
if (hasUnread) {
api.markAllEventsRead().catch(() => {});
onUnreadCountChange?.(0);
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [filter, onUnreadCountChange]);
useEffect(() => {
if (open) load();
}, [open, load]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
panelRef.current && !panelRef.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, onClose, anchorRef]);
const clearAll = async () => {
await api.clearEvents();
setEvents([]);
onUnreadCountChange?.(0);
};
if (!presence.shouldRender) return null;
return (
<div
ref={panelRef}
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
<button onClick={clearAll} className="btn btn-link">
{tr('清空')}
</button>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
<button key={filterType} onClick={() => setFilter(filterType)}
style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
cursor: 'pointer',
}}>
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
</button>
))}
</div>
{/* Events list */}
<div style={{ maxHeight: 360, overflow: 'auto' }}>
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
{!loading && events.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{tr('暂无通知')}
</div>
)}
{events.map((ev: any) => {
const targetPath = buildEventNavigationPath(ev);
const openTarget = () => {
onClose();
navigate(targetPath);
};
return (
<div
key={ev.id}
className="notification-event-item"
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
cursor: 'pointer',
}}
onClick={openTarget}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openTarget();
}
}}
>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
background: levelColors[ev.level] || 'var(--color-info)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 500, fontSize: 13 }}>{ev.title}</span>
<span style={{ fontSize: 10, padding: '1px 5px', borderRadius: 8, background: 'var(--color-bg)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-light)' }}>
{tr(typeLabels[ev.type] || ev.type)}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{ev.message}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
{formatDateTimeMinuteLocal(ev.createdAt)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -1,16 +0,0 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readPageSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('ResponsiveBatchActionBar page adoption', () => {
it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => {
expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar');
});
});
@@ -1,30 +0,0 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveBatchActionBar from './ResponsiveBatchActionBar.js';
describe('ResponsiveBatchActionBar', () => {
it('renders the shared mobile batch bar on mobile', () => {
const root = create(
<ResponsiveBatchActionBar isMobile info="已选 2 项">
<button type="button">批量启用</button>
</ResponsiveBatchActionBar>,
);
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
expect(bar).toBeTruthy();
expect(root.root.findByType('button').children).toContain('批量启用');
});
it('renders the desktop card wrapper on desktop', () => {
const root = create(
<ResponsiveBatchActionBar isMobile={false} info="已选 3 项">
<button type="button">批量删除</button>
</ResponsiveBatchActionBar>,
);
const card = root.root.find((node) => node.props.className === 'card');
expect(card).toBeTruthy();
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
});
});
@@ -1,42 +0,0 @@
import React from 'react';
import MobileBatchBar from './MobileBatchBar.js';
type ResponsiveBatchActionBarProps = {
isMobile: boolean;
info: React.ReactNode;
children: React.ReactNode;
desktopStyle?: React.CSSProperties;
infoStyle?: React.CSSProperties;
};
const DEFAULT_DESKTOP_STYLE: React.CSSProperties = {
padding: 12,
display: 'flex',
gap: 8,
flexWrap: 'wrap',
alignItems: 'center',
};
const DEFAULT_INFO_STYLE: React.CSSProperties = {
fontSize: 13,
fontWeight: 600,
};
export default function ResponsiveBatchActionBar({
isMobile,
info,
children,
desktopStyle,
infoStyle,
}: ResponsiveBatchActionBarProps) {
if (isMobile) {
return <MobileBatchBar info={info}>{children}</MobileBatchBar>;
}
return (
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
{children}
</div>
);
}
@@ -1,58 +0,0 @@
import React from 'react';
import MobileFilterSheet from './MobileFilterSheet.js';
type ResponsiveFilterPanelProps = {
isMobile: boolean;
mobileOpen: boolean;
onMobileOpen?: () => void;
onMobileClose: () => void;
mobileTitle: string;
mobileContent: React.ReactNode;
desktopContent?: React.ReactNode;
mobileTrigger?: React.ReactNode;
mobileTriggerLabel?: string;
mobileTriggerWrapperClassName?: string;
mobileTriggerWrapperStyle?: React.CSSProperties;
mobileTriggerButtonClassName?: string;
mobileTriggerButtonStyle?: React.CSSProperties;
};
export default function ResponsiveFilterPanel({
isMobile,
mobileOpen,
onMobileOpen,
onMobileClose,
mobileTitle,
mobileContent,
desktopContent = null,
mobileTrigger,
mobileTriggerLabel = '筛选',
mobileTriggerWrapperClassName = 'mobile-filter-row',
mobileTriggerWrapperStyle,
mobileTriggerButtonClassName = 'btn btn-ghost',
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
}: ResponsiveFilterPanelProps) {
if (!isMobile) {
return <>{desktopContent}</>;
}
return (
<>
{mobileTrigger ?? (onMobileOpen ? (
<div className={mobileTriggerWrapperClassName} style={mobileTriggerWrapperStyle}>
<button
type="button"
className={mobileTriggerButtonClassName}
style={mobileTriggerButtonStyle}
onClick={onMobileOpen}
>
{mobileTriggerLabel}
</button>
</div>
) : null)}
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
{mobileContent}
</MobileFilterSheet>
</>
);
}
-21
View File
@@ -1,21 +0,0 @@
import React from 'react';
type ResponsiveFormGridProps = {
columns?: 1 | 2 | 3;
children: React.ReactNode;
className?: string;
};
export default function ResponsiveFormGrid({
columns = 2,
children,
className,
}: ResponsiveFormGridProps) {
const classes = [
'responsive-form-grid',
`responsive-form-grid-${columns}`,
className,
].filter(Boolean).join(' ');
return <div className={classes}>{children}</div>;
}
-305
View File
@@ -1,305 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
interface SiteResult {
id: number;
name: string;
url: string;
}
interface AccountResult {
id: number;
username: string | null;
status?: string | null;
balance?: number | null;
segment?: 'session' | 'apikey';
site?: { name: string } | null;
}
interface AccountTokenResult {
id: number;
accountId: number;
name: string;
tokenGroup?: string | null;
account?: {
username?: string | null;
segment?: 'session' | 'apikey';
} | null;
site?: { name: string } | null;
}
interface CheckinLogResult {
id: number;
accountId: number;
message?: string | null;
createdAt?: string | null;
account?: { username?: string | null } | null;
}
interface ProxyLogResult {
id: number;
modelRequested?: string | null;
status?: string | null;
latencyMs?: number | null;
createdAt?: string | null;
}
interface ModelSearchResult {
name: string;
accountCount: number;
tokenCount: number;
siteCount: number;
}
interface SearchResult {
accounts: AccountResult[];
accountTokens: AccountTokenResult[];
sites: SiteResult[];
checkinLogs: CheckinLogResult[];
proxyLogs: ProxyLogResult[];
models: ModelSearchResult[];
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const presence = useAnimatedVisibility(open, 180);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const timerRef = useRef<number>();
useEffect(() => {
if (open) {
setQuery('');
setResults(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults(null);
return;
}
setLoading(true);
try {
const res = await api.search(q);
setResults({
models: Array.isArray(res?.models) ? res.models : [],
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
accountTokens: Array.isArray(res?.accountTokens) ? res.accountTokens : [],
sites: Array.isArray(res?.sites) ? res.sites : [],
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
});
} catch {
// ignore search errors in modal
} finally {
setLoading(false);
}
}, []);
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => doSearch(val), 300);
};
const goTo = (path: string) => {
onClose();
navigate(path);
};
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!presence.shouldRender) return null;
const hasResults = results && (
results.models.length
|| results.accounts.length
|| results.accountTokens.length
|| results.sites.length
|| results.checkinLogs.length
|| results.proxyLogs.length
);
return (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
value={query}
onChange={e => handleInput(e.target.value)}
placeholder={t('搜索站点、账号、模型、日志...')}
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
/>
{loading && <span className="spinner spinner-sm" />}
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
</div>
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
{query && !loading && !hasResults && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{t('没有找到匹配结果')}
</div>
)}
{results?.models.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
{results.models.map((m) => (
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{m.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{m.accountCount} {t('个账号')} · {m.tokenCount} {t('个令牌')} · {m.siteCount} {t('个站点')}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.sites.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
{results.sites.map((s) => (
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{s.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
</div>
</button>
))}
</div>
) : null}
{results?.accounts.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
{results.accounts.map((a) => (
<button
key={a.id}
className="search-result-item"
onClick={() => goTo(buildAccountFocusPath(a.id, {
openRebind: a.status === 'expired',
segment: a.segment,
}))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>
{a.username?.trim() || (a.segment === 'apikey' ? t('API Key 连接') : `ID:${a.id}`)}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{a.site?.name || t('未关联站点')}
{a.segment === 'apikey' ? ` · ${t('API Key 连接')}` : ''}
{' · '}
{t('余额')} ${(a.balance || 0).toFixed(2)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.accountTokens.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
{results.accountTokens.map((token) => (
<button
key={token.id}
className="search-result-item"
onClick={() => goTo(buildTokenFocusPath(token.id))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{token.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{(token.account?.username?.trim() || (token.account?.segment === 'apikey' ? t('API Key 连接') : t('未命名')))}
{' · '}
{token.site?.name || t('未关联站点')}
{token.tokenGroup ? ` · ${token.tokenGroup}` : ''}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.checkinLogs.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
{results.checkinLogs.map((l) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{l.account?.username || `ID:${l.accountId}`}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{l.message || '-'} · {formatDateLocal(l.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.proxyLogs.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
{results.proxyLogs.map((l) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{l.modelRequested || '-'}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{l.status || '-'} · {l.latencyMs || 0}ms · {formatDateTimeMinuteLocal(l.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
</div>
</div>
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
type SiteBadgeLinkProps = {
siteId?: number | null;
siteName?: string | null;
className?: string;
badgeClassName?: string;
badgeStyle?: React.CSSProperties;
};
export default function SiteBadgeLink({
siteId,
siteName,
className = 'badge-link',
badgeClassName = 'badge badge-muted',
badgeStyle,
}: SiteBadgeLinkProps) {
const label = String(siteName || '').trim() || '-';
const normalizedSiteId = Number(siteId);
if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) {
return (
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
);
}
return (
<Link to={`/sites?focusSiteId=${Math.trunc(normalizedSiteId)}`} className={className}>
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
</Link>
);
}
@@ -1,105 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import SiteCreatedModal from './SiteCreatedModal.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('SiteCreatedModal', () => {
it('uses the shared centered modal shell and close button instead of a native dialog skin', async () => {
const onChoice = vi.fn();
const onClose = vi.fn();
const root = create(
<SiteCreatedModal
siteName="Demo Site"
onChoice={onChoice}
onClose={onClose}
/>,
);
expect(root.root.findAllByType('dialog')).toHaveLength(0);
const backdrop = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-backdrop')
));
const footer = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-footer')
));
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭弹框'
));
expect(backdrop).toBeTruthy();
expect(footer).toBeTruthy();
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(onChoice).not.toHaveBeenCalled();
});
it('keeps both next-step actions visible while promoting API key flow for api-key-first presets', async () => {
const onChoice = vi.fn();
const onClose = vi.fn();
const root = create(
<SiteCreatedModal
siteName="CodingPlan"
initializationPresetId="codingplan-openai"
initialSegment="apikey"
onChoice={onChoice}
onClose={onClose}
/>,
);
const choiceButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
&& collectText(node) !== '稍后配置'
));
expect(choiceButtons.map((button) => collectText(button))).toEqual(
expect.arrayContaining([
'添加 API Key(推荐)',
'添加账号(用户名密码登录)',
]),
);
expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true);
const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)');
await act(async () => {
sessionButton!.props.onClick();
});
expect(onChoice).toHaveBeenCalledWith('session');
});
it('uses the supplied session label for OAuth-style session actions', () => {
const root = create(
<SiteCreatedModal
siteName="Codex Site"
initialSegment="session"
sessionLabel="添加 OAuth 连接"
onChoice={() => {}}
onClose={() => {}}
/>,
);
const buttons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
));
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
});
});
-103
View File
@@ -1,103 +0,0 @@
import CenteredModal from './CenteredModal.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type NextStepChoice = 'session' | 'apikey' | 'later';
type Props = {
siteName: string;
initializationPresetId?: string | null;
initialSegment?: 'session' | 'apikey';
sessionLabel?: string;
onChoice: (choice: NextStepChoice) => void;
onClose: () => void;
};
export default function SiteCreatedModal({
siteName,
initializationPresetId,
initialSegment = 'session',
sessionLabel = '添加账号(用户名密码登录)',
onChoice,
onClose,
}: Props) {
const preset = getSiteInitializationPreset(initializationPresetId);
const apiKeyFirst = initialSegment === 'apikey';
const helperText = preset?.description
|| (apiKeyFirst
? '该平台更适合直接通过 Base URL + API Key 接入,后续再补模型初始化。'
: '接下来您可以继续补充登录连接或 API Key。');
const primaryAction = apiKeyFirst
? {
choice: 'apikey' as const,
label: '添加 API Key(推荐)',
}
: {
choice: 'session' as const,
label: sessionLabel,
};
const secondaryAction = apiKeyFirst
? {
choice: 'session' as const,
label: sessionLabel,
}
: {
choice: 'apikey' as const,
label: '添加 API Key',
};
return (
<CenteredModal
open
onClose={onClose}
title="站点创建成功"
maxWidth={520}
closeOnBackdrop
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={() => onChoice('later')} className="btn btn-ghost">
稍后配置
</button>
<button
onClick={() => onChoice(secondaryAction.choice)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{secondaryAction.label}
</button>
<button
onClick={() => onChoice(primaryAction.choice)}
className="btn btn-primary"
>
{primaryAction.label}
</button>
</>
)}
>
<div className="alert alert-success animate-scale-in" style={{ margin: 0 }}>
<div className="alert-title">站点已添加成功</div>
<div className="site-created-summary">
站点 <strong>"{siteName}"</strong> 已加入列表,您现在可以继续补充连接信息。
</div>
</div>
{preset ? (
<div className="alert alert-info" style={{ margin: 0 }}>
<div className="alert-title">{preset.label}</div>
<div className="site-created-helper-text">
{helperText}
</div>
</div>
) : (
<p className="site-created-helper-text">
{helperText}
</p>
)}
<p className="site-created-note">
提示:您可以随时在“站点管理”页面补充账号或 API Key。
</p>
</CenteredModal>
);
}
-82
View File
@@ -1,82 +0,0 @@
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
type: ToastType;
message: string;
exiting?: boolean;
}
interface ToastContextValue {
toast: (type: ToastType, message: string) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
const icons: Record<ToastType, React.ReactNode> = {
success: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /></svg>,
error: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /></svg>,
info: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>,
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 250);
}, []);
const addToast = useCallback((type: ToastType, message: string) => {
const id = ++idRef.current;
setToasts(prev => [...prev, { id, type, message }]);
setTimeout(() => removeToast(id), 3200);
}, [removeToast]);
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
const info = useCallback((msg: string) => addToast('info', msg), [addToast]);
// Keep the context object stable so effect dependencies on `useToast()` do not refire
// every time the toast list changes.
const value = useMemo<ToastContextValue>(() => ({
toast: addToast,
success,
error,
info,
}), [addToast, error, info, success]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-container">
{toasts.map(t => (
<div
key={t.id}
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
onClick={() => removeToast(t.id)}
style={{ cursor: 'pointer' }}
>
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
<div className="toast-progress" />
</div>
))}
</div>
</ToastContext.Provider>
);
}
-229
View File
@@ -1,229 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type TooltipSide = 'top' | 'bottom';
type TooltipAlign = 'start' | 'center' | 'end';
type ActiveTooltip = {
target: HTMLElement;
text: string;
side: TooltipSide;
align: TooltipAlign;
};
type TooltipPosition = {
left: number;
top: number;
arrowLeft: number;
side: TooltipSide;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function readTooltipSide(target: HTMLElement): TooltipSide {
return target.getAttribute('data-tooltip-side') === 'bottom' ? 'bottom' : 'top';
}
function readTooltipAlign(target: HTMLElement): TooltipAlign {
const align = target.getAttribute('data-tooltip-align');
if (align === 'start' || align === 'end') return align;
return 'center';
}
function resolveTooltipTarget(eventTarget: EventTarget | null): HTMLElement | null {
if (!(eventTarget instanceof Element)) return null;
const target = eventTarget.closest<HTMLElement>('[data-tooltip]');
if (!target) return null;
const text = target.getAttribute('data-tooltip');
return text && text.trim() ? target : null;
}
export default function TooltipLayer() {
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltip | null>(null);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const bubbleRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const cancelFrame = useCallback(() => {
if (rafRef.current === null || typeof window === 'undefined') return;
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}, []);
const hideTooltip = useCallback(() => {
cancelFrame();
setActiveTooltip(null);
setPosition(null);
}, [cancelFrame]);
const showTooltipForTarget = useCallback((target: HTMLElement | null) => {
if (!target) {
hideTooltip();
return;
}
const text = target.getAttribute('data-tooltip')?.trim();
if (!text) {
hideTooltip();
return;
}
setActiveTooltip({
target,
text,
side: readTooltipSide(target),
align: readTooltipAlign(target),
});
}, [hideTooltip]);
const refreshPosition = useCallback(() => {
if (!activeTooltip || !bubbleRef.current || typeof window === 'undefined') return;
if (!activeTooltip.target.isConnected) {
hideTooltip();
return;
}
const targetRect = activeTooltip.target.getBoundingClientRect();
const bubbleRect = bubbleRef.current.getBoundingClientRect();
const viewportPadding = 12;
const gap = 10;
let left = targetRect.left;
if (activeTooltip.align === 'center') {
left = targetRect.left + targetRect.width / 2 - bubbleRect.width / 2;
} else if (activeTooltip.align === 'end') {
left = targetRect.right - bubbleRect.width;
}
let top = activeTooltip.side === 'bottom'
? targetRect.bottom + gap
: targetRect.top - gap - bubbleRect.height;
left = clamp(left, viewportPadding, window.innerWidth - viewportPadding - bubbleRect.width);
top = clamp(top, viewportPadding, window.innerHeight - viewportPadding - bubbleRect.height);
const targetCenter = targetRect.left + targetRect.width / 2;
const arrowLeft = clamp(targetCenter - left, 14, bubbleRect.width - 14);
setPosition({
left,
top,
arrowLeft,
side: activeTooltip.side,
});
}, [activeTooltip, hideTooltip]);
const scheduleRefresh = useCallback(() => {
cancelFrame();
if (typeof window === 'undefined') return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
refreshPosition();
});
}, [cancelFrame, refreshPosition]);
useEffect(() => {
if (typeof document === 'undefined') return;
document.body.dataset.tooltipPortal = 'true';
return () => {
delete document.body.dataset.tooltipPortal;
};
}, []);
useEffect(() => {
if (typeof document === 'undefined') return;
const handleMouseOver = (event: MouseEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleFocusIn = (event: FocusEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleMouseOut = (event: MouseEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handleFocusOut = (event: FocusEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handlePointerDown = (event: Event) => {
if (!activeTooltip) return;
if (event.target instanceof Node && activeTooltip.target.contains(event.target)) return;
hideTooltip();
};
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('focusout', handleFocusOut);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('focusout', handleFocusOut);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [activeTooltip, hideTooltip, showTooltipForTarget]);
useLayoutEffect(() => {
if (!activeTooltip) return;
setPosition(null);
scheduleRefresh();
}, [activeTooltip, scheduleRefresh]);
useEffect(() => {
if (!activeTooltip || typeof window === 'undefined') return;
const handleViewportChange = () => scheduleRefresh();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [activeTooltip, scheduleRefresh]);
useEffect(() => () => cancelFrame(), [cancelFrame]);
if (!activeTooltip || typeof document === 'undefined') return null;
const tooltip = (
<div className="tooltip-layer" aria-hidden="true">
<div
ref={bubbleRef}
className={`tooltip-bubble tooltip-bubble-${position?.side ?? activeTooltip.side} ${position ? 'is-visible' : ''}`.trim()}
style={position ? {
position: 'fixed',
left: position.left,
top: position.top,
} : {
position: 'fixed',
left: 0,
top: 0,
visibility: 'hidden',
}}
>
{activeTooltip.text}
<span
className={`tooltip-bubble-arrow tooltip-bubble-arrow-${position?.side ?? activeTooltip.side}`}
style={position ? { left: position.arrowLeft } : undefined}
/>
</div>
</div>
);
return createPortal(tooltip, document.body);
}
-71
View File
@@ -1,71 +0,0 @@
export {
collectBrandCandidates,
getAllBrandNames,
getAllBrands,
getBrand,
stripCommonWrappers,
type BrandInfo,
type BrandMatchContext,
} from '../../server/shared/modelBrand.js';
const LEGACY_ICON_ALIASES: Record<string, string> = {
anthropic: 'claude-color',
'claude.color': 'claude-color',
'cohere.color': 'cohere-color',
'doubao.color': 'doubao-color',
'gemini.color': 'gemini-color',
'hunyuan.color': 'hunyuan-color',
meta: 'meta-color',
'meta-brand-color': 'meta-color',
'minimax.color': 'minimax-color',
'qwen.color': 'qwen-color',
'spark.color': 'spark-color',
stability: 'stability-color',
'stability-brand-color': 'stability-color',
stepfun: 'stepfun-color',
'wenxin.color': 'wenxin-color',
xai: 'xai',
'yi.color': 'yi-color',
'zhipu.color': 'zhipu-color',
azure: 'microsoft-color',
'bytedance-brand-color': 'bytedance-color',
};
function normalizeInput(value: string): string {
return String(value || '').trim().toLowerCase();
}
const FALLBACK_COLORS = [
'linear-gradient(135deg, #4f46e5, #818cf8)',
'linear-gradient(135deg, #059669, #34d399)',
'linear-gradient(135deg, #2563eb, #60a5fa)',
'linear-gradient(135deg, #d946ef, #f0abfc)',
'linear-gradient(135deg, #ea580c, #fb923c)',
'linear-gradient(135deg, #0891b2, #22d3ee)',
'linear-gradient(135deg, #7c3aed, #a78bfa)',
'linear-gradient(135deg, #dc2626, #f87171)',
];
export function normalizeBrandIconKey(icon: string | null | undefined): string | null {
const normalized = normalizeInput(icon || '').replace(/\./g, '-');
if (!normalized) return null;
return LEGACY_ICON_ALIASES[normalized] || normalized;
}
export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null {
const normalized = normalizeBrandIconKey(icon);
if (!normalized) return null;
return `${cdn}/${normalized}.png`;
}
export function hashColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0;
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]!;
}
export function avatarLetters(name: string): string {
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
@@ -1,82 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import CenteredModal from './CenteredModal.js';
describe('CenteredModal component', () => {
const originalDocument = globalThis.document;
afterEach(() => {
globalThis.document = originalDocument;
});
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={onClose} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭弹框'
));
const backdrop = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-backdrop')
));
expect(backdrop.props.onClick).toBeUndefined();
expect(onClose).not.toHaveBeenCalled();
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
expect(source).toContain('modal-backdrop');
expect(source).toContain('modal-content');
expect(source).toContain('useAnimatedVisibility');
expect(source).toContain('createPortal');
});
it('renders safely without touching document.body or listeners when no usable body exists', async () => {
globalThis.document = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={() => {}} title="测试弹框" closeOnEscape>
<div>content</div>
</CenteredModal>,
);
});
expect(root.toJSON()).toBeTruthy();
expect(globalThis.document.addEventListener).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});
@@ -1,128 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
const vChartSpy = vi.fn();
vi.mock('@visactor/react-vchart', () => ({
VChart: (props: Record<string, unknown>) => {
vChartSpy(props);
return null;
},
}));
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
describe('DownstreamKeyTrendChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
vChartSpy.mockClear();
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('formats trend bucket timestamps with local display time', async () => {
const bucketStart = '2026-04-05T15:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={3600}
buckets={[
{
startUtc: bucketStart,
totalRequests: 2,
totalTokens: 2128425,
totalCost: 2.128425,
successRate: 100,
},
]}
/>,
);
})).resolves.toBeUndefined();
expect(vChartSpy).toHaveBeenCalledTimes(1);
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
data?: Array<{ values?: Array<{ date?: string; tooltipDate?: string }> }>;
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
tooltip?: {
dimension?: {
title?: {
value?: (datum: Record<string, unknown>) => string;
};
};
};
};
const expectedLabel = formatDateTimeMinuteLocal(bucketStart);
expect(spec.data?.[0]?.values?.[0]?.tooltipDate).toBe(expectedLabel);
expect(spec.tooltip?.dimension?.title?.value?.({ tooltipDate: expectedLabel })).toBe(expectedLabel);
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 3600));
renderer.unmount();
});
it('uses day-only axis labels for daily buckets', async () => {
const bucketStart = '2026-04-05T16:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={86400}
buckets={[
{
startUtc: bucketStart,
totalRequests: 3,
totalTokens: 600,
totalCost: 0.06,
successRate: 66.7,
},
]}
/>,
);
})).resolves.toBeUndefined();
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
};
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 86400));
renderer.unmount();
});
});
@@ -1,228 +0,0 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
type Metric = 'tokens' | 'requests' | 'cost';
const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [
{ key: 'tokens', label: 'Tokens' },
{ key: 'requests', label: '请求数' },
{ key: 'cost', label: '成本' },
];
export type DownstreamKeyTrendBucket = {
startUtc: string | null;
totalRequests: number;
totalTokens: number;
totalCost: number;
successRate: number | null;
};
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
export default function DownstreamKeyTrendChart({
buckets,
bucketSeconds = 3600,
loading,
height = 260,
}: {
buckets: DownstreamKeyTrendBucket[];
bucketSeconds?: number;
loading?: boolean;
height?: number;
}) {
const [metric, setMetric] = useState<Metric>('tokens');
const flatData = useMemo(() => {
if (!Array.isArray(buckets) || buckets.length === 0) return [];
return buckets
.map((bucket) => {
const rawDate = (bucket.startUtc || '').trim();
const value = metric === 'tokens'
? Number(bucket.totalTokens || 0)
: (metric === 'requests'
? Number(bucket.totalRequests || 0)
: Number(bucket.totalCost || 0));
return {
date: rawDate,
tooltipDate: rawDate ? formatDateTimeMinuteLocal(rawDate) : '',
value,
};
})
.filter((row) => row.date.length > 0);
}, [buckets, metric]);
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
if (!flatData || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 32 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
</div>
</div>
);
}
const spec: Record<string, unknown> = {
type: 'area' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
area: {
style: {
curveType: 'monotone',
fillOpacity: 0.2,
},
},
line: {
style: {
curveType: 'monotone',
lineWidth: 2,
},
},
point: { visible: false },
axes: [
{
orient: 'bottom',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
},
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
tooltip: {
dimension: {
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
value: (datum: Record<string, unknown>) => {
const value = Number(datum?.value ?? 0);
if (metric === 'cost') return `$${value.toFixed(6)}`;
return value.toLocaleString();
},
},
],
},
},
color: ['var(--color-primary)'],
background: 'transparent',
animationAppear: {
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
},
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -1,51 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import SiteDistributionChart from './SiteDistributionChart.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => null,
}));
describe('SiteDistributionChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
let renderer!: WebTestRenderer;
await expect(act(async () => {
renderer = create(
<SiteDistributionChart
data={[
{
siteName: 'Demo Site',
platform: 'demo',
totalBalance: 12.34,
totalSpend: 1.23,
accountCount: 2,
},
]}
/>,
);
})).resolves.toBeUndefined();
renderer.unmount();
});
});
@@ -1,287 +0,0 @@
import { useState, useMemo } from 'react';
import { VChart } from '@visactor/react-vchart';
import { useThemeLabelColor } from '../useThemeLabelColor.js';
interface SiteDistributionData {
siteName: string;
platform: string;
totalBalance: number;
totalSpend: number;
accountCount: number;
}
interface SiteDistributionChartProps {
data: SiteDistributionData[];
loading?: boolean;
}
type ViewMode = 'balance' | 'spend';
function coerceDatumRecord(datum: unknown): Record<string, unknown> {
return datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
}
function safeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function SkeletonCircle() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 0',
}}
>
<div
className="skeleton"
style={{
width: 200,
height: 200,
borderRadius: '50%',
}}
/>
<div style={{ marginLeft: 32, display: 'flex', flexDirection: 'column', gap: 12 }}>
{[...Array(4)].map((_, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="skeleton" style={{ width: 12, height: 12, borderRadius: 3 }} />
<div className="skeleton" style={{ width: 80 + i * 10, height: 12 }} />
</div>
))}
</div>
</div>
);
}
function EmptyState() {
return (
<div className="empty-state" style={{ padding: 40 }}>
<div style={{ margin: '0 auto 16px', width: 64, height: 64, opacity: 0.35 }}>
<svg
width="64"
height="64"
fill="none"
viewBox="0 0 24 24"
stroke="var(--color-text-muted)"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"
/>
</svg>
</div>
<div className="empty-state-title" style={{ marginBottom: 4 }}>
暂无站点数据
</div>
<div className="empty-state-desc">添加站点后将自动展示分布图表</div>
</div>
);
}
export default function SiteDistributionChart({ data, loading }: SiteDistributionChartProps) {
const [viewMode, setViewMode] = useState<ViewMode>('balance');
const labelColor = useThemeLabelColor();
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item) => ({
siteName: item.siteName,
platform: item.platform,
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
accountCount: safeNumber(item.accountCount),
}));
}, [data, viewMode]);
const hasData = chartData.length > 0 && chartData.some((d) => d.value > 0);
const PIE_COLORS = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
const spec = useMemo(() => {
if (!hasData) return null;
return {
type: 'pie' as const,
data: [{ id: 'siteData', values: chartData }],
valueField: 'value',
categoryField: 'siteName',
outerRadius: 0.8,
innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
legends: { visible: false },
tooltip: {
mark: {
content: [
{
key: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.siteName || '-');
},
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
const val = safeNumber(item.value);
return `$${val.toFixed(2)}`;
},
},
{
key: '占比',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
const pct = safeNumber(item._percent_);
return `${pct.toFixed(1)}%`;
},
},
{
key: '账户数',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.accountCount || 0);
},
},
] as any,
},
},
color: PIE_COLORS,
animation: true,
background: 'transparent',
};
}, [chartData, hasData, labelColor]);
const formatValue = (value: number): string => {
if (value >= 1000) return `$${value.toFixed(2)}`;
if (value >= 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(6)}`;
};
return (
<div
className="chart-container animate-fade-in"
style={{ padding: 20 }}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
fontWeight: 600,
color: 'var(--color-text-primary)',
}}
>
<svg
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"
/>
</svg>
站点分布
</div>
{/* Toggle buttons */}
<div
style={{
display: 'flex',
gap: 0,
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
padding: 3,
border: '1px solid var(--color-border-light)',
}}
>
<button
onClick={() => setViewMode('balance')}
style={{
padding: '5px 14px',
fontSize: 12,
fontWeight: 500,
border: 'none',
borderRadius: 'calc(var(--radius-sm) - 2px)',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: viewMode === 'balance' ? 'var(--color-primary)' : 'transparent',
color: viewMode === 'balance' ? '#ffffff' : 'var(--color-text-secondary)',
boxShadow: viewMode === 'balance' ? 'var(--shadow-sm)' : 'none',
}}
>
余额分布
</button>
<button
onClick={() => setViewMode('spend')}
style={{
padding: '5px 14px',
fontSize: 12,
fontWeight: 500,
border: 'none',
borderRadius: 'calc(var(--radius-sm) - 2px)',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: viewMode === 'spend' ? 'var(--color-primary)' : 'transparent',
color: viewMode === 'spend' ? '#ffffff' : 'var(--color-text-secondary)',
boxShadow: viewMode === 'spend' ? 'var(--shadow-sm)' : 'none',
}}
>
消耗分布
</button>
</div>
</div>
{/* Content */}
{loading ? (
<SkeletonCircle />
) : !hasData ? (
<EmptyState />
) : (
<div>
<div style={{ width: '100%', height: 300 }}>
{spec && <VChart spec={spec} style={{ width: '100%', height: '100%' }} />}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{chartData.map((d, idx) => (
<span key={d.siteName} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: PIE_COLORS[idx % PIE_COLORS.length], flexShrink: 0 }} />
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.siteName}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>
{formatValue(d.value)}
</span>
</span>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,253 +0,0 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface SiteTrendData {
date: string;
sites: Record<string, { spend: number; calls: number }>;
}
interface SiteTrendChartProps {
data: SiteTrendData[];
loading?: boolean;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
type Metric = 'spend' | 'calls';
const METRIC_OPTIONS: { key: Metric; label: string }[] = [
{ key: 'spend', label: '消耗趋势' },
{ key: 'calls', label: '调用趋势' },
];
const COLOR_PALETTE = [
'#4f46e5',
'#06b6d4',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
const [metric, setMetric] = useState<Metric>('spend');
/* ---------- data transform ---------- */
const flatData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.flatMap((d) =>
Object.entries(d.sites).map(([site, v]) => ({
date: d.date,
site,
value: metric === 'spend' ? v.spend : v.calls,
})),
);
}, [data, metric]);
/* ---------- loading state ---------- */
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 200, height: 32, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
/* ---------- empty state ---------- */
if (!data || data.length === 0 || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 48 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
</div>
</div>
);
}
/* ---------- vchart spec ---------- */
const spec: Record<string, unknown> = {
type: 'line' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
seriesField: 'site',
point: {
visible: true,
style: { size: 6 },
},
line: {
style: { lineWidth: 2, curveType: 'monotone' },
},
legends: {
visible: true,
orient: 'bottom',
padding: { top: 12 },
item: {
shape: { style: { symbolType: 'circle' } },
label: { style: { fontSize: 12 } },
},
},
tooltip: {
mark: {
title: { value: (datum: Record<string, unknown>) => datum?.date ?? '' },
content: [
{
key: (datum: Record<string, unknown>) => datum?.site ?? '',
value: (datum: Record<string, unknown>) => {
const v = Number(datum?.value ?? 0);
return metric === 'spend' ? `$${v.toFixed(4)}` : String(v);
},
},
],
},
dimension: {
title: { value: (datum: Record<string, unknown>) => datum?.date ?? '' },
content: [
{
key: (datum: Record<string, unknown>) => datum?.site ?? '',
value: (datum: Record<string, unknown>) => {
const v = Number(datum?.value ?? 0);
return metric === 'spend' ? `$${v.toFixed(4)}` : String(v);
},
},
],
},
},
animation: true,
animationAppear: {
line: { type: 'clipIn', duration: 800, easing: 'cubicOut' },
point: { type: 'fadeIn', duration: 600, delay: 400, easing: 'cubicOut' },
},
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
},
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
color: COLOR_PALETTE,
background: 'transparent',
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
/* ---------- render ---------- */
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height: 320 }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Styles (inline, consistent with project conventions) */
/* ------------------------------------------------------------------ */
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 20,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 16px',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import MobileBatchBar from './MobileBatchBar.js';
describe('MobileBatchBar', () => {
it('renders info text and action content inside the shared batch bar shell', () => {
const root = create(
<MobileBatchBar info="已选 2 项">
<button type="button">批量删除</button>
</MobileBatchBar>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('已选 2 项');
expect(text).toContain('批量删除');
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy();
});
});
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MobileCard, MobileField } from './MobileCard.js';
describe('MobileCard', () => {
it('renders separate header and footer action slots plus stacked fields', () => {
const root = create(
<MobileCard
title="CardTitle"
subtitle="CardSubtitle"
compact
headerActions={<span>Meta</span>}
footerActions={<button type="button">Action</button>}
>
<MobileField label="Status" value="OK" />
<MobileField label="URL" value="https://example.com/very/long/path" stacked />
</MobileCard>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('CardTitle');
expect(text).toContain('CardSubtitle');
expect(text).toContain('Meta');
expect(text).toContain('Status');
expect(text).toContain('OK');
expect(text).toContain('Action');
const headerActions = root.root.find((node) => node.props?.className === 'mobile-card-header-actions');
const footerActions = root.root.find((node) => node.props?.className === 'mobile-card-footer-actions');
const stackedField = root.root.find((node) => node.props?.className === 'mobile-field is-stacked');
const compactCard = root.root.find((node) => node.props?.className === 'mobile-card is-compact');
expect(headerActions).toBeTruthy();
expect(footerActions).toBeTruthy();
expect(stackedField).toBeTruthy();
expect(compactCard).toBeTruthy();
});
});
-74
View File
@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { act, create } from 'react-test-renderer';
import MobileDrawer from './MobileDrawer.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileDrawer', () => {
it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
const onClose = vi.fn();
let root!: WebTestRenderer;
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
await act(async () => {
root = create(
<MobileDrawer open onClose={onClose} title="导航菜单">
<div>DrawerContent</div>
</MobileDrawer>,
);
});
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('DrawerContent');
expect(text).toContain('导航菜单');
expect(document.body.style.overflow).toBe('hidden');
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
expect(backdrop.type).toBe('div');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className === 'mobile-drawer-close'
));
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
expect(document.body.style.overflow).toBe('');
vi.unstubAllGlobals();
}
});
it('defines independent right-side drawer animations in shared css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('@keyframes drawer-slide-in-right');
expect(css).toContain('@keyframes drawer-slide-out-right');
expect(css).toMatch(/\.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-in-right/);
expect(css).toMatch(/\.mobile-drawer-root\.is-closing \.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-out-right/);
});
});
@@ -1,40 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import MobileFilterSheet from './MobileFilterSheet.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileFilterSheet', () => {
it('wraps content with the shared filter panel shell', () => {
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
const root = create(
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
<div>FilterContent</div>
</MobileFilterSheet>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('筛选条件');
expect(text).toContain('FilterContent');
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
} finally {
vi.unstubAllGlobals();
}
});
});
-3
View File
@@ -1,3 +0,0 @@
export const MOBILE_BREAKPOINT = 768;
export const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveFormGrid from './ResponsiveFormGrid.js';
describe('ResponsiveFormGrid', () => {
it('applies the shared responsive grid class contract', () => {
const root = create(
<ResponsiveFormGrid columns={3}>
<div>Field A</div>
<div>Field B</div>
</ResponsiveFormGrid>,
);
const container = root.root.findByType('div');
expect(container.props.className).toContain('responsive-form-grid');
expect(container.props.className).toContain('responsive-form-grid-3');
});
});
@@ -1,132 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import SearchModal from './SearchModal.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
search: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../i18n.js', () => ({
useI18n: () => ({
t: (value: string) => value,
}),
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function LocationProbe() {
const location = useLocation();
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('SearchModal results', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
Object.defineProperty(globalThis, 'window', {
value: globalThis,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'document', {
value: {
addEventListener: () => undefined,
removeEventListener: () => undefined,
},
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders account token results and navigates API key accounts to the apikey segment', async () => {
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [
{
id: 8,
username: '',
balance: 0,
segment: 'apikey',
site: { name: 'Key Search Site' },
},
],
accountTokens: [
{
id: 15,
name: 'search-token',
tokenGroup: 'default',
accountId: 8,
account: { username: '' },
site: { name: 'Key Search Site' },
},
],
});
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<SearchModal open onClose={onClose} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'search' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('账号令牌');
expect(rendered).toContain('search-token');
expect(rendered).toContain('API Key 连接');
const buttons = root.root.findAll((node) => node.type === 'button');
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
expect(accountButton).toBeTruthy();
await act(async () => {
accountButton!.props.onClick();
});
const locationAfterAccountClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterAccountClick)).toBe('/accounts?segment=apikey&focusAccountId=8');
} finally {
root?.unmount();
}
});
});
@@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import SiteBadgeLink from './SiteBadgeLink.js';
describe('SiteBadgeLink', () => {
it('renders a focus-navigation link when site id is valid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={7} siteName="Demo Site" />
</MemoryRouter>,
);
const link = root.root.findByType('a');
expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7');
expect(String(link.props.className || '')).toContain('badge-link');
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site');
root.unmount();
});
it('falls back to plain badge text when site id is invalid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={0} siteName="Unknown Site" />
</MemoryRouter>,
);
expect(root.root.findAllByType('a')).toHaveLength(0);
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site');
root.unmount();
});
});
-17
View File
@@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('TooltipLayer component', () => {
it('uses a portal-based fixed tooltip layer and is mounted by App', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/TooltipLayer.tsx'), 'utf8');
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain('createPortal');
expect(source).toContain('[data-tooltip]');
expect(source).toContain("position: 'fixed'");
expect(source).toContain('document.body.dataset.tooltipPortal');
expect(appSource).toContain("import TooltipLayer from './components/TooltipLayer.js'");
expect(appSource).toContain('<TooltipLayer />');
});
});
@@ -1,29 +0,0 @@
import { useEffect, useState } from 'react';
export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
const [shouldRender, setShouldRender] = useState(visible);
const [isVisible, setIsVisible] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
return () => window.cancelAnimationFrame(rafId);
}
setIsVisible(true);
return undefined;
}
setIsVisible(false);
if (durationMs <= 0) {
setShouldRender(false);
return undefined;
}
const timerId = globalThis.setTimeout(() => setShouldRender(false), durationMs);
return () => globalThis.clearTimeout(timerId);
}, [visible, durationMs]);
return { shouldRender, isVisible };
}
-128
View File
@@ -1,128 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act, create } from 'react-test-renderer';
import { useIsMobile } from './useIsMobile.js';
function Probe() {
return <div data-mobile={String(useIsMobile())} />;
}
describe('useIsMobile', () => {
const widthRef = { current: 768 };
const listeners = new Map<string, Set<() => void>>();
beforeEach(() => {
widthRef.current = 768;
listeners.clear();
vi.stubGlobal('window', {
innerWidth: widthRef.current,
addEventListener: (event: string, handler: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(handler);
},
removeEventListener: (event: string, handler: () => void) => {
listeners.get(event)?.delete(handler);
},
dispatchEvent: (event: Event) => {
listeners.get(event.type)?.forEach((handler) => handler());
return true;
},
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('does not throw when a partial window mock lacks resize listener APIs', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('does not throw when matchMedia only exposes addEventListener', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
matchMedia: () => ({
matches: true,
media: '(max-width: 768px)',
addEventListener: () => {},
}),
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('treats 767 and 768 as mobile, but 769 as desktop', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<Probe />);
});
const probe = root.root.findByType('div');
expect(probe.props['data-mobile']).toBe('true');
widthRef.current = 767;
window.innerWidth = 767;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 768;
window.innerWidth = 768;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 769;
window.innerWidth = 769;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
expect(root.root.findByType('div').props['data-mobile']).toBe('false');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
});
-40
View File
@@ -1,40 +0,0 @@
import { useEffect, useState } from 'react';
import { MOBILE_BREAKPOINT, MOBILE_MEDIA_QUERY } from './mobileLayout.js';
export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(() => (
typeof window !== 'undefined' ? window.innerWidth <= breakpoint : false
));
useEffect(() => {
if (typeof window === 'undefined') return;
const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
update();
if (media && typeof media.addEventListener === 'function') {
media.addEventListener('change', update);
return () => {
if (typeof media.removeEventListener === 'function') {
media.removeEventListener('change', update);
}
};
}
const addResizeListener = typeof window.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function';
if (!addResizeListener) return;
window.addEventListener('resize', update);
return () => {
if (removeResizeListener) {
window.removeEventListener('resize', update);
}
};
}, [breakpoint]);
return isMobile;
}
-35
View File
@@ -1,35 +0,0 @@
import { useEffect, useState } from 'react';
function readThemeLabelColor(): string | null {
if (typeof document === 'undefined') return null;
const root = document.documentElement;
if (!root || typeof globalThis.getComputedStyle !== 'function') return null;
const color = globalThis.getComputedStyle(root).getPropertyValue('--color-text-secondary').trim();
return color || null;
}
export function useThemeLabelColor(fallback = '#9ca3af'): string {
const [labelColor, setLabelColor] = useState(fallback);
useEffect(() => {
const root = typeof document !== 'undefined' ? document.documentElement : null;
const read = () => {
const color = readThemeLabelColor();
if (color) setLabelColor(color);
};
read();
if (!root || typeof globalThis.MutationObserver !== 'function') {
return undefined;
}
const observer = new globalThis.MutationObserver(read);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return labelColor;
}