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 (
+ {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)} + + | +