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:
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user