From 71ea68f2b0e012ae7bf24c620b5f71524ff47201 Mon Sep 17 00:00:00 2001 From: lzc Date: Mon, 11 May 2026 09:36:01 +0800 Subject: [PATCH 01/15] feat: localize UI to CNY by default and add Chinese AI providers to homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change default QuotaDisplayType from USD to CNY in backend and frontend stores - Update dynamic pricing breakdown fallback to CNY (¥) with 7x exchange rate - Make currency symbol/label dynamic across all pricing, billing, and payment UI components using getCurrencyLabel() - Add Chinese AI providers (Zhipu, Moonshot, Baidu, Spark, Volcengine, Minimax) and reorder homepage model list to prioritize domestic providers - Add frontend container service to docker-compose.dev.yml for fully containerized dev workflow - Add i18n keys for currency-neutral pricing labels in en/zh locales Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.dev.yml | 30 ++++++++++--- setting/operation_setting/general_setting.go | 2 +- web/default/bun.lock | 1 + .../home/components/sections/features.tsx | 2 +- web/default/src/features/home/constants.ts | 14 +++++- .../drawers/model-mutate-drawer.tsx | 21 ++++++--- .../components/dynamic-pricing-breakdown.tsx | 2 +- .../dialogs/subscription-purchase-dialog.tsx | 6 ++- .../system-settings/billing/index.tsx | 2 +- .../integrations/amount-discount-dialog.tsx | 4 +- .../amount-options-visual-editor.tsx | 8 +++- .../integrations/payment-settings-section.tsx | 18 ++++---- .../waffo-pancake-settings-section.tsx | 8 ++-- .../integrations/waffo-settings-section.tsx | 4 +- .../models/model-pricing-sheet.tsx | 44 ++++++++++++------- .../models/model-ratio-form.tsx | 2 +- .../models/model-ratio-visual-editor.tsx | 29 ++++++------ web/default/src/i18n/locales/en.json | 1 + web/default/src/i18n/locales/zh.json | 4 ++ web/default/src/stores/system-config-store.ts | 4 +- 20 files changed, 140 insertions(+), 66 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e75befaeea8a..8bcd86c55558 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,9 +1,8 @@ -# Frontend Development - Backend built from local source +# Frontend Development - Backend + Frontend in Docker # # Usage: -# 1. docker compose -f docker-compose.dev.yml up -d -# 2. cd web && bun install && bun run dev -# 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000) +# docker compose -f docker-compose.dev.yml up -d +# Open http://localhost:5173 (Rsbuild dev server, API auto-proxied to :3000) # # Rebuild backend after Go code changes: # docker compose -f docker-compose.dev.yml up -d --build new-api @@ -39,15 +38,33 @@ services: networks: - dev-network + frontend: + image: oven/bun:1 + container_name: new-api-frontend + restart: unless-stopped + working_dir: /app + ports: + - "5173:3000" + volumes: + - ./web/default:/app + - frontend_node_modules:/app/node_modules + environment: + - VITE_REACT_APP_SERVER_URL=http://new-api-dev:3000 + command: sh -c "bun install && bun run dev --host 0.0.0.0" + depends_on: + - new-api + networks: + - dev-network + redis: - image: redis:7-alpine + image: redis:latest container_name: new-api-dev-redis restart: unless-stopped networks: - dev-network postgres: - image: postgres:15-alpine + image: postgres:15 container_name: new-api-dev-pg restart: unless-stopped environment: @@ -67,6 +84,7 @@ services: volumes: dev_data: dev_pg_data: + frontend_node_modules: networks: dev-network: diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go index b4a3ccccdaf3..2c09b3972a1c 100644 --- a/setting/operation_setting/general_setting.go +++ b/setting/operation_setting/general_setting.go @@ -27,7 +27,7 @@ var generalSetting = GeneralSetting{ DocsLink: "https://docs.newapi.pro", PingIntervalEnabled: false, PingIntervalSeconds: 60, - QuotaDisplayType: QuotaDisplayTypeUSD, + QuotaDisplayType: QuotaDisplayTypeCNY, CustomCurrencySymbol: "¤", CustomCurrencyExchangeRate: 1.0, } diff --git a/web/default/bun.lock b/web/default/bun.lock index f9dc0300f2bb..bf4639e6cceb 100644 --- a/web/default/bun.lock +++ b/web/default/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "newapi-web", diff --git a/web/default/src/features/home/components/sections/features.tsx b/web/default/src/features/home/components/sections/features.tsx index c25ec91df1dd..ac78537c4c20 100644 --- a/web/default/src/features/home/components/sections/features.tsx +++ b/web/default/src/features/home/components/sections/features.tsx @@ -30,7 +30,7 @@ export function Features(_props: FeaturesProps) { icon: , visual: (
- {['OpenAI', 'Claude', 'Gemini', 'DeepSeek', 'Qwen', 'Llama'].map( + {['DeepSeek', 'Qwen', 'Doubao', 'Zhipu', 'OpenAI', 'Claude'].map( (name) => (
('') + // Currency display + const currencyLabel = getCurrencyLabel() + const currencySymbol = + currencyLabel === 'CNY' ? '¥' : currencyLabel === 'USD' ? '$' : currencyLabel + const currencyName = currencyLabel === 'Tokens' ? 'Tokens' : currencyLabel + // Fetch vendors for dropdown const { data: vendorsData } = useQuery({ queryKey: vendorsQueryKeys.list(), @@ -904,7 +911,7 @@ export function ModelMutateDrawer({ name='price' render={({ field }) => ( - {t('Fixed price (USD)')} + {t('Fixed price')} ({currencyName}) {t( - 'Cost in USD per request, regardless of tokens used.' + 'Cost per request, regardless of tokens used.' )} @@ -946,7 +953,7 @@ export function ModelMutateDrawer({
@@ -982,7 +989,7 @@ export function ModelMutateDrawer({
{field.value && !isNaN(parseFloat(field.value)) - ? `Calculated price: $${(parseFloat(field.value) * 2).toFixed(4)} per 1M tokens` + ? `Calculated price: ${currencySymbol}${(parseFloat(field.value) * 2).toFixed(4)} per 1M tokens` : t('Multiplier for prompt tokens.')} @@ -1024,7 +1031,7 @@ export function ModelMutateDrawer({ !isNaN(parseFloat(field.value)) && promptPrice && !isNaN(parseFloat(promptPrice)) - ? `Calculated price: $${(parseFloat(promptPrice) * parseFloat(field.value)).toFixed(4)} per 1M tokens` + ? `Calculated price: ${currencySymbol}${(parseFloat(promptPrice) * parseFloat(field.value)).toFixed(4)} per 1M tokens` : t('Multiplier for completion tokens.')} @@ -1036,7 +1043,7 @@ export function ModelMutateDrawer({ <>
- +
- + { diff --git a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx index 2bca3eb79673..88a74d520d78 100644 --- a/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/subscription-purchase-dialog.tsx @@ -20,6 +20,7 @@ import { } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { GroupBadge } from '@/components/group-badge' +import { getCurrencyLabel } from '@/lib/currency' import { paySubscriptionStripe, paySubscriptionCreem, @@ -47,6 +48,9 @@ interface Props { export function SubscriptionPurchaseDialog(props: Props) { const { t } = useTranslation() + const currencyLabel = getCurrencyLabel() + const currencySymbol = + currencyLabel === 'CNY' ? '¥' : currencyLabel === 'USD' ? '$' : currencyLabel const [paying, setPaying] = useState(false) const [selectedEpayMethod, setSelectedEpayMethod] = useState('') @@ -226,7 +230,7 @@ export function SubscriptionPurchaseDialog(props: Props) {
{t('Amount Due')} - ${price} + {currencySymbol}{price}
diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index f9f4b389589d..876d48240a1e 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -15,7 +15,7 @@ const defaultBillingSettings: BillingSettings = { 'quota_setting.enable_free_model_pre_consume': true, QuotaPerUnit: 500000, USDExchangeRate: 7, - 'general_setting.quota_display_type': 'USD', + 'general_setting.quota_display_type': 'CNY', 'general_setting.custom_currency_symbol': '¤', 'general_setting.custom_currency_exchange_rate': 1, DisplayInCurrencyEnabled: true, diff --git a/web/default/src/features/system-settings/integrations/amount-discount-dialog.tsx b/web/default/src/features/system-settings/integrations/amount-discount-dialog.tsx index 344cae3ac4c1..1c69fd41a3e9 100644 --- a/web/default/src/features/system-settings/integrations/amount-discount-dialog.tsx +++ b/web/default/src/features/system-settings/integrations/amount-discount-dialog.tsx @@ -22,6 +22,7 @@ import { FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' +import { getCurrencyLabel } from '@/lib/currency' const createAmountDiscountDialogSchema = (t: (key: string) => string) => z.object({ @@ -58,6 +59,7 @@ export function AmountDiscountDialog({ editData, }: AmountDiscountDialogProps) { const { t } = useTranslation() + const currencyName = getCurrencyLabel() const isEditMode = !!editData const amountDiscountDialogSchema = createAmountDiscountDialogSchema(t) @@ -118,7 +120,7 @@ export function AmountDiscountDialog({ name='amount' render={({ field }) => ( - {t('Recharge Amount (USD)')} + {t('Recharge Amount')} ({currencyName}) { @@ -92,7 +96,7 @@ export function AmountOptionsVisualEditor({ className='text-base' copyable={false} > - ${amount} + {currencySymbol}{amount} diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx index 157e63567035..0611dffb8fee 100644 --- a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx @@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { getCurrencyLabel } from '@/lib/currency' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' import { AmountDiscountVisualEditor } from './amount-discount-visual-editor' @@ -119,6 +120,7 @@ export function PaymentSettingsSection({ waffoPancakeDefaultValues, }: PaymentSettingsSectionProps) { const { t } = useTranslation() + const currencyName = getCurrencyLabel() const updateOption = useUpdateOption() const initialRef = React.useRef(defaultValues) const defaultsSignature = React.useMemo( @@ -565,7 +567,7 @@ export function PaymentSettingsSection({ name='Price' render={({ field }) => ( - {t('Price (local currency / USD)')} + {t('Price (local currency / USD)').replace(/USD|美元/g, currencyName)} {t( - 'How much to charge for each US dollar of balance (Epay)' + 'How much to charge for each dollar of balance (Epay)' )} @@ -592,7 +594,7 @@ export function PaymentSettingsSection({ name='MinTopUp' render={({ field }) => ( - {t('Minimum top-up (USD)')} + {t('Minimum top-up (USD)').replace(/USD|美元/g, currencyName)} - {t('Smallest USD amount users can recharge (Epay)')} + {t('Smallest USD amount users can recharge (Epay)').replace(/USD|美元/g, currencyName)} @@ -1030,7 +1032,7 @@ export function PaymentSettingsSection({ render={({ field }) => ( - {t('Unit price (local currency / USD)')} + {t('Unit price (local currency / USD)').replace(/USD|美元/g, currencyName)} - {t('e.g., 8 means 8 local currency per USD')} + {t('e.g., 8 means 8 local currency per USD').replace(/USD|美元/g, currencyName)} @@ -1056,7 +1058,7 @@ export function PaymentSettingsSection({ name='StripeMinTopUp' render={({ field }) => ( - {t('Minimum top-up (USD)')} + {t('Minimum top-up (USD)').replace(/USD|美元/g, currencyName)} - {t('Minimum recharge amount in USD')} + {t('Minimum recharge amount in USD').replace(/USD|美元/g, currencyName)} diff --git a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx index ce80b53c442a..cf1ccb762b7f 100644 --- a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { getCurrencyLabel } from '@/lib/currency' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' import { removeTrailingSlash } from './utils' @@ -33,6 +34,7 @@ interface Props { export function WaffoPancakeSettingsSection(props: Props) { const { t } = useTranslation() + const currencyName = getCurrencyLabel() const updateOption = useUpdateOption() const [loading, setLoading] = useState(false) const form = useForm({ @@ -157,7 +159,7 @@ export function WaffoPancakeSettingsSection(props: Props) { title={t('Waffo Pancake Payment Gateway')} description={t( 'Configure Waffo Pancake hosted checkout integration for USD-priced top-ups' - )} + ).replace(/USD|美元/g, currencyName)} > @@ -264,7 +266,7 @@ export function WaffoPancakeSettingsSection(props: Props) {
- +
- + (null) @@ -285,7 +287,7 @@ export function WaffoSettingsSection(props: Props) {
- + , laneEnabled: Record, - t: (key: string) => string + t: (key: string) => string, + symbol: string = '$' ): PreviewRow[] { if (mode === 'tiered_expr') { const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr) @@ -297,7 +299,7 @@ function buildPreviewRows( { key: 'price', label: 'ModelPrice', - value: values.price || t('Empty'), + value: values.price ? `${symbol}${values.price}` : t('Empty'), }, ] } @@ -306,14 +308,14 @@ function buildPreviewRows( { key: 'inputPrice', label: t('Input price'), - value: promptPrice ? `$${promptPrice}` : t('Empty'), + value: promptPrice ? `${symbol}${promptPrice}` : t('Empty'), }, { key: 'completion', label: t('Completion price'), value: laneEnabled.completion && lanePrices.completion - ? `$${lanePrices.completion}` + ? `${symbol}${lanePrices.completion}` : t('Empty'), }, { @@ -321,7 +323,7 @@ function buildPreviewRows( label: t('Cache read price'), value: laneEnabled.cache && lanePrices.cache - ? `$${lanePrices.cache}` + ? `${symbol}${lanePrices.cache}` : t('Empty'), }, { @@ -329,7 +331,7 @@ function buildPreviewRows( label: t('Cache write price'), value: laneEnabled.createCache && lanePrices.createCache - ? `$${lanePrices.createCache}` + ? `${symbol}${lanePrices.createCache}` : t('Empty'), }, { @@ -337,7 +339,7 @@ function buildPreviewRows( label: t('Image input price'), value: laneEnabled.image && lanePrices.image - ? `$${lanePrices.image}` + ? `${symbol}${lanePrices.image}` : t('Empty'), }, { @@ -345,7 +347,7 @@ function buildPreviewRows( label: t('Audio input price'), value: laneEnabled.audioInput && lanePrices.audioInput - ? `$${lanePrices.audioInput}` + ? `${symbol}${lanePrices.audioInput}` : t('Empty'), }, { @@ -353,7 +355,7 @@ function buildPreviewRows( label: t('Audio output price'), value: laneEnabled.audioOutput && lanePrices.audioOutput - ? `$${lanePrices.audioOutput}` + ? `${symbol}${lanePrices.audioOutput}` : t('Empty'), }, ] @@ -401,6 +403,8 @@ export function ModelPricingEditorPanel({ className, }: ModelPricingEditorPanelProps) { const { t } = useTranslation() + const currencyLabel = getCurrencyLabel() + const currencySymbol = currencyLabel === 'CNY' ? '¥' : currencyLabel === 'Tokens' ? '' : '$' const [pricingMode, setPricingMode] = useState('per-token') const [promptPrice, setPromptPrice] = useState('') const [lanePrices, setLanePrices] = useState>({ @@ -607,10 +611,12 @@ export function ModelPricingEditorPanel({ promptPrice, lanePrices, laneEnabled, - t + t, + currencySymbol ), [ billingExpr, + currencySymbol, laneEnabled, lanePrices, pricingMode, @@ -799,10 +805,11 @@ export function ModelPricingEditorPanel({ - {t('USD price per 1M input tokens.')} + {t('Price per 1M input tokens.')} @@ -821,6 +828,7 @@ export function ModelPricingEditorPanel({ value={lanePrices[lane.key]} enabled={laneEnabled[lane.key]} disabled={disabled} + currencySymbol={currencySymbol} onEnabledChange={(checked) => handleLaneToggle(lane.key, checked) } @@ -846,7 +854,7 @@ export function ModelPricingEditorPanel({ {t('Fixed price')} - $ + {currencySymbol || '$'} {t( - 'Cost in USD per request, regardless of tokens used.' + 'Cost per request, regardless of tokens used.' )} @@ -961,11 +969,13 @@ function PriceInput(props: { value: string placeholder?: string disabled?: boolean + currencySymbol?: string onChange: (value: string) => void }) { + const symbol = props.currencySymbol ?? '$' return ( - $ + {symbol} props.onChange(event.target.value)} /> - $/1M + {symbol}/1M ) } @@ -985,6 +995,7 @@ function PriceLane(props: { value: string enabled: boolean disabled?: boolean + currencySymbol?: string onEnabledChange: (checked: boolean) => void onChange: (value: string) => void }) { @@ -1015,11 +1026,12 @@ function PriceLane(props: { value={props.value} placeholder={props.placeholder} disabled={effectiveDisabled} + currencySymbol={props.currencySymbol} onChange={props.onChange} /> {props.enabled - ? t('USD price per 1M tokens.') + ? t('Price per 1M tokens.') : t('Disabled lanes are omitted on save.')} diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index f8a2552f11a3..912772f8d424 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -157,7 +157,7 @@ export const ModelRatioForm = memo(function ModelRatioForm({ {t( - 'JSON map of model → USD cost per request. Takes precedence over ratio based billing.' + 'JSON map of model → cost per request. Takes precedence over ratio based billing.' )} diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index 8b8fd55c7c13..02d1d2f1f36c 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -20,6 +20,7 @@ import { useMediaQuery } from '@/hooks' import { Copy, Pencil, Plus, Trash2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { getCurrencyLabel } from '@/lib/currency' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -127,12 +128,12 @@ const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => { return t('Expression pricing') } -const getPriceSummary = (row: ModelRow, t: (key: string) => string) => { +const getPriceSummary = (row: ModelRow, t: (key: string) => string, symbol: string = '$') => { if (row.billingMode === 'tiered_expr') { return getExpressionSummary(row, t) } if (row.billingMode === 'per-request') { - return row.price ? `$${row.price} / ${t('request')}` : t('Unset price') + return row.price ? `${symbol}${row.price} / ${t('request')}` : t('Unset price') } const inputPrice = ratioToPrice(row.ratio) @@ -148,11 +149,11 @@ const getPriceSummary = (row: ModelRow, t: (key: string) => string) => { ].filter(hasValue).length return extraCount > 0 - ? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}` - : `${t('Input')} $${inputPrice}` + ? `${t('Input')} ${symbol}${inputPrice} · ${extraCount} ${t('extras')}` + : `${t('Input')} ${symbol}${inputPrice}` } -const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { +const getPriceDetail = (row: ModelRow, t: (key: string) => string, symbol: string = '$') => { if (row.billingMode === 'tiered_expr') { return row.requestRuleExpr ? t('Includes request rules') @@ -167,11 +168,11 @@ const getPriceDetail = (row: ModelRow, t: (key: string) => string) => { const details = [ row.completionRatio && - `${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`, + `${t('Output')} ${symbol}${ratioToPrice(row.completionRatio, inputPrice)}`, row.cacheRatio && - `${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`, + `${t('Cache')} ${symbol}${ratioToPrice(row.cacheRatio, inputPrice)}`, row.createCacheRatio && - `${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`, + `${t('Cache write')} ${symbol}${ratioToPrice(row.createCacheRatio, inputPrice)}`, ].filter(Boolean) return details.length > 0 ? details.join(' · ') : t('Base input price only') @@ -192,6 +193,8 @@ export const ModelRatioVisualEditor = memo( onChange, }: ModelRatioVisualEditorProps) { const { t } = useTranslation() + const currencyLabel = getCurrencyLabel() + const currencySymbol = currencyLabel === 'CNY' ? '¥' : currencyLabel === 'Tokens' ? '' : '$' const isMobile = useMediaQuery('(max-width: 767px)') const [sheetOpen, setSheetOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false) @@ -618,16 +621,16 @@ export const ModelRatioVisualEditor = memo( cell: ({ row }) => (
- {getPriceSummary(row.original, t)} + {getPriceSummary(row.original, t, currencySymbol)} - {getPriceDetail(row.original, t)} + {getPriceDetail(row.original, t, currencySymbol)}
), sortingFn: (rowA, rowB) => - getPriceSummary(rowA.original, t).localeCompare( - getPriceSummary(rowB.original, t) + getPriceSummary(rowA.original, t, currencySymbol).localeCompare( + getPriceSummary(rowB.original, t, currencySymbol) ), meta: { label: t('Price summary') }, }, @@ -654,7 +657,7 @@ export const ModelRatioVisualEditor = memo( enableHiding: false, }, ] - }, [handleEdit, handleDelete, t]) + }, [currencySymbol, handleEdit, handleDelete, t]) const table = useReactTable({ data: models, diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 413d70f84d82..c6e4fdf91669 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -3029,6 +3029,7 @@ "Prompt caching": "Prompt caching", "Prompt Caching": "Prompt Caching", "Prompt Details": "Prompt Details", + "Prompt price": "Prompt price", "Prompt price ($/1M tokens)": "Prompt price ($/1M tokens)", "Proprietary": "Proprietary", "Protect login and registration with Cloudflare Turnstile": "Protect login and registration with Cloudflare Turnstile", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index e9fa98447b85..1ddba1d08bfe 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -963,6 +963,7 @@ "Core pricing": "核心价格", "Cost": "费用", "Cost in USD per request, regardless of tokens used.": "每请求的美元费用,不考虑使用的令牌数。", + "Cost per request, regardless of tokens used.": "每请求的费用,不考虑使用的令牌数。", "Cost Tracking": "成本跟踪", "Count must be between {{min}} and {{max}}": "计数必须介于{{min}}和{{max}}之间", "Coze": "Coze", @@ -3029,6 +3030,7 @@ "Prompt caching": "提示词缓存", "Prompt Caching": "提示词缓存", "Prompt Details": "提示词详情", + "Prompt price": "提示词价格", "Prompt price ($/1M tokens)": "提示词价格(美元/100 万 token)", "Proprietary": "商业闭源", "Protect login and registration with Cloudflare Turnstile": "使用 Cloudflare Turnstile 保护登录和注册", @@ -4145,6 +4147,8 @@ "USD Exchange Rate": "美元汇率", "USD price per 1M input tokens.": "每 100 万输入 token 的美元价格。", "USD price per 1M tokens.": "每 100 万 token 的美元价格。", + "Price per 1M input tokens.": "每 100 万输入 token 的价格。", + "Price per 1M tokens.": "每 100 万 token 的价格。", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "使用 +: 添加分组,使用 -: 移除默认可选分组,不加前缀则追加分组。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "请使用支持生物识别认证或安全密钥的兼容浏览器或设备来注册通行密钥。", "Use authenticator code": "使用验证器代码", diff --git a/web/default/src/stores/system-config-store.ts b/web/default/src/stores/system-config-store.ts index 5b0f8f38be7c..485adcaa7228 100644 --- a/web/default/src/stores/system-config-store.ts +++ b/web/default/src/stores/system-config-store.ts @@ -30,9 +30,9 @@ export interface SystemConfig { export const DEFAULT_CURRENCY_CONFIG: CurrencyConfig = { displayInCurrency: true, - quotaDisplayType: 'USD', + quotaDisplayType: 'CNY', quotaPerUnit: 500000, - usdExchangeRate: 1, + usdExchangeRate: 7.3, customCurrencySymbol: '¤', customCurrencyExchangeRate: 1, } From 9435a3d41795c47047e82a98a34aaf7756aac661 Mon Sep 17 00:00:00 2001 From: lzc Date: Mon, 11 May 2026 19:36:18 +0800 Subject: [PATCH 02/15] fix: switch billing base to CNY and fix floating-point precision in price inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redefine ratio constants: RMB=500 as base, USD=RMB*7.3 (was USD=500) - Update QuotaPerUnit comment and logger/billing to reflect CNY-first display - Frontend currency.ts: CNY exchangeRate=1 (direct), USD=1/rate - Default currency config: quotaDisplayType changed to CNY - Fix toFixed(12) → toFixed(8) in price display (default + classic UI) - Add formatRatio (toFixed(14)) for ratio storage to prevent round-trip floating-point loss (e.g. input 1 → stored as 0.33333333 → display 0.99999999) - Enable Rsbuild polling watch for Docker on Windows (HMR support) Co-Authored-By: Claude Sonnet 4.6 --- common/constants.go | 2 +- controller/billing.go | 15 ++++++------ logger/logger.go | 24 +++++++++---------- setting/ratio_setting/model_ratio.go | 18 ++++++-------- .../Ratio/hooks/useModelPricingEditorState.js | 24 ++++++++++++------- web/default/rsbuild.config.ts | 16 +++++++++---- .../components/dynamic-pricing-breakdown.tsx | 8 +++++-- .../models/model-pricing-sheet.tsx | 14 +++++++---- .../models/model-ratio-visual-editor.tsx | 2 +- web/default/src/lib/currency.ts | 23 ++++++++++-------- web/default/src/stores/system-config-store.ts | 4 ++-- 11 files changed, 86 insertions(+), 64 deletions(-) diff --git a/common/constants.go b/common/constants.go index c4d2511ef357..bb2c9f571340 100644 --- a/common/constants.go +++ b/common/constants.go @@ -38,7 +38,7 @@ func SetTheme(t string) { // var ChatLink = "" // var ChatLink2 = "" -var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens +var QuotaPerUnit = 500 * 1000.0 // ¥0.002 / 1K tokens(人民币基准) // 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制 var DisplayInCurrencyEnabled = true var DisplayTokenStatEnabled = true diff --git a/controller/billing.go b/controller/billing.go index f75f6819842e..e4bd3baca491 100644 --- a/controller/billing.go +++ b/controller/billing.go @@ -40,18 +40,17 @@ func GetSubscription(c *gin.Context) { } quota := remainQuota + usedQuota amount := float64(quota) - // OpenAI 兼容接口中的 *_USD 字段含义保持“额度单位”对应值: - // 我们将其解释为以“站点展示类型”为准: - // - USD: 直接除以 QuotaPerUnit - // - CNY: 先转 USD 再乘汇率 + // 计价基准为人民币:quota / QuotaPerUnit 直接得到 CNY + // - CNY: 直接除以 QuotaPerUnit + // - USD: 除以 QuotaPerUnit 再除以汇率 // - TOKENS: 直接使用 tokens 数量 switch operation_setting.GetQuotaDisplayType() { case operation_setting.QuotaDisplayTypeCNY: - amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate + amount = amount / common.QuotaPerUnit case operation_setting.QuotaDisplayTypeTokens: // amount 保持 tokens 数值 default: - amount = amount / common.QuotaPerUnit + amount = amount / common.QuotaPerUnit / operation_setting.USDExchangeRate } if token != nil && token.UnlimitedQuota { amount = 100000000 @@ -93,11 +92,11 @@ func GetUsage(c *gin.Context) { amount := float64(quota) switch operation_setting.GetQuotaDisplayType() { case operation_setting.QuotaDisplayTypeCNY: - amount = amount / common.QuotaPerUnit * operation_setting.USDExchangeRate + amount = amount / common.QuotaPerUnit case operation_setting.QuotaDisplayTypeTokens: // tokens 保持原值 default: - amount = amount / common.QuotaPerUnit + amount = amount / common.QuotaPerUnit / operation_setting.USDExchangeRate } usage := OpenAIUsageResponse{ Object: "list", diff --git a/logger/logger.go b/logger/logger.go index 7b0c82de50d6..936d04a9465d 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -118,15 +118,14 @@ func logHelper(ctx context.Context, level string, msg string) { } func LogQuota(quota int) string { - // 新逻辑:根据额度展示类型输出 + // 计价基准为人民币,直接除以 QuotaPerUnit 即为 CNY q := float64(quota) switch operation_setting.GetQuotaDisplayType() { case operation_setting.QuotaDisplayTypeCNY: - usd := q / common.QuotaPerUnit - cny := usd * operation_setting.USDExchangeRate + cny := q / common.QuotaPerUnit return fmt.Sprintf("¥%.6f 额度", cny) case operation_setting.QuotaDisplayTypeCustom: - usd := q / common.QuotaPerUnit + base := q / common.QuotaPerUnit rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol if symbol == "" { @@ -135,12 +134,13 @@ func LogQuota(quota int) string { if rate <= 0 { rate = 1 } - v := usd * rate + v := base * rate return fmt.Sprintf("%s%.6f 额度", symbol, v) case operation_setting.QuotaDisplayTypeTokens: return fmt.Sprintf("%d 点额度", quota) default: // USD - return fmt.Sprintf("$%.6f 额度", q/common.QuotaPerUnit) + usd := q / common.QuotaPerUnit / operation_setting.USDExchangeRate + return fmt.Sprintf("$%.6f 额度", usd) } } @@ -148,11 +148,10 @@ func FormatQuota(quota int) string { q := float64(quota) switch operation_setting.GetQuotaDisplayType() { case operation_setting.QuotaDisplayTypeCNY: - usd := q / common.QuotaPerUnit - cny := usd * operation_setting.USDExchangeRate + cny := q / common.QuotaPerUnit return fmt.Sprintf("¥%.6f", cny) case operation_setting.QuotaDisplayTypeCustom: - usd := q / common.QuotaPerUnit + base := q / common.QuotaPerUnit rate := operation_setting.GetGeneralSetting().CustomCurrencyExchangeRate symbol := operation_setting.GetGeneralSetting().CustomCurrencySymbol if symbol == "" { @@ -161,12 +160,13 @@ func FormatQuota(quota int) string { if rate <= 0 { rate = 1 } - v := usd * rate + v := base * rate return fmt.Sprintf("%s%.6f", symbol, v) case operation_setting.QuotaDisplayTypeTokens: return fmt.Sprintf("%d", quota) - default: - return fmt.Sprintf("$%.6f", q/common.QuotaPerUnit) + default: // USD + usd := q / common.QuotaPerUnit / operation_setting.USDExchangeRate + return fmt.Sprintf("$%.6f", usd) } } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 80702ee42ad2..ac1274727dbd 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -8,20 +8,16 @@ import ( "github.com/QuantumNous/new-api/types" ) -// from songquanpeng/one-api +// 计价基准:人民币 (CNY) const ( - USD2RMB = 7.3 // 暂定 1 USD = 7.3 RMB - USD = 500 // $0.002 = 1 -> $1 = 500 - RMB = USD / USD2RMB + USD2RMB = 7.3 // 暂定 1 USD = 7.3 RMB + RMB = 500 // ¥0.002 = 1 -> ¥1 = 500(基准货币) + USD = RMB * USD2RMB // $1 = 3650(由汇率换算,仅供参考) ) -// modelRatio -// https://platform.openai.com/docs/models/model-endpoint-compatibility -// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf -// https://openai.com/pricing -// TODO: when a new api is enabled, check the pricing here -// 1 === $0.002 / 1K tokens -// 1 === ¥0.014 / 1k tokens +// modelRatio — 以人民币为基准 +// 1 === ¥0.002 / 1K tokens === ¥2 / 1M tokens +// 1 === $0.000274 / 1K tokens(按 7.3 汇率换算) var defaultModelRatio = map[string]float64{ //"midjourney": 50, diff --git a/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js b/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js index ba9efdfcebc0..f232e3d96d5c 100644 --- a/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js +++ b/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js @@ -80,7 +80,15 @@ const formatNumber = (value) => { if (num === null) { return ''; } - return parseFloat(num.toFixed(12)).toString(); + return parseFloat(num.toFixed(8)).toString(); +}; + +const formatRatio = (value) => { + const num = toNumberOrNull(value); + if (num === null) { + return ''; + } + return parseFloat(num.toFixed(14)).toString(); }; const toNormalizedNumber = (value) => { @@ -565,7 +573,7 @@ export const buildPreviewRows = (model, t) => { { key: 'ModelRatio', label: 'ModelRatio', - value: formatNumber(inputPrice / 2), + value: formatRatio(inputPrice / 2), }, { key: 'CompletionRatio', @@ -573,35 +581,35 @@ export const buildPreviewRows = (model, t) => { value: model.completionRatioLocked ? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})` : completionPrice !== null - ? formatNumber(completionPrice / inputPrice) + ? formatRatio(completionPrice / inputPrice) : t('空'), }, { key: 'CacheRatio', label: 'CacheRatio', value: - cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'), + cachePrice !== null ? formatRatio(cachePrice / inputPrice) : t('空'), }, { key: 'CreateCacheRatio', label: 'CreateCacheRatio', value: createCachePrice !== null - ? formatNumber(createCachePrice / inputPrice) + ? formatRatio(createCachePrice / inputPrice) : t('空'), }, { key: 'ImageRatio', label: 'ImageRatio', value: - imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'), + imagePrice !== null ? formatRatio(imagePrice / inputPrice) : t('空'), }, { key: 'AudioRatio', label: 'AudioRatio', value: audioInputPrice !== null - ? formatNumber(audioInputPrice / inputPrice) + ? formatRatio(audioInputPrice / inputPrice) : t('空'), }, { @@ -611,7 +619,7 @@ export const buildPreviewRows = (model, t) => { audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0 - ? formatNumber(audioOutputPrice / audioInputPrice) + ? formatRatio(audioOutputPrice / audioInputPrice) : t('空'), }, ]; diff --git a/web/default/rsbuild.config.ts b/web/default/rsbuild.config.ts index 3c60ab50c38f..634374721d9f 100644 --- a/web/default/rsbuild.config.ts +++ b/web/default/rsbuild.config.ts @@ -88,15 +88,21 @@ export default defineConfig(({ envMode }) => { }, }, tools: { - rspack: { - plugins: [ + rspack: (config) => { + config.plugins = config.plugins ?? [] + config.plugins.push( tanstackRouter({ target: 'react', - // Dev: avoid per-route async chunks (reduces white flash on navigation + faster HMR feedback). - // Prod: keep route-based code splitting. autoCodeSplitting: isProd, }), - ], + ) + if (!isProd) { + config.watchOptions = { + poll: 1000, + aggregateTimeout: 300, + } + } + return config }, }, } diff --git a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx index 50fd9416f84d..2d827f871c55 100644 --- a/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx +++ b/web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx @@ -146,7 +146,7 @@ export function DynamicPricingBreakdown({ const { symbol, rate } = useMemo(() => { if (currency.quotaDisplayType === 'CNY') { - return { symbol: '¥', rate: currency.usdExchangeRate || 7 } + return { symbol: '¥', rate: 1 } } if (currency.quotaDisplayType === 'CUSTOM') { return { @@ -154,7 +154,11 @@ export function DynamicPricingBreakdown({ rate: currency.customCurrencyExchangeRate || 1, } } - return { symbol: '¥', rate: currency.usdExchangeRate || 7 } + if (currency.quotaDisplayType === 'USD') { + const r = currency.usdExchangeRate > 0 ? currency.usdExchangeRate : 7.3 + return { symbol: '$', rate: 1 / r } + } + return { symbol: '¥', rate: 1 } }, [currency]) const { tiers, ruleGroups } = useMemo(() => { diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index 4bac5b2186b6..a9d4670bf504 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -202,7 +202,13 @@ function toNumberOrNull(value: unknown): number | null { function formatNumber(value: unknown): string { const num = toNumberOrNull(value) if (num === null) return '' - return Number.parseFloat(num.toFixed(12)).toString() + return Number.parseFloat(num.toFixed(8)).toString() +} + +function formatRatio(value: unknown): string { + const num = toNumberOrNull(value) + if (num === null) return '' + return Number.parseFloat(num.toFixed(14)).toString() } function ratioToBasePrice(ratio: unknown): string { @@ -499,12 +505,12 @@ export function ModelPricingEditorPanel({ if (lane === 'audioOutput') { const audioInputPrice = toNumberOrNull(nextLanePrices.audioInput) if (audioInputPrice === null || audioInputPrice === 0) return '' - return formatNumber(priceNumber / audioInputPrice) + return formatRatio(priceNumber / audioInputPrice) } const inputPrice = toNumberOrNull(nextPromptPrice) if (inputPrice === null || inputPrice === 0) return '' - return formatNumber(priceNumber / inputPrice) + return formatRatio(priceNumber / inputPrice) } const syncLaneRatios = ( @@ -515,7 +521,7 @@ export function ModelPricingEditorPanel({ const inputPrice = toNumberOrNull(nextPromptPrice) setFormValue( 'ratio', - inputPrice !== null ? formatNumber(inputPrice / 2) : '' + inputPrice !== null ? formatRatio(inputPrice / 2) : '' ) laneConfigs.forEach(({ key }) => { diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index 02d1d2f1f36c..c07e1b5a6eee 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -90,7 +90,7 @@ const toNumberOrNull = (value?: string) => { } const formatPrice = (value: number) => { - return Number.parseFloat(value.toFixed(12)).toString() + return Number.parseFloat(value.toFixed(8)).toString() } const ratioToPrice = (ratio?: string, denominator?: string) => { diff --git a/web/default/src/lib/currency.ts b/web/default/src/lib/currency.ts index 8f0a1a732583..c5cea0ccc2b9 100644 --- a/web/default/src/lib/currency.ts +++ b/web/default/src/lib/currency.ts @@ -9,13 +9,12 @@ * * ## Key Concepts * - * 1. **System USD**: Internal currency unit used throughout the system (e.g., 10 USD) - * 2. **Local Currency**: Admin-configured display currency (e.g., CNY, custom currency) - * 3. **Exchange Rate (usdExchangeRate)**: Conversion rate from USD to local currency - * - Example: usdExchangeRate = 7 means 1 USD = 7 CNY - * 4. **Recharge Price (priceRatio)**: Cost in local currency to purchase 1 system USD - * - Example: priceRatio = 5 means user pays 5 CNY to get 1 USD credit - * 5. **Tokens**: Alternative display unit (e.g., 500,000 tokens = 1 USD) + * 1. **System CNY**: Internal currency unit (base currency is RMB/CNY) + * 2. **Display Currency**: Admin-configured display currency (CNY, USD, custom) + * 3. **Exchange Rate (usdExchangeRate)**: 1 USD = X CNY (e.g., 7.3) + * - Used to convert FROM CNY base TO USD display (divide by rate) + * 4. **Recharge Price (priceRatio)**: Cost in local currency to purchase 1 CNY credit + * 5. **Tokens**: Alternative display unit (e.g., 500,000 tokens = 1 CNY) * * ## When to Use Each Function * @@ -148,13 +147,15 @@ function getConfig(): CurrencyConfig { } function getDisplayMeta(config: CurrencyConfig): DisplayMeta { + // 计价基准为人民币:ratio * 2 直接得到 ¥/1M tokens + // CNY 显示无需转换,USD 显示需除以汇率 switch (config.quotaDisplayType) { case 'CNY': return { kind: 'currency', symbol: '¥', currencyCode: 'CNY', - exchangeRate: config.usdExchangeRate, + exchangeRate: 1, } case 'CUSTOM': return { @@ -168,13 +169,15 @@ function getDisplayMeta(config: CurrencyConfig): DisplayMeta { quotaPerUnit: config.quotaPerUnit, } case 'USD': - default: + default: { + const rate = config.usdExchangeRate > 0 ? config.usdExchangeRate : 7.3 return { kind: 'currency', symbol: '$', currencyCode: 'USD', - exchangeRate: 1, + exchangeRate: 1 / rate, } + } } } diff --git a/web/default/src/stores/system-config-store.ts b/web/default/src/stores/system-config-store.ts index 485adcaa7228..0f33ce1a6651 100644 --- a/web/default/src/stores/system-config-store.ts +++ b/web/default/src/stores/system-config-store.ts @@ -9,9 +9,9 @@ export interface CurrencyConfig { displayInCurrency: boolean /** Currency presentation strategy configured by the admin */ quotaDisplayType: CurrencyDisplayType - /** Number of quota units that equal one USD */ + /** Number of quota units that equal one CNY (base currency) */ quotaPerUnit: number - /** Exchange rate from USD to the configured local currency */ + /** Exchange rate: 1 USD = X CNY, used for USD display conversion */ usdExchangeRate: number /** Custom currency symbol configured by the admin (used when type === CUSTOM) */ customCurrencySymbol: string From e749047afa7d5daac83636bb110a7c3138315358 Mon Sep 17 00:00:00 2001 From: lzc Date: Tue, 12 May 2026 13:19:49 +0800 Subject: [PATCH 03/15] feat: add Merged JSON mode for model pricing editor - Add a third editing mode alongside Visual and Field JSON - Merged JSON allows configuring all fields of a model in one entry - Live-sync between merged format and individual field JSONs - Include Chinese field description table for quick reference Co-Authored-By: Claude Sonnet 4.6 --- .../models/model-ratio-form.tsx | 262 ++++++++++++++++-- 1 file changed, 242 insertions(+), 20 deletions(-) diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index 912772f8d424..e10004919d02 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -1,8 +1,9 @@ import { memo, useCallback, useState } from 'react' import { type UseFormReturn } from 'react-hook-form' -import { Code2, Eye } from 'lucide-react' +import { Code2, Eye, Layers } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, @@ -13,9 +14,117 @@ import { FormMessage, } from '@/components/ui/form' import { Switch } from '@/components/ui/switch' -import { Textarea } from '@/components/ui/textarea' import { ModelRatioVisualEditor } from './model-ratio-visual-editor' +// --------------------------------------------------------------------------- +// Merged JSON helpers +// --------------------------------------------------------------------------- + +type MergedModelEntry = { + ratio?: number + price?: number + completion_ratio?: number + cache_ratio?: number + create_cache_ratio?: number + image_ratio?: number + audio_ratio?: number + audio_completion_ratio?: number +} + +function safeParseJson(s: string): Record { + try { + if (!s.trim()) return {} + return JSON.parse(s) as Record + } catch { + return {} + } +} + +/** Convert 8 separate field JSONs → one merged-per-model object */ +function fieldsToMerged(values: ModelFormValues): string { + const ratio = safeParseJson(values.ModelRatio) + const price = safeParseJson(values.ModelPrice) + const completion = safeParseJson(values.CompletionRatio) + const cache = safeParseJson(values.CacheRatio) + const createCache = safeParseJson(values.CreateCacheRatio) + const image = safeParseJson(values.ImageRatio) + const audio = safeParseJson(values.AudioRatio) + const audioCompletion = safeParseJson(values.AudioCompletionRatio) + + const models = new Set([ + ...Object.keys(ratio), + ...Object.keys(price), + ...Object.keys(completion), + ...Object.keys(cache), + ...Object.keys(createCache), + ...Object.keys(image), + ...Object.keys(audio), + ...Object.keys(audioCompletion), + ]) + + const merged: Record = {} + for (const model of models) { + const entry: MergedModelEntry = {} + if (ratio[model] !== undefined) entry.ratio = ratio[model] + if (price[model] !== undefined) entry.price = price[model] + if (completion[model] !== undefined) entry.completion_ratio = completion[model] + if (cache[model] !== undefined) entry.cache_ratio = cache[model] + if (createCache[model] !== undefined) entry.create_cache_ratio = createCache[model] + if (image[model] !== undefined) entry.image_ratio = image[model] + if (audio[model] !== undefined) entry.audio_ratio = audio[model] + if (audioCompletion[model] !== undefined) entry.audio_completion_ratio = audioCompletion[model] + merged[model] = entry + } + return JSON.stringify(merged, null, 2) +} + +/** Convert merged-per-model object → 8 separate field JSONs, merged with existing */ +function mergedToFields( + mergedJson: string, + current: ModelFormValues +): Partial { + let merged: Record + try { + merged = JSON.parse(mergedJson) + } catch { + return {} + } + + const ratio = safeParseJson(current.ModelRatio) + const price = safeParseJson(current.ModelPrice) + const completion = safeParseJson(current.CompletionRatio) + const cache = safeParseJson(current.CacheRatio) + const createCache = safeParseJson(current.CreateCacheRatio) + const image = safeParseJson(current.ImageRatio) + const audio = safeParseJson(current.AudioRatio) + const audioCompletion = safeParseJson(current.AudioCompletionRatio) + + for (const [model, entry] of Object.entries(merged)) { + if (entry.ratio !== undefined) ratio[model] = entry.ratio + if (entry.price !== undefined) price[model] = entry.price + if (entry.completion_ratio !== undefined) completion[model] = entry.completion_ratio + if (entry.cache_ratio !== undefined) cache[model] = entry.cache_ratio + if (entry.create_cache_ratio !== undefined) createCache[model] = entry.create_cache_ratio + if (entry.image_ratio !== undefined) image[model] = entry.image_ratio + if (entry.audio_ratio !== undefined) audio[model] = entry.audio_ratio + if (entry.audio_completion_ratio !== undefined) audioCompletion[model] = entry.audio_completion_ratio + } + + const s = (o: Record) => + Object.keys(o).length ? JSON.stringify(o, null, 2) : '' + + return { + ModelRatio: s(ratio), + ModelPrice: s(price), + CompletionRatio: s(completion), + CacheRatio: s(cache), + CreateCacheRatio: s(createCache), + ImageRatio: s(image), + AudioRatio: s(audio), + AudioCompletionRatio: s(audioCompletion), + } +} + type ModelFormValues = { ModelPrice: string ModelRatio: string @@ -46,7 +155,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({ isResetting, }: ModelRatioFormProps) { const { t } = useTranslation() - const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') + const [editMode, setEditMode] = useState<'visual' | 'json' | 'merged'>('visual') + const [mergedJson, setMergedJson] = useState('') const handleFieldChange = useCallback( (field: keyof ModelFormValues, value: string) => { @@ -58,30 +168,142 @@ export const ModelRatioForm = memo(function ModelRatioForm({ [form] ) - const toggleEditMode = useCallback(() => { - setEditMode((prev) => (prev === 'visual' ? 'json' : 'visual')) - }, []) + const switchMode = useCallback( + (next: 'visual' | 'json' | 'merged') => { + if (next === 'merged') { + setMergedJson(fieldsToMerged(form.getValues())) + } + if (editMode === 'merged') { + // flush merged → fields before leaving + const patches = mergedToFields(mergedJson, form.getValues()) + for (const [k, v] of Object.entries(patches)) { + form.setValue(k as keyof ModelFormValues, v as string, { + shouldValidate: true, + shouldDirty: true, + }) + } + } + setEditMode(next) + }, + [editMode, form, mergedJson] + ) + + const handleMergedChange = useCallback( + (value: string) => { + setMergedJson(value) + // live-sync into form fields so save works at any time + const patches = mergedToFields(value, form.getValues()) + for (const [k, v] of Object.entries(patches)) { + form.setValue(k as keyof ModelFormValues, v as string, { + shouldDirty: true, + }) + } + }, + [form] + ) return (
-
- + +
- {editMode === 'visual' ? ( + {editMode === 'merged' ? ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型说明
rationumber输入 token 计费比率。1 = ¥0.002/1K tokens = ¥2/1M tokens
pricenumber每次请求固定价格(¥/次),优先于 ratio。适用于图像生成等按次计费的模型
completion_rationumber输出 token 相对输入的价格倍数。例如填 3.0,表示输出价格 = 输入价格 × 3
cache_rationumber缓存读取折扣倍数,上游命中缓存时使用。通常为 0.1~0.5
create_cache_rationumber写入 Prompt Cache 时的额外费用倍数。通常为 1.25(如 Claude 系列)
image_rationumber图像输入相对于 ratio 的倍数,适用于视觉多模态模型
audio_rationumber音频输入相对于 ratio 的倍数,适用于语音识别/音频理解模型
audio_completion_rationumber音频输出相对于 audio_ratio 的倍数,适用于语音合成/音频回复模型
+
+