import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getTokens, createToken, updateToken, deleteToken, getSiteKeyGroups, getSiteKeyGroupPricing, getTokenSupportedModels, } from '../api'; import ConfigExporter from '../components/ConfigExporter'; import { useCurrency } from '../context/SiteContext'; import toast from 'react-hot-toast'; export default function Tokens() { const { t } = useTranslation(); const { symbol, rate } = useCurrency(); const [tokens, setTokens] = useState([]); const [loading, setLoading] = useState(true); const [copiedId, setCopiedId] = useState(null); const [newKey, setNewKey] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const [expandedTokens, setExpandedTokens] = useState({}); const [tokenModels, setTokenModels] = useState({}); // Key groups const [keyGroups, setKeyGroups] = useState([]); const [activePricingGroup, setActivePricingGroup] = useState(null); const [groupPricingCache, setGroupPricingCache] = useState({}); const [loadingGroupPricingId, setLoadingGroupPricingId] = useState(0); const [groupPricingSearch, setGroupPricingSearch] = useState(''); // Create modal const [showCreate, setShowCreate] = useState(false); const [createName, setCreateName] = useState(''); const [selectedGroupId, setSelectedGroupId] = useState(0); const [creating, setCreating] = useState(false); const load = useCallback(async () => { setLoading(true); try { const [tokensRes, groupsRes] = await Promise.all([ getTokens(), getSiteKeyGroups().catch(() => ({ data: { success: false } })), ]); if (tokensRes.data.success) setTokens(tokensRes.data.data || []); if (groupsRes.data.success) setKeyGroups(groupsRes.data.data || []); } catch (e) { /* interceptor */ } setLoading(false); }, []); useEffect(() => { load(); }, [load]); // Group by vendor_category const groupedByVendor = useMemo(() => { const map = {}; keyGroups.forEach((g) => { const cat = g.vendor_category || t('tokens.otherGroups'); if (!map[cat]) map[cat] = []; map[cat].push(g); }); return map; }, [keyGroups, t]); const openCreateFromGroup = (group) => { if (group.is_unavailable) return; setSelectedGroupId(group.id); setCreateName(group.name); setShowCreate(true); }; const openCreateDefault = () => { setSelectedGroupId(0); setCreateName(''); setShowCreate(true); }; const openGroupPricing = async (group) => { setActivePricingGroup(group); setGroupPricingSearch(''); if (groupPricingCache[group.id] || loadingGroupPricingId === group.id) { return; } setLoadingGroupPricingId(group.id); try { const res = await getSiteKeyGroupPricing(group.id); if (res.data.success) { setGroupPricingCache((prev) => ({ ...prev, [group.id]: res.data.data || { items: [], summary: null, group }, })); } } catch (e) { /* interceptor */ } setLoadingGroupPricingId(0); }; const closeGroupPricing = () => { setActivePricingGroup(null); setGroupPricingSearch(''); }; const handleCreate = async (e) => { e.preventDefault(); if (!createName.trim()) { toast.error(t('tokens.enterName')); return; } setCreating(true); try { const payload = { name: createName.trim() }; if (selectedGroupId > 0) payload.key_group_id = selectedGroupId; const res = await createToken(payload); if (res.data.success) { setCreateName(''); setShowCreate(false); setSelectedGroupId(0); const createdKey = res.data.data?.key; if (createdKey) setNewKey(createdKey); await load(); } } catch (e) { /* interceptor */ } setCreating(false); }; const handleToggle = async (token) => { try { const res = await updateToken(token.id, { status: token.status === 1 ? 2 : 1, }); if (res.data.success) { toast.success(token.status === 1 ? t('tokens.tokenDisabled') : t('tokens.tokenEnabled')); await load(); } } catch (e) { /* interceptor */ } }; const handleDelete = async () => { if (!deleteConfirm) return; try { const res = await deleteToken(deleteConfirm.id); if (res.data.success) { toast.success(t('tokens.tokenDeleted')); setDeleteConfirm(null); await load(); } } catch (e) { /* interceptor */ } }; const handleCopy = async (text) => { try { await navigator.clipboard.writeText(text); } catch (e) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } setCopiedId(text); toast.success(t('tokens.copiedToClipboard')); setTimeout(() => setCopiedId(null), 2000); }; const parseTags = (tagsStr) => { try { return JSON.parse(tagsStr || '[]'); } catch { return []; } }; const handleToggleSupportedModels = async (tokenId) => { const isExpanded = !!expandedTokens[tokenId]; setExpandedTokens((prev) => ({ ...prev, [tokenId]: !isExpanded })); if (isExpanded) return; setTokenModels((prev) => ({ ...prev, [tokenId]: { loading: true, models: [], count: 0, provider_names: [], restricted_by_providers: false, restricted_by_models: false }, })); try { const res = await getTokenSupportedModels(tokenId); if (res.data.success) { const data = res.data.data || {}; setTokenModels((prev) => ({ ...prev, [tokenId]: { loading: false, models: data.models || [], count: data.count || 0, provider_names: data.provider_names || [], restricted_by_providers: Boolean(data.restricted_by_providers), restricted_by_models: Boolean(data.restricted_by_models), }, })); } else { setTokenModels((prev) => ({ ...prev, [tokenId]: { loading: false, error: true, models: [], count: 0, provider_names: [], restricted_by_providers: false, restricted_by_models: false }, })); } } catch (e) { setTokenModels((prev) => ({ ...prev, [tokenId]: { loading: false, error: true, models: [], count: 0, provider_names: [], restricted_by_providers: false, restricted_by_models: false }, })); } }; const hasGroups = keyGroups.length > 0; const activeGroupPricing = activePricingGroup ? groupPricingCache[activePricingGroup.id] || null : null; const filteredGroupPricingItems = useMemo(() => { const items = activeGroupPricing?.items || []; const keyword = groupPricingSearch.trim().toLowerCase(); if (!keyword) return items; return items.filter((item) => { const modelName = (item.model_name || '').toLowerCase(); const displayName = (item.display_name || '').toLowerCase(); const category = (item.category || '').toLowerCase(); return ( modelName.includes(keyword) || displayName.includes(keyword) || category.includes(keyword) ); }); }, [activeGroupPricing, groupPricingSearch]); if (loading) { return (
{t('tokens.selectGroupSubtitle')}
)} {/* Default (All Providers) Card */}{t('tokens.selectedGroup')}
{g.name}
{t('tokens.keyWarning')}
{newKey}
{t('tokens.deleteConfirm', { name: deleteConfirm.name })}
{t('tokens.noKeys')}
{t('tokens.noKeysHint')}
{token.name}
sk-{token.key}
{t('tokens.loadSupportedModelsFailed')}
) : ( <>{t('tokens.supportedModels')} ({tokenModels[token.id]?.count || 0})
{tokenModels[token.id]?.restricted_by_models && ( {t('tokens.restrictedByModels')} )} {tokenModels[token.id]?.restricted_by_providers && ( {t('tokens.restrictedByProviders')} )}
{modelName}
))}
{t('tokens.noSupportedModels')}
)} > )}{group.description}
)}{t('tokens.groupPricingSubtitle')}
{displayGroup.description}
)}{t('tokens.groupPricingNotice')}
onSearchChange(event.target.value)} className="input lg:max-w-xs" placeholder={t('tokens.groupPricingSearchPlaceholder')} />| {t('pricing.model')} | {t('定价方式')} | {t('tokens.groupPricingReferencePrice')} | {t('pricing.outputPrice')} | {t('pricing.cacheReadPrice')} | {t('pricing.cacheCreationPrice')} | {t('tokens.groupPricingLines')} |
|---|---|---|---|---|---|---|
|
{item.display_name || item.model_name}
{(item.display_name || item.model_name) !== item.model_name && (
{item.model_name}
)}
{item.category && (
{item.category}
)}
|
{item.billing_type === 'per_call' ? t('pricing.perCall') : t('按量计费')} | {item.status !== 'healthy' ? t('pricing.unknown') : item.billing_type === 'per_call' ? formatGroupPriceRange(item.fixed_price_min, item.fixed_price_max, symbol, rate, true, t) : formatGroupPriceRange(item.input_price_min, item.input_price_max, symbol, rate, false, t)} | {item.status !== 'healthy' ? '-' : item.billing_type === 'per_call' ? '-' : formatGroupPriceRange(item.output_price_min, item.output_price_max, symbol, rate, false, t)} | {item.status !== 'healthy' ? '-' : item.billing_type === 'per_call' ? '-' : formatGroupPriceRange(item.cache_read_price_min, item.cache_read_price_max, symbol, rate, false, t)} | {item.status !== 'healthy' ? '-' : item.billing_type === 'per_call' ? '-' : formatGroupCachePriceRange(item, symbol, rate, t)} | {formatRouteCount(item.route_count, item.has_range, t)} |