feat: Add normalized types and utility functions for handling chat formats
- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling. - Implemented utility functions for creating and merging normalized usage statistics. - Added a new module for shared transformers to manage chat formats. feat: Implement app local state management - Created functions to clear application installation state and manage local storage keys. - Added constants for theme and user profile storage keys. test: Add tests for BrandIcon component rendering - Implemented tests to ensure BrandIcon does not force a colored background for recognized brands. test: Enhance ModelAnalysisPanel tests - Added tests for rendering token summaries with compact units in ModelAnalysisPanel. test: Add tests for ModernSelect component - Implemented tests to verify icon rendering for selected options in ModernSelect. test: Add number formatting tests - Created tests for `formatCompactTokenMetric` to validate token formatting logic. feat: Implement account creation intent handling - Added logic to handle session and API key creation intents in the Accounts page. test: Add tests for dashboard performance card - Implemented tests to verify rendering of performance metrics in the dashboard. feat: Implement default connection segment resolution - Added logic to determine initial connection segments based on platform types. test: Add tests for settings factory reset functionality - Implemented tests to verify the factory reset process and local state clearing. test: Add tests for site creation redirect logic - Implemented tests to verify redirection based on site capabilities during creation. test: Add tests for token routes refresh decision action - Implemented tests to verify refresh decision actions in TokenRoutes. test: Add tests for token routes snapshot load - Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
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)');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,41 @@ describe('getBrand', () => {
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
|
||||
+536
-269
@@ -1,290 +1,557 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
const ICON_CDN = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/dark';
|
||||
const ICON_CDN_LIGHT = 'https://registry.npmmirror.com/@lobehub/icons-static-png/latest/files/light';
|
||||
import React, { useEffect, useState, type CSSProperties } from 'react';
|
||||
|
||||
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`;
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export function useIconCdn() {
|
||||
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
useEffect(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface BrandInfo {
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [
|
||||
{
|
||||
prefixes: ['gpt', 'o1', 'o3', 'o4', 'chatgpt', 'dall-e', 'text-embedding', 'text-davinci', 'text-curie', 'text-babbage', 'text-ada', 'whisper', 'tts'],
|
||||
brand: { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg, #10a37f, #1a7f5a)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['claude'],
|
||||
brand: { name: 'Anthropic', icon: 'claude.color', color: 'linear-gradient(135deg, #d4a574, #c4956a)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['gemini', 'gemma'],
|
||||
brand: { name: 'Google', icon: 'gemini.color', color: 'linear-gradient(135deg, #4285f4, #34a853)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['deepseek'],
|
||||
brand: { name: 'DeepSeek', icon: 'deepseek.color', color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['qwen', 'qwq'],
|
||||
brand: { name: '通义千问', icon: 'qwen.color', color: 'linear-gradient(135deg, #615cf7, #9b8afb)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['glm', 'chatglm', 'codegeex', 'cogview'],
|
||||
brand: { name: '智谱 AI', icon: 'zhipu.color', color: 'linear-gradient(135deg, #3b6cf5, #6366f1)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['llama', 'code-llama', 'codellama'],
|
||||
brand: { name: 'Meta', icon: 'meta', color: 'linear-gradient(135deg, #0668E1, #1877f2)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral'],
|
||||
brand: { name: 'Mistral', icon: 'mistral.color', color: 'linear-gradient(135deg, #f7d046, #f2a900)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['moonshot', 'kimi'],
|
||||
brand: { name: 'Moonshot', icon: 'moonshot', color: 'linear-gradient(135deg, #000000, #333333)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['yi-'],
|
||||
brand: { name: '零一万物', icon: 'yi.color', color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['ernie', 'eb-'],
|
||||
brand: { name: '文心一言', icon: 'wenxin.color', color: 'linear-gradient(135deg, #2932e1, #4468f2)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['spark', 'generalv'],
|
||||
brand: { name: '讯飞星火', icon: 'spark.color', color: 'linear-gradient(135deg, #0070f3, #00d4ff)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['hunyuan'],
|
||||
brand: { name: '腾讯混元', icon: 'hunyuan.color', color: 'linear-gradient(135deg, #00b7ff, #0052d9)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['doubao'],
|
||||
brand: { name: '豆包', icon: 'doubao.color', color: 'linear-gradient(135deg, #3b5bdb, #7048e8)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['minimax', 'abab'],
|
||||
brand: { name: 'MiniMax', icon: 'minimax.color', color: 'linear-gradient(135deg, #6366f1, #818cf8)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['command', 'embed-'],
|
||||
brand: { name: 'Cohere', icon: 'cohere.color', color: 'linear-gradient(135deg, #39594d, #5ba77f)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['phi-'],
|
||||
brand: { name: 'Microsoft', icon: 'azure', color: 'linear-gradient(135deg, #00bcf2, #0078d4)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['grok'],
|
||||
brand: { name: 'xAI', icon: 'xai', color: 'linear-gradient(135deg, #111, #444)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['step-'],
|
||||
brand: { name: '阶跃星辰', icon: 'stepfun', color: 'linear-gradient(135deg, #0066ff, #3399ff)' }
|
||||
},
|
||||
{
|
||||
prefixes: ['flux', 'sd-', 'stable-diffusion', 'sdxl'],
|
||||
brand: { name: 'Stability', icon: 'stability', color: 'linear-gradient(135deg, #8b5cf6, #a855f7)' }
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
export interface BrandMatchContext {
|
||||
raw: string;
|
||||
cleaned: string;
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
interface InternalBrandMatchContext extends BrandMatchContext {
|
||||
candidates: string[];
|
||||
}
|
||||
|
||||
export interface BrandInfo {
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
type BrandDefinition = BrandInfo & {
|
||||
match: (context: InternalBrandMatchContext) => boolean;
|
||||
};
|
||||
|
||||
function normalizeInput(value: string): string {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripCommonWrappers(value: string): string {
|
||||
return value
|
||||
.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '')
|
||||
.replace(/^re:\s*/g, '')
|
||||
.replace(/^\^+/, '')
|
||||
.replace(/\$+$/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function collectBrandCandidates(modelName: string): string[] {
|
||||
const queue: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (value: string) => {
|
||||
const normalized = normalizeInput(value);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
queue.push(normalized);
|
||||
};
|
||||
|
||||
push(modelName);
|
||||
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const candidate = queue[index]!;
|
||||
const cleaned = stripCommonWrappers(candidate);
|
||||
push(cleaned);
|
||||
|
||||
if (cleaned.includes('/')) {
|
||||
for (const part of cleaned.split('/')) push(part);
|
||||
}
|
||||
if (cleaned.includes(':')) {
|
||||
for (const part of cleaned.split(':')) push(part);
|
||||
}
|
||||
if (cleaned.includes(',')) {
|
||||
for (const part of cleaned.split(',')) push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
function buildMatchContext(modelName: string): InternalBrandMatchContext {
|
||||
const candidates = collectBrandCandidates(modelName);
|
||||
const raw = candidates[0] || normalizeInput(modelName);
|
||||
const cleaned = stripCommonWrappers(raw);
|
||||
const segments = Array.from(new Set(
|
||||
candidates
|
||||
.flatMap((candidate) => candidate.split(/[/:,\s]+/g))
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean),
|
||||
));
|
||||
|
||||
return {
|
||||
raw,
|
||||
cleaned,
|
||||
segments,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean {
|
||||
return needles.some((needle) => (
|
||||
context.raw.includes(needle)
|
||||
|| context.cleaned.includes(needle)
|
||||
|| context.candidates.some((candidate) => candidate.includes(needle))
|
||||
));
|
||||
}
|
||||
|
||||
function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean {
|
||||
return needles.some((needle) => (
|
||||
context.raw.startsWith(needle)
|
||||
|| context.cleaned.startsWith(needle)
|
||||
|| context.segments.some((segment) => segment.startsWith(needle))
|
||||
|| context.candidates.some((candidate) => candidate.startsWith(needle))
|
||||
));
|
||||
}
|
||||
|
||||
function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean {
|
||||
return needles.some((needle) => context.segments.includes(needle));
|
||||
}
|
||||
|
||||
function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean {
|
||||
return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate));
|
||||
}
|
||||
|
||||
const BRAND_DEFINITIONS: BrandDefinition[] = [
|
||||
{
|
||||
name: 'OpenAI',
|
||||
icon: 'openai',
|
||||
color: 'linear-gradient(135deg, #10a37f, #1a7f5a)',
|
||||
match: (context) => (
|
||||
startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini'])
|
||||
|| startsWithAny(context, ['o1', 'o3', 'o4', 'tts'])
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Anthropic',
|
||||
icon: 'claude-color',
|
||||
color: 'linear-gradient(135deg, #d4a574, #c4956a)',
|
||||
match: (context) => includesAny(context, ['claude']),
|
||||
},
|
||||
{
|
||||
name: 'Google',
|
||||
icon: 'gemini-color',
|
||||
color: 'linear-gradient(135deg, #4285f4, #34a853)',
|
||||
match: (context) => (
|
||||
includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa'])
|
||||
|| startsWithAny(context, ['veo', 'google/'])
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'DeepSeek',
|
||||
icon: 'deepseek-color',
|
||||
color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)',
|
||||
match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']),
|
||||
},
|
||||
{
|
||||
name: '通义千问',
|
||||
icon: 'qwen-color',
|
||||
color: 'linear-gradient(135deg, #615cf7, #9b8afb)',
|
||||
match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']),
|
||||
},
|
||||
{
|
||||
name: '智谱 AI',
|
||||
icon: 'zhipu-color',
|
||||
color: 'linear-gradient(135deg, #3b6cf5, #6366f1)',
|
||||
match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']),
|
||||
},
|
||||
{
|
||||
name: 'Meta',
|
||||
icon: 'meta-color',
|
||||
color: 'linear-gradient(135deg, #0668E1, #1877f2)',
|
||||
match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']),
|
||||
},
|
||||
{
|
||||
name: 'Mistral',
|
||||
icon: 'mistral-color',
|
||||
color: 'linear-gradient(135deg, #f7d046, #f2a900)',
|
||||
match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']),
|
||||
},
|
||||
{
|
||||
name: 'Moonshot',
|
||||
icon: 'moonshot',
|
||||
color: 'linear-gradient(135deg, #000000, #333333)',
|
||||
match: (context) => includesAny(context, ['moonshot', 'kimi']),
|
||||
},
|
||||
{
|
||||
name: '零一万物',
|
||||
icon: 'yi-color',
|
||||
color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)',
|
||||
match: (context) => (
|
||||
startsWithAny(context, ['yi-'])
|
||||
|| matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/)
|
||||
),
|
||||
},
|
||||
{
|
||||
name: '文心一言',
|
||||
icon: 'wenxin-color',
|
||||
color: 'linear-gradient(135deg, #2932e1, #4468f2)',
|
||||
match: (context) => includesAny(context, ['ernie', 'eb-']),
|
||||
},
|
||||
{
|
||||
name: '讯飞星火',
|
||||
icon: 'spark-color',
|
||||
color: 'linear-gradient(135deg, #0070f3, #00d4ff)',
|
||||
match: (context) => includesAny(context, ['spark', 'generalv']),
|
||||
},
|
||||
{
|
||||
name: '腾讯混元',
|
||||
icon: 'hunyuan-color',
|
||||
color: 'linear-gradient(135deg, #00b7ff, #0052d9)',
|
||||
match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']),
|
||||
},
|
||||
{
|
||||
name: '豆包',
|
||||
icon: 'doubao-color',
|
||||
color: 'linear-gradient(135deg, #3b5bdb, #7048e8)',
|
||||
match: (context) => includesAny(context, ['doubao']),
|
||||
},
|
||||
{
|
||||
name: 'MiniMax',
|
||||
icon: 'minimax-color',
|
||||
color: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
||||
match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']),
|
||||
},
|
||||
{
|
||||
name: 'Cohere',
|
||||
icon: 'cohere-color',
|
||||
color: 'linear-gradient(135deg, #39594d, #5ba77f)',
|
||||
match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']),
|
||||
},
|
||||
{
|
||||
name: 'Microsoft',
|
||||
icon: 'microsoft-color',
|
||||
color: 'linear-gradient(135deg, #00bcf2, #0078d4)',
|
||||
match: (context) => (
|
||||
includesAny(context, ['microsoft/', 'phi-', 'kosmos'])
|
||||
|| hasExactSegment(context, ['phi4'])
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'xAI',
|
||||
icon: 'xai',
|
||||
color: 'linear-gradient(135deg, #111, #444)',
|
||||
match: (context) => includesAny(context, ['grok']),
|
||||
},
|
||||
{
|
||||
name: '阶跃星辰',
|
||||
icon: 'stepfun-color',
|
||||
color: 'linear-gradient(135deg, #0066ff, #3399ff)',
|
||||
match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']),
|
||||
},
|
||||
{
|
||||
name: 'Stability',
|
||||
icon: 'stability-color',
|
||||
color: 'linear-gradient(135deg, #8b5cf6, #a855f7)',
|
||||
match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']),
|
||||
},
|
||||
{
|
||||
name: 'NVIDIA',
|
||||
icon: 'nvidia-color',
|
||||
color: 'linear-gradient(135deg, #76b900, #4a8c0b)',
|
||||
match: (context) => (
|
||||
includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos'])
|
||||
|| startsWithAny(context, ['nv-'])
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'IBM',
|
||||
icon: 'ibm',
|
||||
color: 'linear-gradient(135deg, #0f62fe, #4589ff)',
|
||||
match: (context) => includesAny(context, ['ibm/', 'granite']),
|
||||
},
|
||||
{
|
||||
name: 'BAAI',
|
||||
icon: 'baai',
|
||||
color: 'linear-gradient(135deg, #111827, #374151)',
|
||||
match: (context) => includesAny(context, ['baai/', 'bge-']),
|
||||
},
|
||||
{
|
||||
name: 'ByteDance',
|
||||
icon: 'bytedance-color',
|
||||
color: 'linear-gradient(135deg, #325ab4, #0f66ff)',
|
||||
match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']),
|
||||
},
|
||||
{
|
||||
name: 'InternLM',
|
||||
icon: 'internlm-color',
|
||||
color: 'linear-gradient(135deg, #1b3882, #4063c5)',
|
||||
match: (context) => includesAny(context, ['internlm']),
|
||||
},
|
||||
{
|
||||
name: 'Midjourney',
|
||||
icon: 'midjourney',
|
||||
color: 'linear-gradient(135deg, #4c6ef5, #748ffc)',
|
||||
match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']),
|
||||
},
|
||||
{
|
||||
name: 'DeepL',
|
||||
icon: 'deepl-color',
|
||||
color: 'linear-gradient(135deg, #0f2b46, #21476f)',
|
||||
match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']),
|
||||
},
|
||||
{
|
||||
name: 'Jina AI',
|
||||
icon: 'jina',
|
||||
color: 'linear-gradient(135deg, #111827, #4b5563)',
|
||||
match: (context) => includesAny(context, ['jina']),
|
||||
},
|
||||
];
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const BRAND_RULES = BRAND_MAP.map((entry) => ({
|
||||
brand: entry.brand,
|
||||
prefixMatchers: entry.prefixes.map((prefix) => ({
|
||||
prefix,
|
||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(prefix)}(?=$|[^a-z0-9])`),
|
||||
})),
|
||||
const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({
|
||||
brand,
|
||||
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`),
|
||||
}));
|
||||
|
||||
function collectBrandCandidates(modelName: string): string[] {
|
||||
const queue: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (value: string) => {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
queue.push(normalized);
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
push(modelName);
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const candidate = queue[i];
|
||||
|
||||
// Strip common route/model wrappers, e.g. "[Summer] gpt-5.2".
|
||||
push(candidate.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, ''));
|
||||
|
||||
if (candidate.startsWith('re:')) {
|
||||
push(candidate.slice(3).trim());
|
||||
}
|
||||
push(candidate.replace(/^\^+/, '').replace(/\$+$/, ''));
|
||||
|
||||
if (candidate.includes('/')) {
|
||||
for (const part of candidate.split('/')) push(part);
|
||||
}
|
||||
if (candidate.includes(':')) {
|
||||
for (const part of candidate.split(':')) push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return queue;
|
||||
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 getBrand(modelName: string): BrandInfo | null {
|
||||
const candidates = collectBrandCandidates(modelName);
|
||||
const context = buildMatchContext(modelName);
|
||||
|
||||
// Prefer strict prefix matching first for predictable behavior.
|
||||
for (const candidate of candidates) {
|
||||
for (const rule of BRAND_RULES) {
|
||||
for (const matcher of rule.prefixMatchers) {
|
||||
if (candidate.startsWith(matcher.prefix)) return rule.brand;
|
||||
}
|
||||
}
|
||||
for (const definition of BRAND_DEFINITIONS) {
|
||||
if (definition.match(context)) {
|
||||
return {
|
||||
name: definition.name,
|
||||
icon: definition.icon,
|
||||
color: definition.color,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for regex/model wrappers where brand token appears inside the pattern.
|
||||
for (const candidate of candidates) {
|
||||
for (const rule of BRAND_RULES) {
|
||||
for (const matcher of rule.prefixMatchers) {
|
||||
if (matcher.boundaryRegex.test(candidate)) return rule.brand;
|
||||
}
|
||||
}
|
||||
for (const candidate of context.candidates) {
|
||||
for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) {
|
||||
if (rule.boundaryRegex.test(candidate)) {
|
||||
return {
|
||||
name: rule.brand.name,
|
||||
icon: rule.brand.icon,
|
||||
color: rule.brand.color,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
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 hashColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
|
||||
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length];
|
||||
}
|
||||
|
||||
|
||||
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 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];
|
||||
}
|
||||
|
||||
function avatarLetters(name: string): string {
|
||||
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
if (brand && !imgError) {
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: 10, background: brand.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
padding: 6,
|
||||
}}>
|
||||
<img
|
||||
src={`${ICON_CDN}/${brand.icon.replace(/\./g, '-')}.png`}
|
||||
alt={brand.name}
|
||||
style={{ width: size - 14, height: size - 14, objectFit: 'contain', filter: 'brightness(10)' }}
|
||||
onError={() => setImgError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="model-card-avatar" style={{ width: size, height: size, background: hashColor(model), fontSize: size > 32 ? 16 : 10 }}>
|
||||
{avatarLetters(model)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const cdn = useIconCdn();
|
||||
|
||||
if (brand && !imgError) {
|
||||
return (
|
||||
<img
|
||||
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
|
||||
alt={brand.name}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
objectFit: 'contain',
|
||||
flexShrink: 0,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
onError={() => setImgError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ModelBadge({ model, style }: { model: string; style?: React.CSSProperties }) {
|
||||
const brand = getBrand(model);
|
||||
|
||||
// Brand-specific colors for badge background
|
||||
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' },
|
||||
'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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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: ReturnType<typeof create> | null = null;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { InlineBrandIcon } from './BrandIcon.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
|
||||
type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
|
||||
|
||||
@@ -135,7 +136,7 @@ export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
|
||||
</div>
|
||||
<div className="stat-summary-card stat-summary-green">
|
||||
<div className="stat-summary-card-label">总 Tokens</div>
|
||||
<div className="stat-summary-card-value">{Math.round(totals.tokens).toLocaleString()}</div>
|
||||
<div className="stat-summary-card-value">{formatCompactTokenMetric(totals.tokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { create } 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('');
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,76 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
menuMaxHeight?: number;
|
||||
className?: string;
|
||||
size?: 'md' | 'sm';
|
||||
};
|
||||
|
||||
|
||||
type ModernSelectProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ModernSelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
menuMaxHeight?: number;
|
||||
className?: string;
|
||||
size?: 'md' | 'sm';
|
||||
};
|
||||
|
||||
export default function ModernSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select',
|
||||
disabled = false,
|
||||
emptyLabel = 'No options',
|
||||
menuMaxHeight = 280,
|
||||
className = '',
|
||||
size = 'md',
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select',
|
||||
disabled = false,
|
||||
emptyLabel = 'No options',
|
||||
menuMaxHeight = 280,
|
||||
className = '',
|
||||
size = 'md',
|
||||
}: ModernSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) 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]);
|
||||
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((item) => item.value === value),
|
||||
[options, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) 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]);
|
||||
|
||||
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" />;
|
||||
}
|
||||
@@ -76,18 +81,18 @@ export default function ModernSelect({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
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}
|
||||
<div
|
||||
ref={rootRef}
|
||||
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 ? (
|
||||
@@ -101,27 +106,27 @@ export default function ModernSelect({
|
||||
</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 }}>
|
||||
{options.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
options.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()}
|
||||
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 }}>
|
||||
{options.length === 0 ? (
|
||||
<div className="modern-select-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
options.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);
|
||||
@@ -151,4 +156,3 @@ export default function ModernSelect({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user