871 lines
36 KiB
React
871 lines
36 KiB
React
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 || tokenModels[tokenId]) 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 (
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto px-6 py-10">
|
|
|
|
{/* ========== Section 1: Create Key with Groups ========== */}
|
|
<div className="mb-10">
|
|
<h1 className="text-2xl font-heading font-bold text-page">
|
|
{hasGroups ? t('tokens.selectGroup') : t('tokens.title')}
|
|
</h1>
|
|
{hasGroups && (
|
|
<p className="text-sm text-page-secondary mt-1">{t('tokens.selectGroupSubtitle')}</p>
|
|
)}
|
|
|
|
{/* Default (All Providers) Card */}
|
|
<div className="mt-6">
|
|
<button
|
|
onClick={openCreateDefault}
|
|
className="w-full glass rounded-xl p-4 flex items-center gap-4 hover:border-brand-500/50 border border-page-divider transition-all group text-left"
|
|
>
|
|
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
|
<svg className="w-5 h-5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-page">{hasGroups ? t('tokens.defaultGroup') : t('tokens.newKey')}</p>
|
|
{hasGroups && (
|
|
<p className="text-xs text-page-secondary mt-0.5">{t('tokens.defaultGroupDesc')}</p>
|
|
)}
|
|
</div>
|
|
<span className="text-xs font-medium text-brand-500 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
|
{t('tokens.create')} →
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Vendor Category Sections */}
|
|
{hasGroups && Object.entries(groupedByVendor).map(([vendor, groups]) => (
|
|
<div key={vendor} className="mt-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<h2 className="text-base font-semibold text-page">{vendor}</h2>
|
|
<span className="text-[11px] text-page-muted bg-page-surface px-2 py-0.5 rounded-full">
|
|
{groups.length} {t('tokens.groupCount')}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{groups.map((group) => (
|
|
<KeyGroupCard
|
|
key={group.id}
|
|
group={group}
|
|
parseTags={parseTags}
|
|
onSelect={openCreateFromGroup}
|
|
onViewPricing={openGroupPricing}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* ========== Create Modal ========== */}
|
|
{showCreate && (
|
|
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => { setShowCreate(false); setSelectedGroupId(0); }}>
|
|
<div className="glass rounded-2xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
|
<h2 className="text-lg font-semibold text-page mb-4">{t('tokens.createApiKey')}</h2>
|
|
{selectedGroupId > 0 && (() => {
|
|
const g = keyGroups.find((x) => x.id === selectedGroupId);
|
|
return g ? (
|
|
<div className="mb-4 p-3 rounded-lg bg-page-surface border border-page-divider">
|
|
<p className="text-xs text-page-muted">{t('tokens.selectedGroup')}</p>
|
|
<p className="text-sm font-medium text-page">{g.name}</p>
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-page-label mb-1.5">{t('tokens.name')}</label>
|
|
<input
|
|
type="text"
|
|
value={createName}
|
|
onChange={(e) => setCreateName(e.target.value)}
|
|
className="input"
|
|
placeholder={t('tokens.namePlaceholder')}
|
|
autoFocus
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button type="button" onClick={() => { setShowCreate(false); setSelectedGroupId(0); }} className="btn-secondary">
|
|
{t('tokens.cancel')}
|
|
</button>
|
|
<button type="submit" disabled={creating} className="btn-primary">
|
|
{creating ? t('tokens.creating') : t('tokens.create')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<GroupPricingModal
|
|
open={!!activePricingGroup}
|
|
group={activePricingGroup}
|
|
pricingData={activeGroupPricing}
|
|
items={filteredGroupPricingItems}
|
|
loading={loadingGroupPricingId === activePricingGroup?.id}
|
|
search={groupPricingSearch}
|
|
onSearchChange={setGroupPricingSearch}
|
|
onClose={closeGroupPricing}
|
|
symbol={symbol}
|
|
rate={rate}
|
|
t={t}
|
|
/>
|
|
|
|
{/* ========== New Key Reveal Modal ========== */}
|
|
{newKey && (
|
|
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="glass rounded-2xl p-6 w-full max-w-lg">
|
|
<h2 className="text-lg font-semibold text-page mb-2">{t('tokens.newApiKey')}</h2>
|
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-xl p-3 mb-4">
|
|
<p className="text-sm text-page-warning">
|
|
{t('tokens.keyWarning')}
|
|
</p>
|
|
</div>
|
|
<div className="bg-page-inset rounded-xl p-4 flex items-center gap-3">
|
|
<code className="text-sm font-mono text-page-success flex-1 break-all select-all">
|
|
{newKey}
|
|
</code>
|
|
<button
|
|
onClick={() => handleCopy(newKey)}
|
|
className="btn-primary !px-4 !py-1.5 flex-shrink-0"
|
|
>
|
|
{copiedId === newKey ? t('tokens.copied') : t('tokens.copy')}
|
|
</button>
|
|
</div>
|
|
<div className="flex justify-end mt-4">
|
|
<button onClick={() => setNewKey(null)} className="btn-secondary">
|
|
{t('tokens.savedKey')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ========== Delete Confirmation Modal ========== */}
|
|
{deleteConfirm && (
|
|
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setDeleteConfirm(null)}>
|
|
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
|
<h2 className="text-lg font-semibold text-page mb-3">{t('tokens.deleteToken')}</h2>
|
|
<p className="text-sm text-page-secondary mb-4">
|
|
{t('tokens.deleteConfirm', { name: deleteConfirm.name })}
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary">{t('tokens.cancel')}</button>
|
|
<button onClick={handleDelete} className="px-6 py-2.5 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-500 transition-colors">
|
|
{t('tokens.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ========== Section 2: My API Keys ========== */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-heading font-semibold text-page">{t('tokens.myKeys')}</h2>
|
|
</div>
|
|
|
|
{tokens.length === 0 ? (
|
|
<div className="glass rounded-2xl p-8 text-center">
|
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-page-surface flex items-center justify-center">
|
|
<svg className="w-6 h-6 text-page-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
|
<p className="text-sm text-page-secondary">{t('tokens.noKeys')}</p>
|
|
<p className="text-xs text-page-muted mt-1">{t('tokens.noKeysHint')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{tokens.map((token) => (
|
|
<div key={token.id} className="glass-sm rounded-xl p-5">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${token.status === 1 ? 'bg-green-500' : 'bg-page-muted'}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-page">{token.name}</p>
|
|
</div>
|
|
<span className="text-xs text-page-muted hidden md:block">
|
|
{token.created_time ? new Date(token.created_time * 1000).toLocaleDateString() : ''}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleToggleSupportedModels(token.id)}
|
|
className="px-3 py-1 text-xs rounded-lg border border-page-divider text-page-secondary hover:bg-page-surface-hover transition-colors"
|
|
>
|
|
{expandedTokens[token.id] ? t('tokens.hideSupportedModels') : t('tokens.viewSupportedModels')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggle(token)}
|
|
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${
|
|
token.status === 1
|
|
? 'border-green-500/30 text-page-success hover:bg-green-500/10'
|
|
: 'border-page-divider text-page-secondary hover:bg-page-surface-hover'
|
|
}`}
|
|
>
|
|
{token.status === 1 ? t('tokens.enabled') : t('tokens.disabled')}
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteConfirm(token)}
|
|
className="px-3 py-1 text-xs rounded-lg border border-red-500/20 text-page-danger hover:bg-red-500/10 transition-colors"
|
|
>
|
|
{t('tokens.delete')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{token.key && (
|
|
<div className="mt-3 flex items-center gap-2 bg-page-inset rounded-lg px-3 py-2">
|
|
<code className="text-xs font-mono text-page-muted flex-1 break-all select-all">
|
|
sk-{token.key}
|
|
</code>
|
|
<button
|
|
onClick={() => handleCopy('sk-' + token.key)}
|
|
className="flex-shrink-0 px-2.5 py-1 text-xs rounded-md bg-page-surface text-page-secondary hover:bg-page-surface-hover hover:text-page transition-colors"
|
|
>
|
|
{copiedId === 'sk-' + token.key ? t('tokens.copied') : t('tokens.copy')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{expandedTokens[token.id] && (
|
|
<div className="mt-3 rounded-xl border border-page-divider bg-page-surface/50 px-4 py-3">
|
|
{tokenModels[token.id]?.loading ? (
|
|
<div className="flex items-center gap-2 text-sm text-page-secondary">
|
|
<div className="w-4 h-4 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
|
<span>{t('tokens.loadingSupportedModels')}</span>
|
|
</div>
|
|
) : tokenModels[token.id]?.error ? (
|
|
<p className="text-sm text-page-danger">{t('tokens.loadSupportedModelsFailed')}</p>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-sm font-medium text-page">
|
|
{t('tokens.supportedModels')} ({tokenModels[token.id]?.count || 0})
|
|
</p>
|
|
{tokenModels[token.id]?.restricted_by_models && (
|
|
<span className="px-2 py-0.5 rounded-full text-[11px] bg-brand-500/10 text-brand-500">
|
|
{t('tokens.restrictedByModels')}
|
|
</span>
|
|
)}
|
|
{tokenModels[token.id]?.restricted_by_providers && (
|
|
<span className="px-2 py-0.5 rounded-full text-[11px] bg-brand-500/10 text-brand-500">
|
|
{t('tokens.restrictedByProviders')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{tokenModels[token.id]?.provider_names?.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
<span className="text-xs text-page-muted">{t('tokens.supportedProviders')}</span>
|
|
{tokenModels[token.id].provider_names.map((name) => (
|
|
<span key={name} className="px-2 py-0.5 rounded-full text-[11px] bg-page-inset text-page-secondary">
|
|
{name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{tokenModels[token.id]?.models?.length > 0 ? (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{tokenModels[token.id].models.map((modelName) => (
|
|
<code key={modelName} className="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-page-inset text-page-secondary">
|
|
{modelName}
|
|
</code>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-sm text-page-muted">{t('tokens.noSupportedModels')}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<ConfigExporter tokens={tokens} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ========== Key Group Card ========== */
|
|
function KeyGroupCard({ group, parseTags, onSelect, onViewPricing, t }) {
|
|
const tags = parseTags(group.tags);
|
|
const isUnavailable = group.is_unavailable;
|
|
|
|
return (
|
|
<div
|
|
className={`glass-sm rounded-xl p-4 border border-page-divider transition-all ${
|
|
isUnavailable
|
|
? 'opacity-75'
|
|
: 'hover:border-brand-500/40 cursor-pointer group'
|
|
}`}
|
|
onClick={() => !isUnavailable && onSelect(group)}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
{/* Name + badges */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-semibold text-sm text-page">{group.name}</span>
|
|
{group.is_recommended && (
|
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/15 text-amber-500">
|
|
{t('tokens.recommended')}
|
|
</span>
|
|
)}
|
|
{isUnavailable && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-500/10 text-red-500">
|
|
{t('tokens.unavailable')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Price + discount + tags */}
|
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
|
{group.rmb_per_usd > 0 && (
|
|
<span className="text-xs font-medium text-page">
|
|
{group.rmb_per_usd} {t('tokens.rmbPerUsd')}
|
|
</span>
|
|
)}
|
|
{group.discount_label && (
|
|
<span className="text-[11px] font-semibold text-page-success">
|
|
{group.discount_label}
|
|
</span>
|
|
)}
|
|
{tags.map((tag, i) => (
|
|
<span key={i} className="px-1.5 py-0.5 rounded text-[10px] bg-page-surface text-page-secondary">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{group.description && (
|
|
<p className="text-xs text-page-muted mt-1">{group.description}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onViewPricing(group);
|
|
}}
|
|
className="px-3 py-1.5 text-xs rounded-lg border border-page-divider text-page-secondary hover:bg-page-surface-hover hover:text-page transition-colors"
|
|
>
|
|
{t('tokens.viewGroupPricing')}
|
|
</button>
|
|
|
|
{!isUnavailable && (
|
|
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<span className="text-xs font-medium text-brand-500">{t('tokens.create')}</span>
|
|
<svg className="w-4 h-4 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GroupPricingModal({
|
|
open,
|
|
group,
|
|
pricingData,
|
|
items,
|
|
loading,
|
|
search,
|
|
onSearchChange,
|
|
onClose,
|
|
symbol,
|
|
rate,
|
|
t,
|
|
}) {
|
|
if (!open || !group) {
|
|
return null;
|
|
}
|
|
|
|
const displayGroup = pricingData?.group || group;
|
|
const summary = pricingData?.summary;
|
|
const hasItems = (pricingData?.items || []).length > 0;
|
|
|
|
return (
|
|
<div
|
|
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="glass rounded-2xl w-full max-w-6xl max-h-[88vh] overflow-hidden"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="px-6 py-5 border-b border-page-divider">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-xl font-heading font-semibold text-page">
|
|
{displayGroup.name} · {t('tokens.groupPricingTitle')}
|
|
</h2>
|
|
<p className="text-sm text-page-secondary mt-1 max-w-3xl">
|
|
{t('tokens.groupPricingSubtitle')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-3 py-1.5 text-sm rounded-lg border border-page-divider text-page-secondary hover:bg-page-surface-hover transition-colors"
|
|
>
|
|
{t('tokens.cancel')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
|
{displayGroup.discount_label && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-green-500/10 text-page-success">
|
|
{displayGroup.discount_label}
|
|
</span>
|
|
)}
|
|
{displayGroup.rmb_per_usd > 0 && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
|
|
{displayGroup.rmb_per_usd} {t('tokens.rmbPerUsd')}
|
|
</span>
|
|
)}
|
|
{summary && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
|
|
{t('tokens.groupPricingAvailableLines')}: {summary.provider_count}
|
|
</span>
|
|
)}
|
|
{summary && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
|
|
{t('tokens.groupPricingAvailableModels')}: {summary.model_count}
|
|
</span>
|
|
)}
|
|
{summary?.provider_limited && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs bg-brand-500/10 text-brand-500">
|
|
{t('tokens.restrictedByProviders')}
|
|
</span>
|
|
)}
|
|
{summary?.model_limited && (
|
|
<span className="px-2.5 py-1 rounded-full text-xs bg-brand-500/10 text-brand-500">
|
|
{t('tokens.restrictedByModels')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{displayGroup.description && (
|
|
<p className="text-sm text-page-secondary mt-3">
|
|
{displayGroup.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-6 py-4 border-b border-page-divider bg-page-surface/40">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<p className="text-sm text-page-secondary">
|
|
{t('tokens.groupPricingNotice')}
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(event) => onSearchChange(event.target.value)}
|
|
className="input lg:max-w-xs"
|
|
placeholder={t('tokens.groupPricingSearchPlaceholder')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 overflow-y-auto max-h-[58vh]">
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 text-sm text-page-secondary">
|
|
<div className="w-4 h-4 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
|
<span>{t('tokens.groupPricingLoading')}</span>
|
|
</div>
|
|
) : !hasItems ? (
|
|
<div className="text-sm text-page-secondary">
|
|
{t('tokens.groupPricingNoData')}
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className="text-sm text-page-secondary">
|
|
{t('tokens.groupPricingNoMatch')}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm min-w-[860px]">
|
|
<thead>
|
|
<tr className="border-b border-page-divider">
|
|
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('pricing.model')}</th>
|
|
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('定价方式')}</th>
|
|
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('tokens.groupPricingReferencePrice')}</th>
|
|
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('pricing.outputPrice')}</th>
|
|
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('pricing.cacheReadPrice')}</th>
|
|
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('pricing.cacheCreationPrice')}</th>
|
|
<th className="text-center px-4 py-3 font-medium text-page-secondary">{t('tokens.groupPricingLines')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item) => (
|
|
<tr key={`${item.model_name}:${item.billing_type}`} className="border-b border-page-divider last:border-0 align-top">
|
|
<td className="px-4 py-3.5">
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-page">{item.display_name || item.model_name}</div>
|
|
{(item.display_name || item.model_name) !== item.model_name && (
|
|
<div className="text-xs text-page-muted font-mono mt-1">{item.model_name}</div>
|
|
)}
|
|
{item.category && (
|
|
<div className="text-xs text-page-muted mt-1 uppercase tracking-wide">{item.category}</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3.5 text-page-secondary">
|
|
{item.billing_type === 'per_call' ? t('pricing.perCall') : t('按量计费')}
|
|
</td>
|
|
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
|
|
{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)}
|
|
</td>
|
|
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
|
|
{item.status !== 'healthy'
|
|
? '-'
|
|
: item.billing_type === 'per_call'
|
|
? '-'
|
|
: formatGroupPriceRange(item.output_price_min, item.output_price_max, symbol, rate, false, t)}
|
|
</td>
|
|
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
|
|
{item.status !== 'healthy'
|
|
? '-'
|
|
: item.billing_type === 'per_call'
|
|
? '-'
|
|
: formatGroupPriceRange(item.cache_read_price_min, item.cache_read_price_max, symbol, rate, false, t)}
|
|
</td>
|
|
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
|
|
{item.status !== 'healthy'
|
|
? '-'
|
|
: item.billing_type === 'per_call'
|
|
? '-'
|
|
: formatGroupCachePriceRange(item, symbol, rate, t)}
|
|
</td>
|
|
<td className="px-4 py-3.5 text-center">
|
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
|
|
{formatRouteCount(item.route_count, item.has_range, t)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatGroupPriceRange(min, max, symbol, rate, perCall, t) {
|
|
if (min == null && max == null) {
|
|
return '-';
|
|
}
|
|
const low = Number(min ?? max ?? 0);
|
|
const high = Number(max ?? min ?? 0);
|
|
const factor = perCall ? rate : rate * 1000;
|
|
const suffix = perCall ? `/${t('pricing.perCallUnit')}` : '';
|
|
const lowText = `${symbol}${(low * factor).toFixed(4)}`;
|
|
const highText = `${symbol}${(high * factor).toFixed(4)}`;
|
|
if (Math.abs(low - high) <= 1e-9) {
|
|
return `${lowText}${suffix}`;
|
|
}
|
|
return `${lowText} - ${highText}${suffix}`;
|
|
}
|
|
|
|
function formatGroupCachePriceRange(item, symbol, rate, t) {
|
|
const base = formatGroupPriceRange(
|
|
item.cache_creation_price_min,
|
|
item.cache_creation_price_max,
|
|
symbol,
|
|
rate,
|
|
false,
|
|
t,
|
|
);
|
|
if (
|
|
item.cache_creation_price_1h_min == null &&
|
|
item.cache_creation_price_1h_max == null
|
|
) {
|
|
return base;
|
|
}
|
|
const baseMin = Number(item.cache_creation_price_min ?? 0);
|
|
const baseMax = Number(item.cache_creation_price_max ?? 0);
|
|
const oneHourMin = Number(item.cache_creation_price_1h_min ?? 0);
|
|
const oneHourMax = Number(item.cache_creation_price_1h_max ?? 0);
|
|
if (
|
|
Math.abs(baseMin - oneHourMin) <= 1e-9 &&
|
|
Math.abs(baseMax - oneHourMax) <= 1e-9
|
|
) {
|
|
return base;
|
|
}
|
|
const oneHour = formatGroupPriceRange(
|
|
item.cache_creation_price_1h_min,
|
|
item.cache_creation_price_1h_max,
|
|
symbol,
|
|
rate,
|
|
false,
|
|
t,
|
|
);
|
|
return `${t('pricing.cacheCreation5m')} ${base} / ${t('pricing.cacheCreation1h')} ${oneHour}`;
|
|
}
|
|
|
|
function formatRouteCount(routeCount, hasRange, t) {
|
|
if (!routeCount) {
|
|
return t('pricing.unknown');
|
|
}
|
|
if (routeCount === 1) {
|
|
return `1 ${t('tokens.groupPricingLineUnitSingle')}`;
|
|
}
|
|
if (!hasRange) {
|
|
return `${routeCount} ${t('tokens.groupPricingLineUnit')} · ${t('tokens.groupPricingSamePrice')}`;
|
|
}
|
|
return `${routeCount} ${t('tokens.groupPricingLineUnit')}`;
|
|
}
|