diff --git a/src/api.js b/src/api.js index 963fe4d..23f485d 100644 --- a/src/api.js +++ b/src/api.js @@ -57,6 +57,7 @@ export const getSiteModels = () => api.get('/api/dist/site/models'); export const getSitePricing = () => api.get('/api/dist/site/pricing'); export const getSitePackages = () => api.get('/api/dist/site/packages'); export const getSiteKeyGroups = () => api.get('/api/dist/site/key-groups'); +export const getSiteKeyGroupPricing = (id) => api.get(`/api/dist/site/key-groups/${id}/pricing`); export const getSubDistributorInfo = () => api.get('/api/dist/site/sub-distributor/info'); // ===== Auth ===== diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2b98e84..6eebe6e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -180,6 +180,21 @@ "tokens.selectedGroup": "Selected Group", "tokens.quickStart": "Quick Start", "tokens.quickStartComment": "# Use your API key with OpenAI-compatible endpoint", + "tokens.viewGroupPricing": "View Reference Pricing", + "tokens.groupPricingTitle": "Reference Pricing", + "tokens.groupPricingSubtitle": "Shows anonymous reference prices and price ranges for this group without exposing provider names.", + "tokens.groupPricingNotice": "Actual billing dynamically selects from currently available routes in this group. Prices below are for reference only.", + "tokens.groupPricingSearchPlaceholder": "Search models in this group...", + "tokens.groupPricingLoading": "Loading group reference pricing...", + "tokens.groupPricingNoData": "No reference pricing is available for this group yet.", + "tokens.groupPricingNoMatch": "No models match your search.", + "tokens.groupPricingReferencePrice": "Reference input / call price", + "tokens.groupPricingLines": "Routes", + "tokens.groupPricingLineUnitSingle": "route", + "tokens.groupPricingLineUnit": "routes", + "tokens.groupPricingSamePrice": "same price", + "tokens.groupPricingAvailableLines": "Available routes", + "tokens.groupPricingAvailableModels": "Available models", "config.title": "Config File Export", "config.subtitle": "Select your API Key and model to generate config files", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index bb09df6..f5b6135 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -180,6 +180,21 @@ "tokens.selectedGroup": "已选分组", "tokens.quickStart": "快速开始", "tokens.quickStartComment": "# 使用 OpenAI 兼容端点调用 API", + "tokens.viewGroupPricing": "查看参考价", + "tokens.groupPricingTitle": "分组参考价", + "tokens.groupPricingSubtitle": "仅展示该分组下的匿名参考价与价格区间,不展示商家名称。", + "tokens.groupPricingNotice": "实际计费会在该分组当前可用线路中动态选择,以下价格仅作为参考。", + "tokens.groupPricingSearchPlaceholder": "搜索分组内模型...", + "tokens.groupPricingLoading": "正在加载分组参考价...", + "tokens.groupPricingNoData": "当前分组暂无可展示的参考价。", + "tokens.groupPricingNoMatch": "没有匹配的模型。", + "tokens.groupPricingReferencePrice": "参考输入价 / 按次价", + "tokens.groupPricingLines": "线路", + "tokens.groupPricingLineUnitSingle": "条线路", + "tokens.groupPricingLineUnit": "条线路", + "tokens.groupPricingSamePrice": "同价", + "tokens.groupPricingAvailableLines": "可用线路", + "tokens.groupPricingAvailableModels": "可用模型", "config.title": "配置文件导出", "config.subtitle": "选择您的 API Key 和模型,一键生成配置文件", diff --git a/src/pages/Logs.jsx b/src/pages/Logs.jsx index 8b23ab8..092655c 100644 --- a/src/pages/Logs.jsx +++ b/src/pages/Logs.jsx @@ -35,7 +35,7 @@ function getProviderSummary(other) { function getBillingSourceLabel(other, t) { if (!other?.billing_source) return ''; if (other.billing_source === 'subscription') { - if (other.subscription_source === 'dist_package') return t('分站套餐'); + if (other.subscription_source === 'dist_package') return t('dashboard.packages'); if (other.subscription_source === 'order') return t('主站订阅'); if (other.subscription_source === 'admin') return t('后台订阅'); return t('订阅'); diff --git a/src/pages/Tokens.jsx b/src/pages/Tokens.jsx index 4ab6d3c..91b6063 100644 --- a/src/pages/Tokens.jsx +++ b/src/pages/Tokens.jsx @@ -1,11 +1,21 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { getTokens, createToken, updateToken, deleteToken, getSiteKeyGroups, getTokenSupportedModels } from '../api'; +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); @@ -16,6 +26,10 @@ export default function Tokens() { // 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); @@ -62,6 +76,30 @@ export default function Tokens() { 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()) { @@ -170,6 +208,26 @@ export default function Tokens() { } }; + 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 (
@@ -178,8 +236,6 @@ export default function Tokens() { ); } - const hasGroups = keyGroups.length > 0; - return (
@@ -231,6 +287,7 @@ export default function Tokens() { group={group} parseTags={parseTags} onSelect={openCreateFromGroup} + onViewPricing={openGroupPricing} t={t} /> ))} @@ -279,6 +336,20 @@ export default function Tokens() {
)} + + {/* ========== New Key Reveal Modal ========== */} {newKey && (
@@ -458,7 +529,7 @@ export default function Tokens() { } /* ========== Key Group Card ========== */ -function KeyGroupCard({ group, parseTags, onSelect, t }) { +function KeyGroupCard({ group, parseTags, onSelect, onViewPricing, t }) { const tags = parseTags(group.tags); const isUnavailable = group.is_unavailable; @@ -466,7 +537,7 @@ function KeyGroupCard({ group, parseTags, onSelect, t }) {
!isUnavailable && onSelect(group)} @@ -513,16 +584,287 @@ function KeyGroupCard({ group, parseTags, onSelect, t }) { )}
- {/* Create arrow */} - {!isUnavailable && ( -
- {t('tokens.create')} - - - -
- )} +
+ + + {!isUnavailable && ( +
+ {t('tokens.create')} + + + +
+ )} +
); } + +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 ( +
+
event.stopPropagation()} + > +
+
+
+

+ {displayGroup.name} · {t('tokens.groupPricingTitle')} +

+

+ {t('tokens.groupPricingSubtitle')} +

+
+ +
+ +
+ {displayGroup.discount_label && ( + + {displayGroup.discount_label} + + )} + {displayGroup.rmb_per_usd > 0 && ( + + {displayGroup.rmb_per_usd} {t('tokens.rmbPerUsd')} + + )} + {summary && ( + + {t('tokens.groupPricingAvailableLines')}: {summary.provider_count} + + )} + {summary && ( + + {t('tokens.groupPricingAvailableModels')}: {summary.model_count} + + )} + {summary?.provider_limited && ( + + {t('tokens.restrictedByProviders')} + + )} + {summary?.model_limited && ( + + {t('tokens.restrictedByModels')} + + )} +
+ + {displayGroup.description && ( +

+ {displayGroup.description} +

+ )} +
+ +
+
+

+ {t('tokens.groupPricingNotice')} +

+ onSearchChange(event.target.value)} + className="input lg:max-w-xs" + placeholder={t('tokens.groupPricingSearchPlaceholder')} + /> +
+
+ +
+ {loading ? ( +
+
+ {t('tokens.groupPricingLoading')} +
+ ) : !hasItems ? ( +
+ {t('tokens.groupPricingNoData')} +
+ ) : items.length === 0 ? ( +
+ {t('tokens.groupPricingNoMatch')} +
+ ) : ( +
+ + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
{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)} + +
+
+ )} +
+
+
+ ); +} + +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')}`; +}