fix: update language settings and improve model pricing editor for better clarity and functionality

This commit is contained in:
CaIon
2026-03-06 21:36:51 +08:00
parent 782124510a
commit 8186ed0ea5
23 changed files with 2217 additions and 4422 deletions

View File

@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -17,10 +16,56 @@ import (
"github.com/gin-gonic/gin"
)
var completionRatioMetaOptionKeys = []string{
"ModelPrice",
"ModelRatio",
"CompletionRatio",
"CacheRatio",
"CreateCacheRatio",
"ImageRatio",
"AudioRatio",
"AudioCompletionRatio",
}
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
if strings.TrimSpace(raw) == "" {
return
}
var parsed map[string]any
if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
return
}
for modelName := range parsed {
modelNames[modelName] = struct{}{}
}
}
func buildCompletionRatioMetaValue(optionValues map[string]string) string {
modelNames := make(map[string]struct{})
for _, key := range completionRatioMetaOptionKeys {
collectModelNamesFromOptionValue(optionValues[key], modelNames)
}
meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
for modelName := range modelNames {
meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
}
jsonBytes, err := common.Marshal(meta)
if err != nil {
return "{}"
}
return string(jsonBytes)
}
func GetOptions(c *gin.Context) {
var options []*model.Option
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
value := common.Interface2String(v)
if strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
strings.HasSuffix(k, "Key") ||
@@ -30,10 +75,20 @@ func GetOptions(c *gin.Context) {
}
options = append(options, &model.Option{
Key: k,
Value: common.Interface2String(v),
Value: value,
})
for _, optionKey := range completionRatioMetaOptionKeys {
if optionKey == k {
optionValues[k] = value
break
}
}
}
common.OptionMapRWMutex.Unlock()
options = append(options, &model.Option{
Key: "CompletionRatioMeta",
Value: buildCompletionRatioMetaValue(optionValues),
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -49,7 +104,7 @@ type OptionUpdateRequest struct {
func UpdateOption(c *gin.Context) {
var option OptionUpdateRequest
err := json.NewDecoder(c.Request.Body).Decode(&option)
err := common.DecodeJson(c.Request.Body, &option)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,

View File

@@ -452,6 +452,44 @@ func GetCompletionRatio(name string) float64 {
return hardCodedRatio
}
type CompletionRatioInfo struct {
Ratio float64 `json:"ratio"`
Locked bool `json:"locked"`
}
func GetCompletionRatioInfo(name string) CompletionRatioInfo {
name = FormatMatchingModelName(name)
if strings.Contains(name, "/") {
if ratio, ok := completionRatioMap.Get(name); ok {
return CompletionRatioInfo{
Ratio: ratio,
Locked: false,
}
}
}
hardCodedRatio, locked := getHardcodedCompletionModelRatio(name)
if locked {
return CompletionRatioInfo{
Ratio: hardCodedRatio,
Locked: true,
}
}
if ratio, ok := completionRatioMap.Get(name); ok {
return CompletionRatioInfo{
Ratio: ratio,
Locked: false,
}
}
return CompletionRatioInfo{
Ratio: hardCodedRatio,
Locked: false,
}
}
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")

View File

@@ -21,7 +21,7 @@ import { defineConfig } from 'i18next-cli';
/** @type {import('i18next-cli').I18nextToolkitConfig} */
export default defineConfig({
locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
locales: ['zh-CN', 'zh-TW', 'en', 'fr', 'ru', 'ja', 'vi'],
extract: {
input: ['src/**/*.{js,jsx,ts,tsx}'],
ignore: ['src/i18n/**/*'],

View File

@@ -37,6 +37,7 @@ import {
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useLocation } from 'react-router-dom';
import { normalizeLanguage } from '../../i18n/language';
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
@@ -115,7 +116,11 @@ const PageLayout = () => {
}
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
i18n.changeLanguage(savedLang);
const normalizedLang = normalizeLanguage(savedLang);
if (normalizedLang !== savedLang) {
localStorage.setItem('i18nextLng', normalizedLang);
}
i18n.changeLanguage(normalizedLang);
}
}, [i18n]);

View File

@@ -95,19 +95,19 @@ const RatioSetting = () => {
return (
<Spin spinning={loading} size='large'>
{/* 模型倍率设置以及可视化编辑器 */}
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='card'>
<Tabs type='card' defaultActiveKey='visual'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>

View File

@@ -23,6 +23,7 @@ import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { API, showSuccess, showError } from "../../../../helpers";
import { UserContext } from "../../../../context/User";
import { normalizeLanguage } from "../../../../i18n/language";
// Language options with native names
const languageOptions = [
@@ -39,7 +40,7 @@ const PreferencesSettings = ({ t }) => {
const { i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [currentLanguage, setCurrentLanguage] = useState(
i18n.language || "zh-CN",
normalizeLanguage(i18n.language) || "zh-CN",
);
const [loading, setLoading] = useState(false);
@@ -49,8 +50,7 @@ const PreferencesSettings = ({ t }) => {
try {
const settings = JSON.parse(userState.user.setting);
if (settings.language) {
// Normalize legacy "zh" to "zh-CN" for backward compatibility
const lang = settings.language === "zh" ? "zh-CN" : settings.language;
const lang = normalizeLanguage(settings.language);
setCurrentLanguage(lang);
// Sync i18n with saved preference
if (i18n.language !== lang) {

View File

@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { reducer, initialState } from './reducer';
import { normalizeLanguage } from '../../i18n/language';
export const UserContext = React.createContext({
state: initialState,
@@ -35,8 +36,9 @@ export const UserProvider = ({ children }) => {
if (state.user?.setting) {
try {
const settings = JSON.parse(state.user.setting);
if (settings.language && settings.language !== i18n.language) {
i18n.changeLanguage(settings.language);
const normalizedLanguage = normalizeLanguage(settings.language);
if (normalizedLanguage && normalizedLanguage !== i18n.language) {
i18n.changeLanguage(normalizedLanguage);
}
} catch (e) {
// Ignore parse errors

View File

@@ -24,6 +24,7 @@ import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useSetTheme, useTheme, useActualTheme } from '../../context/Theme';
import { getLogo, getSystemName, API, showSuccess } from '../../helpers';
import { normalizeLanguage } from '../../i18n/language';
import { useIsMobile } from './useIsMobile';
import { useSidebarCollapsed } from './useSidebarCollapsed';
import { useMinimumLoadingTime } from './useMinimumLoadingTime';
@@ -36,7 +37,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const [logoLoaded, setLogoLoaded] = useState(false);
const navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language);
const [currentLang, setCurrentLang] = useState(normalizeLanguage(i18n.language));
const location = useLocation();
const loading = statusState?.status === undefined;
@@ -118,12 +119,13 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
// Language change effect
useEffect(() => {
const handleLanguageChanged = (lng) => {
setCurrentLang(lng);
const normalizedLang = normalizeLanguage(lng);
setCurrentLang(normalizedLang);
try {
const iframe = document.querySelector('iframe');
const cw = iframe && iframe.contentWindow;
if (cw) {
cw.postMessage({ lang: lng }, '*');
cw.postMessage({ lang: normalizedLang }, '*');
}
} catch (e) {
// Silently ignore cross-origin or access errors

View File

@@ -28,12 +28,14 @@ import zhTWTranslation from './locales/zh-TW.json';
import ruTranslation from './locales/ru.json';
import jaTranslation from './locales/ja.json';
import viTranslation from './locales/vi.json';
import { supportedLanguages } from './language';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
load: 'currentOnly',
supportedLngs: supportedLanguages,
resources: {
en: enTranslation,
'zh-CN': zhCNTranslation,

61
web/src/i18n/language.js vendored Normal file
View File

@@ -0,0 +1,61 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export const supportedLanguages = [
'zh-CN',
'zh-TW',
'en',
'fr',
'ru',
'ja',
'vi',
];
export const normalizeLanguage = (language) => {
if (!language) {
return language;
}
const normalized = language.trim().replace(/_/g, '-');
const lower = normalized.toLowerCase();
if (
lower === 'zh' ||
lower === 'zh-cn' ||
lower === 'zh-sg' ||
lower.startsWith('zh-hans')
) {
return 'zh-CN';
}
if (
lower === 'zh-tw' ||
lower === 'zh-hk' ||
lower === 'zh-mo' ||
lower.startsWith('zh-hant')
) {
return 'zh-TW';
}
const matchedLanguage = supportedLanguages.find(
(supportedLanguage) => supportedLanguage.toLowerCase() === lower,
);
return matchedLanguage || normalized;
};

View File

@@ -1718,6 +1718,7 @@
"未获取到授权码": "Authorization code not obtained",
"未设置": "Not set",
"未设置倍率模型": "Models without ratio settings",
"未设置价格模型": "Models without price settings",
"未设置路径": "",
"未配置模型": "No model configured",
"未配置的模型列表": "Models not configured",
@@ -2850,8 +2851,8 @@
"输入 OIDC 的 Userinfo Endpoint": "Enter OIDC Userinfo Endpoint",
"输入IP地址后回车8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
"输入JSON对象": "Enter JSON Object",
"输入价格": "Enter Price",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Enter price: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入价格": "Input Price",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Input Price: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入你注册的 LinuxDO OAuth APP 的 ID": "Enter the ID of your registered LinuxDO OAuth APP",
"输入你的账户名{{username}}以确认删除": "Enter your account name{{username}} to confirm deletion",
"输入域名后回车": "Enter domain and press Enter",
@@ -3183,6 +3184,52 @@
"默认折叠侧边栏": "Default collapse sidebar",
"默认测试模型": "Default Test Model",
"默认用户消息": "Default User Message",
"默认补全倍率": "Default completion ratio"
"默认补全倍率": "Default completion ratio",
"分组相关设置": "Group Related Settings",
"保存分组相关设置": "Save Group Related Settings",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "This page only shows models without base pricing. After saving, configured models will be removed from this list automatically.",
"没有未设置定价的模型": "No unpriced models",
"当前没有未设置定价的模型": "There are currently no models without pricing",
"模型计费编辑器": "Model Pricing Editor",
"价格摘要": "Price Summary",
"当前提示": "Current Notes",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "This editor uses prices by default and converts them back into the ratio JSON required by the backend when saved.",
"当前未启用,需要时再打开即可。": "This field is currently disabled. Enable it when needed.",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "The fields below show which backend values will be written after saving, so you can keep them aligned with the raw JSON editors.",
"补全价格已锁定": "Completion price is locked",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Backend fixed ratio: {{ratio}}. This field only displays the converted price.",
"这些价格都是可选项,不填也可以。": "All of these prices are optional and can be left empty.",
"请先开启并填写音频输入价格。": "Enable and fill in the audio input price first.",
"输入模型名称,例如 gpt-4.1": "Enter a model name, for example gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "This model currently has both per-request pricing and ratio-based pricing. Saving will overwrite them according to the current billing mode.",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "This model has derived ratios without an explicit input ratio. Once you fill in the input price, they will be converted into price fields automatically.",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "For per-token billing, fill in the input price before saving other price fields.",
"填写音频补全价格前,需要先填写音频输入价格。": "Fill in the audio input price before setting the audio completion price.",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Model {{name}} is missing an input price, so the ratios for completion, cache, image, and audio pricing cannot be calculated.",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Model {{name}} is missing an audio input price, so the audio completion ratio cannot be calculated.",
"批量应用当前模型价格": "Batch Apply Current Model Pricing",
"请先选择一个作为模板的模型": "Please select a model to use as the template first",
"请先勾选需要批量设置的模型": "Please select the models you want to update in batch first",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Applied the pricing configuration of model {{name}} to {{count}} models in batch",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "The pricing configuration of the currently edited model {{name}} will be applied to the {{count}} selected models.",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Useful for pricing model variants together, for example syncing the pricing of gpt-5.1 to gpt-5.1-high, gpt-5.1-low, and similar models.",
"已勾选": "Selected",
"当前编辑": "Editing",
"已勾选 {{count}} 个模型": "{{count}} models selected",
"计费方式": "Billing Mode",
"未设置价格": "Price not set",
"保存预览": "Save Preview",
"基础价格": "Base Pricing",
"扩展价格": "Additional Pricing",
"额外价格项": "Additional price items",
"补全价格": "Completion Price",
"提示缓存价格": "Input Cache Read Price",
"缓存创建价格": "Input Cache Creation Price",
"图片输入价格": "Image Input Price",
"音频输入价格": "Audio Input Price",
"音频补全价格": "Audio Completion Price",
"适合 MJ / 任务类等按次收费模型。": "Suitable for MJ and other task-based models billed per request.",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "This model's completion ratio is fixed to {{ratio}} by the backend. The completion price cannot be changed here.",
"空": "Empty"
}
}

View File

@@ -1709,6 +1709,7 @@
"未获取到授权码": "Code d'autorisation non obtenu",
"未设置": "Non défini",
"未设置倍率模型": "Modèles sans ratio",
"未设置价格模型": "Modèles sans prix",
"未设置路径": "",
"未配置模型": "Aucun modèle configuré",
"未配置的模型列表": "Modèles non configurés",
@@ -2832,8 +2833,8 @@
"输入 OIDC 的 Userinfo Endpoint": "Saisir le point de terminaison des informations utilisateur OIDC",
"输入IP地址后回车8.8.8.8": "Saisissez l'adresse IP et appuyez sur Entrée, par exemple : 8.8.8.8",
"输入JSON对象": "Saisir l'objet JSON",
"输入价格": "Saisir le prix",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Saisir le prix : {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入价格": "Prix d'entrée",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入你注册的 LinuxDO OAuth APP 的 ID": "Saisir l'ID de votre application OAuth LinuxDO enregistrée",
"输入你的账户名{{username}}以确认删除": "Saisissez votre nom de compte{{username}}pour confirmer la suppression",
"输入域名后回车": "Saisissez le domaine et appuyez sur Entrée",
@@ -3152,6 +3153,52 @@
"默认折叠侧边栏": "Réduire la barre latérale par défaut",
"默认测试模型": "Modèle de test par défaut",
"默认用户消息": "Bonjour",
"默认补全倍率": "Taux de complétion par défaut"
"默认补全倍率": "Taux de complétion par défaut",
"分组相关设置": "Paramètres liés aux groupes",
"保存分组相关设置": "Enregistrer les paramètres liés aux groupes",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "Cette page n'affiche que les modèles sans prix ou ratio de base. Après enregistrement, ils seront retirés automatiquement de cette liste.",
"没有未设置定价的模型": "Aucun modèle sans prix",
"当前没有未设置定价的模型": "Il n'y a actuellement aucun modèle sans prix",
"模型计费编辑器": "Éditeur de tarification des modèles",
"价格摘要": "Résumé des prix",
"当前提示": "Informations actuelles",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "Cette interface utilise les prix par défaut et les reconvertit automatiquement en JSON de ratios requis par le backend lors de l'enregistrement.",
"当前未启用,需要时再打开即可。": "Ce champ est actuellement désactivé. Activez-le si nécessaire.",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "Les champs backend écrits après l'enregistrement sont affichés ci-dessous afin de rester cohérents avec les éditeurs JSON bruts.",
"补全价格已锁定": "Le prix de complétion est verrouillé",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Ratio fixé par le backend : {{ratio}}. Ce champ affiche uniquement le prix converti.",
"这些价格都是可选项,不填也可以。": "Tous ces prix sont optionnels et peuvent être laissés vides.",
"请先开启并填写音频输入价格。": "Activez et renseignez d'abord le prix d'entrée audio.",
"输入模型名称,例如 gpt-4.1": "Saisissez un nom de modèle, par exemple gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "Ce modèle possède actuellement à la fois une tarification par requête et une configuration par ratio. L'enregistrement écrasera selon le mode de facturation actuel.",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "Ce modèle contient des ratios étendus sans ratio d'entrée explicite. Après saisie du prix d'entrée, ils seront convertis automatiquement en champs de prix.",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "En facturation au volume, il faut d'abord renseigner le prix d'entrée avant d'enregistrer les autres prix.",
"填写音频补全价格前,需要先填写音频输入价格。": "Renseignez d'abord le prix d'entrée audio avant de définir le prix de complétion audio.",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Le modèle {{name}} n'a pas de prix d'entrée, impossible de calculer les ratios correspondants pour la complétion, le cache, les images et l'audio.",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Le modèle {{name}} n'a pas de prix d'entrée audio, impossible de calculer le ratio de complétion audio.",
"批量应用当前模型价格": "Appliquer en lot le prix du modèle actuel",
"请先选择一个作为模板的模型": "Veuillez d'abord choisir un modèle comme modèle de référence",
"请先勾选需要批量设置的模型": "Veuillez d'abord sélectionner les modèles à configurer en lot",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "La configuration tarifaire du modèle {{name}} a été appliquée à {{count}} modèles en lot",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "La configuration tarifaire du modèle actuellement édité {{name}} sera appliquée aux {{count}} modèles sélectionnés.",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Pratique pour tarifer ensemble des variantes d'un même modèle, par exemple synchroniser le prix de gpt-5.1 vers gpt-5.1-high, gpt-5.1-low et autres variantes similaires.",
"已勾选": "Sélectionné",
"当前编辑": "En cours d'édition",
"已勾选 {{count}} 个模型": "{{count}} modèles sélectionnés",
"计费方式": "Mode de facturation",
"未设置价格": "Prix non défini",
"保存预览": "Aperçu avant enregistrement",
"基础价格": "Prix de base",
"扩展价格": "Prix supplémentaires",
"额外价格项": "Éléments de prix supplémentaires",
"补全价格": "Prix de complétion",
"提示缓存价格": "Prix de lecture du cache d'entrée",
"缓存创建价格": "Prix de création du cache d'entrée",
"图片输入价格": "Prix d'entrée image",
"音频输入价格": "Prix d'entrée audio",
"音频补全价格": "Prix de complétion audio",
"适合 MJ / 任务类等按次收费模型。": "Convient aux modèles MJ et autres modèles facturés à la requête.",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "Le ratio de complétion de ce modèle est fixé à {{ratio}} par le backend. Le prix de complétion ne peut pas être modifié ici.",
"空": "Vide"
}
}

View File

@@ -1692,6 +1692,7 @@
"未获取到授权码": "認可コードの取得に失敗しました",
"未设置": "未設定",
"未设置倍率模型": "倍率が未設定のモデル",
"未设置价格模型": "価格が未設定のモデル",
"未设置路径": "",
"未配置模型": "未設定モデル",
"未配置的模型列表": "未設定のモデルリスト",
@@ -2813,7 +2814,7 @@
"输入 OIDC 的 Userinfo Endpoint": "OIDCのUserinfo Endpointを入力してください",
"输入IP地址后回车8.8.8.8": "IPアドレスを入力してEnter8.8.8.8",
"输入JSON对象": "JSONオブジェクトを入力してください",
"输入价格": "料金を入力してください",
"输入价格": "入力価格",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "入力料金:{{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入你注册的 LinuxDO OAuth APP 的 ID": "登録したLinuxDO OAuth APPのIDを入力してください",
"输入你的账户名{{username}}以确认删除": "削除確認: アカウント名{{username}}を入力してください",
@@ -3133,6 +3134,52 @@
"默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ",
"默认测试模型": "デフォルトテストモデル",
"默认用户消息": "こんにちは",
"默认补全倍率": "デフォルト補完倍率"
"默认补全倍率": "デフォルト補完倍率",
"分组相关设置": "グループ関連設定",
"保存分组相关设置": "グループ関連設定を保存",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "このページには価格または基本倍率が未設定のモデルのみ表示され、設定後は一覧から自動的に消えます。",
"没有未设置定价的模型": "価格未設定のモデルはありません",
"当前没有未设置定价的模型": "現在、価格未設定のモデルはありません",
"模型计费编辑器": "モデル料金エディタ",
"价格摘要": "価格概要",
"当前提示": "現在のヒント",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "この画面では価格を基準に入力し、保存時にバックエンドが必要とする倍率 JSON に自動変換されます。",
"当前未启用,需要时再打开即可。": "この項目は現在無効です。必要なときに有効にしてください。",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "保存後にこのモデルでどのバックエンド項目に書き込まれるかを以下に表示します。元の JSON エディタとの整合確認に便利です。",
"补全价格已锁定": "補完価格はロックされています",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "バックエンド固定倍率: {{ratio}}。この項目は変換後の価格表示のみです。",
"这些价格都是可选项,不填也可以。": "これらの価格はすべて任意項目で、未入力でも構いません。",
"请先开启并填写音频输入价格。": "先に音声入力価格を有効にして入力してください。",
"输入模型名称,例如 gpt-4.1": "モデル名を入力してください。例: gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "このモデルには従量価格と倍率設定が同時に存在しています。保存すると現在の課金方式に従って上書きされます。",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "このモデルには入力倍率が明示されていない拡張倍率があります。入力価格を設定すると価格項目へ自動換算されます。",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "従量課金では、他の価格項目を保存する前に入力価格を設定する必要があります。",
"填写音频补全价格前,需要先填写音频输入价格。": "音声補完価格を入力する前に、先に音声入力価格を入力してください。",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "モデル {{name}} に入力価格がないため、補完・キャッシュ・画像・音声価格に対応する倍率を計算できません。",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "モデル {{name}} に音声入力価格がないため、音声補完倍率を計算できません。",
"批量应用当前模型价格": "現在のモデル価格を一括適用",
"请先选择一个作为模板的模型": "まずテンプレートとして使うモデルを選択してください",
"请先勾选需要批量设置的模型": "一括設定したいモデルを先に選択してください",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "モデル {{name}} の価格設定を {{count}} 個のモデルに一括適用しました",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "現在編集中のモデル {{name}} の価格設定を、選択済みの {{count}} 個のモデルに一括適用します。",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "同系列モデルをまとめて価格設定するのに適しています。例えば gpt-5.1 の価格を gpt-5.1-high、gpt-5.1-low などへ一括同期できます。",
"已勾选": "選択済み",
"当前编辑": "編集中",
"已勾选 {{count}} 个模型": "{{count}} 個のモデルを選択済み",
"计费方式": "課金方式",
"未设置价格": "価格未設定",
"保存预览": "保存プレビュー",
"基础价格": "基本価格",
"扩展价格": "追加価格",
"额外价格项": "追加価格項目",
"补全价格": "補完価格",
"提示缓存价格": "入力キャッシュ読み取り価格",
"缓存创建价格": "入力キャッシュ作成価格",
"图片输入价格": "画像入力価格",
"音频输入价格": "音声入力価格",
"音频补全价格": "音声補完価格",
"适合 MJ / 任务类等按次收费模型。": "MJ やその他のリクエスト単位課金モデルに適しています。",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "このモデルの補完倍率はバックエンドで {{ratio}} に固定されています。ここでは補完価格を変更できません。",
"空": "空"
}
}

View File

@@ -1721,6 +1721,7 @@
"未获取到授权码": "Код авторизации не получен",
"未设置": "Не настроено",
"未设置倍率模型": "Модели с неустановленным множителем",
"未设置价格模型": "Модели с неустановленной ценой",
"未设置路径": "",
"未配置模型": "Ненастроенные модели",
"未配置的模型列表": "Список ненастроенных моделей",
@@ -2846,7 +2847,7 @@
"输入 OIDC 的 Userinfo Endpoint": "Введите Userinfo Endpoint OIDC",
"输入IP地址后回车8.8.8.8": "Введите IP-адрес и нажмите Enter, например: 8.8.8.8",
"输入JSON对象": "Введите JSON-объект",
"输入价格": "Введите цену",
"输入价格": "Цена ввода",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Цена ввода: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入你注册的 LinuxDO OAuth APP 的 ID": "Введите ID вашего зарегистрированного LinuxDO OAuth APP",
"输入你的账户名{{username}}以确认删除": "Введите имя вашей учётной записи {{username}} для подтверждения удаления",
@@ -3166,6 +3167,52 @@
"默认折叠侧边栏": "Сворачивать боковую панель по умолчанию",
"默认测试模型": "Модель для тестирования по умолчанию",
"默认用户消息": "Здравствуйте",
"默认补全倍率": "Default completion ratio"
"默认补全倍率": "Default completion ratio",
"分组相关设置": "Настройки, связанные с группами",
"保存分组相关设置": "Сохранить настройки, связанные с группами",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "На этой странице показаны только модели без цены или базового коэффициента. После сохранения они будут автоматически удалены из списка.",
"没有未设置定价的模型": "Нет моделей без цены",
"当前没有未设置定价的模型": "Сейчас нет моделей без цены",
"模型计费编辑器": "Редактор тарификации моделей",
"价格摘要": "Сводка цен",
"当前提示": "Текущие подсказки",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "В этом интерфейсе значения по умолчанию задаются через цены, а при сохранении они автоматически преобразуются в JSON коэффициентов, требуемый backend.",
"当前未启用,需要时再打开即可。": "Это поле сейчас отключено. Включите его при необходимости.",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "Ниже показано, какие backend-поля будут записаны после сохранения, чтобы их было удобно сверять с редакторами исходного JSON.",
"补全价格已锁定": "Цена завершения заблокирована",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Фиксированный backend-коэффициент: {{ratio}}. Это поле только показывает вычисленную цену.",
"这些价格都是可选项,不填也可以。": "Все эти цены необязательны и могут быть оставлены пустыми.",
"请先开启并填写音频输入价格。": "Сначала включите и заполните цену аудио-ввода.",
"输入模型名称,例如 gpt-4.1": "Введите имя модели, например gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "У этой модели одновременно задана цена за запрос и конфигурация коэффициентов. При сохранении данные будут перезаписаны согласно текущему режиму тарификации.",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "У этой модели есть дополнительные коэффициенты без явно заданного входного коэффициента; после ввода входной цены они будут автоматически преобразованы в ценовые поля.",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "При тарификации по объему сначала нужно указать входную цену, чтобы сохранить остальные ценовые поля.",
"填写音频补全价格前,需要先填写音频输入价格。": "Перед указанием цены аудио-завершения сначала задайте цену аудио-ввода.",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "У модели {{name}} отсутствует входная цена, поэтому невозможно вычислить коэффициенты для завершения, кэша, изображений и аудио.",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "У модели {{name}} отсутствует цена аудио-ввода, поэтому невозможно вычислить коэффициент аудио-завершения.",
"批量应用当前模型价格": "Массово применить цену текущей модели",
"请先选择一个作为模板的模型": "Сначала выберите модель-шаблон",
"请先勾选需要批量设置的模型": "Сначала отметьте модели для массовой настройки",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Ценовая конфигурация модели {{name}} массово применена к {{count}} моделям",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "Ценовая конфигурация редактируемой модели {{name}} будет применена к {{count}} выбранным моделям.",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Подходит для совместной настройки цен вариантов одной модели, например синхронизации цены gpt-5.1 с gpt-5.1-high, gpt-5.1-low и похожими моделями.",
"已勾选": "Выбрано",
"当前编辑": "Текущее редактирование",
"已勾选 {{count}} 个模型": "Выбрано моделей: {{count}}",
"计费方式": "Режим тарификации",
"未设置价格": "Цена не задана",
"保存预览": "Предпросмотр сохранения",
"基础价格": "Базовые цены",
"扩展价格": "Дополнительные цены",
"额外价格项": "Дополнительные ценовые позиции",
"补全价格": "Цена завершения",
"提示缓存价格": "Цена чтения входного кеша",
"缓存创建价格": "Цена создания входного кеша",
"图片输入价格": "Цена входного изображения",
"音频输入价格": "Цена входного аудио",
"音频补全价格": "Цена завершения аудио",
"适合 MJ / 任务类等按次收费模型。": "Подходит для MJ и других моделей с тарификацией за запрос.",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "Коэффициент завершения для этой модели зафиксирован на уровне {{ratio}} на бэкенде. Цену завершения нельзя изменить здесь.",
"空": "Пусто"
}
}

View File

@@ -1693,6 +1693,7 @@
"未获取到授权码": "Không lấy được mã ủy quyền",
"未设置": "Chưa thiết lập",
"未设置倍率模型": "Mô hình không có cài đặt tỷ lệ",
"未设置价格模型": "Mô hình chưa thiết lập giá",
"未设置路径": "",
"未配置模型": "Không có mô hình được cấu hình",
"未配置的模型列表": "Mô hình chưa được cấu hình",
@@ -3291,9 +3292,9 @@
"输入 OIDC 的 Userinfo Endpoint": "Nhập Userinfo Endpoint của OIDC",
"输入IP地址后回车8.8.8.8": "Nhập địa chỉ IP và nhấn Enter, ví dụ: 8.8.8.8",
"输入JSON对象": "Nhập đối tượng JSON",
"输入价格": "Nhập giá",
"输入价格": "Giá đầu vào",
"输入价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu vào: {{symbol}}{{price}} / 1M tokens",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Nhập giá: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}": "Giá đầu vào: {{symbol}}{{price}} / 1M tokens{{audioPrice}}",
"输入你注册的 LinuxDO OAuth APP 的 ID": "Nhập ID của LinuxDO OAuth APP bạn đã đăng ký",
"输入你的账户名{{username}}以确认删除": "Nhập tên tài khoản của bạn {{username}} để xác nhận xóa",
"输入倍率": "Tỷ lệ đầu vào",
@@ -3705,6 +3706,52 @@
"默认折叠侧边栏": "Mặc định thu gọn thanh bên",
"默认测试模型": "Mô hình kiểm tra mặc định",
"默认用户消息": "Xin chào",
"默认补全倍率": "Tỷ lệ hoàn thành mặc định"
"默认补全倍率": "Tỷ lệ hoàn thành mặc định",
"分组相关设置": "Cài đặt liên quan đến nhóm",
"保存分组相关设置": "Lưu cài đặt liên quan đến nhóm",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "Trang này chỉ hiển thị các mô hình chưa thiết lập giá hoặc tỷ lệ cơ bản. Sau khi lưu, chúng sẽ tự động biến mất khỏi danh sách.",
"没有未设置定价的模型": "Không có mô hình chưa thiết lập giá",
"当前没有未设置定价的模型": "Hiện không có mô hình nào chưa thiết lập giá",
"模型计费编辑器": "Trình chỉnh sửa giá mô hình",
"价格摘要": "Tóm tắt giá",
"当前提示": "Gợi ý hiện tại",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "Giao diện này mặc định nhập theo giá, khi lưu sẽ tự động quy đổi lại thành JSON tỷ lệ mà backend yêu cầu.",
"当前未启用,需要时再打开即可。": "Trường này hiện đang tắt. Hãy bật khi cần.",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "Bên dưới hiển thị các trường backend sẽ được ghi sau khi lưu, giúp bạn dễ đối chiếu với ô chỉnh sửa JSON gốc.",
"补全价格已锁定": "Giá hoàn thành đã bị khóa",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "Tỷ lệ cố định từ backend: {{ratio}}. Trường này chỉ hiển thị giá sau khi quy đổi.",
"这些价格都是可选项,不填也可以。": "Tất cả các mức giá này đều là tùy chọn và có thể để trống.",
"请先开启并填写音频输入价格。": "Hãy bật và điền giá đầu vào âm thanh trước.",
"输入模型名称,例如 gpt-4.1": "Nhập tên mô hình, ví dụ gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "Mô hình này hiện đồng thời có giá theo lượt gọi và cấu hình tỷ lệ. Khi lưu, dữ liệu sẽ bị ghi đè theo chế độ tính phí hiện tại.",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "Mô hình này có các tỷ lệ mở rộng mà chưa đặt rõ tỷ lệ đầu vào; sau khi điền giá đầu vào, chúng sẽ tự động được quy đổi thành trường giá.",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "Ở chế độ tính phí theo lượng, cần điền giá đầu vào trước thì mới lưu được các mục giá khác.",
"填写音频补全价格前,需要先填写音频输入价格。": "Trước khi nhập giá hoàn thành âm thanh, hãy nhập giá đầu vào âm thanh trước.",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "Mô hình {{name}} thiếu giá đầu vào, nên không thể tính tỷ lệ tương ứng cho giá hoàn thành, bộ nhớ đệm, hình ảnh và âm thanh.",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "Mô hình {{name}} thiếu giá đầu vào âm thanh, nên không thể tính tỷ lệ hoàn thành âm thanh.",
"批量应用当前模型价格": "Áp dụng hàng loạt giá của mô hình hiện tại",
"请先选择一个作为模板的模型": "Vui lòng chọn trước một mô hình làm mẫu",
"请先勾选需要批量设置的模型": "Vui lòng chọn các mô hình cần thiết lập hàng loạt trước",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "Đã áp dụng hàng loạt cấu hình giá của mô hình {{name}} cho {{count}} mô hình",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "Cấu hình giá của mô hình đang chỉnh sửa {{name}} sẽ được áp dụng hàng loạt cho {{count}} mô hình đã chọn.",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "Phù hợp để định giá cùng lúc các biến thể cùng dòng, ví dụ đồng bộ giá của gpt-5.1 sang gpt-5.1-high, gpt-5.1-low và các mô hình tương tự.",
"已勾选": "Đã chọn",
"当前编辑": "Đang chỉnh sửa",
"已勾选 {{count}} 个模型": "Đã chọn {{count}} mô hình",
"计费方式": "Chế độ tính phí",
"未设置价格": "Chưa thiết lập giá",
"保存预览": "Xem trước khi lưu",
"基础价格": "Giá cơ bản",
"扩展价格": "Giá mở rộng",
"额外价格项": "Mục giá bổ sung",
"补全价格": "Giá hoàn thành",
"提示缓存价格": "Giá đọc bộ nhớ đệm đầu vào",
"缓存创建价格": "Giá tạo bộ nhớ đệm đầu vào",
"图片输入价格": "Giá đầu vào hình ảnh",
"音频输入价格": "Giá đầu vào âm thanh",
"音频补全价格": "Giá hoàn thành âm thanh",
"适合 MJ / 任务类等按次收费模型。": "Phù hợp cho MJ và các mô hình tính phí theo lượt gọi tương tự.",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "Tỷ lệ hoàn thành của mô hình này được backend cố định ở {{ratio}}. Không thể chỉnh giá hoàn thành tại đây.",
"空": "Trống"
}
}

View File

@@ -1368,6 +1368,7 @@
"未获取到授权码": "未获取到授权码",
"未设置": "未设置",
"未设置倍率模型": "未设置倍率模型",
"未设置价格模型": "未设置价格模型",
"未配置模型": "未配置模型",
"未配置的模型列表": "未配置的模型列表",
"本地": "本地",
@@ -2813,6 +2814,49 @@
"缓存写": "缓存写",
"写": "写",
"根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。": "根据 Anthropic 协定,/v1/messages 的输入 tokens 仅统计非缓存输入,不包含缓存读取与缓存写入 tokens。",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加"
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加",
"分组相关设置": "分组相关设置",
"保存分组相关设置": "保存分组相关设置",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出",
"没有未设置定价的模型": "没有未设置定价的模型",
"当前没有未设置定价的模型": "当前没有未设置定价的模型",
"模型计费编辑器": "模型计费编辑器",
"价格摘要": "价格摘要",
"当前提示": "当前提示",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。",
"当前未启用,需要时再打开即可。": "当前未启用,需要时再打开即可。",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。",
"补全价格已锁定": "补全价格已锁定",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。",
"这些价格都是可选项,不填也可以。": "这些价格都是可选项,不填也可以。",
"请先开启并填写音频输入价格。": "请先开启并填写音频输入价格。",
"输入模型名称,例如 gpt-4.1": "输入模型名称,例如 gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "按量计费下需要先填写输入价格,才能保存其它价格项。",
"填写音频补全价格前,需要先填写音频输入价格。": "填写音频补全价格前,需要先填写音频输入价格。",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率",
"批量应用当前模型价格": "批量应用当前模型价格",
"请先选择一个作为模板的模型": "请先选择一个作为模板的模型",
"请先勾选需要批量设置的模型": "请先勾选需要批量设置的模型",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。",
"已勾选": "已勾选",
"当前编辑": "当前编辑",
"已勾选 {{count}} 个模型": "已勾选 {{count}} 个模型",
"基础价格": "基础价格",
"扩展价格": "扩展价格",
"额外价格项": "额外价格项",
"补全价格": "补全价格",
"提示缓存价格": "提示缓存价格",
"缓存创建价格": "缓存创建价格",
"图片输入价格": "图片输入价格",
"音频输入价格": "音频输入价格",
"音频补全价格": "音频补全价格",
"适合 MJ / 任务类等按次收费模型。": "适合 MJ / 任务类等按次收费模型。",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。",
"空": "空"
}
}

View File

@@ -1372,6 +1372,7 @@
"未获取到授权码": "未獲取到授權碼",
"未设置": "未設定",
"未设置倍率模型": "未設定倍率模型",
"未设置价格模型": "未設定價格模型",
"未配置模型": "未設定模型",
"未配置的模型列表": "未設定的模型列表",
"本地": "本地",
@@ -2806,6 +2807,49 @@
"自动生成:": "自動生成:",
"请先填写服务器地址,以自动生成完整的端点 URL": "請先填寫伺服器位址,以自動生成完整的端點 URL",
"端点 URL 必须是完整地址(以 http:// 或 https:// 开头)": "端點 URL 必須是完整位址(以 http:// 或 https:// 開頭)",
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按下 Enter 鍵可將「{{name}}」作為自訂模型名稱新增"
"未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加": "未匹配到模型,按下 Enter 鍵可將「{{name}}」作為自訂模型名稱新增",
"分组相关设置": "分組相關設定",
"保存分组相关设置": "保存分組相關設定",
"此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出": "此頁面僅顯示未設定價格或基礎倍率的模型,設定後會自動從列表中移出",
"没有未设置定价的模型": "沒有未設定定價的模型",
"当前没有未设置定价的模型": "目前沒有未設定定價的模型",
"模型计费编辑器": "模型計費編輯器",
"价格摘要": "價格摘要",
"当前提示": "目前提示",
"这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。": "這個介面預設按價格填寫,儲存時會自動換算回後端需要的倍率 JSON。",
"当前未启用,需要时再打开即可。": "目前未啟用,需要時再開啟即可。",
"下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。": "下方會顯示此模型儲存後將寫入哪些後端欄位,方便與原始 JSON 編輯框保持一致。",
"补全价格已锁定": "補全價格已鎖定",
"后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。": "後端固定倍率:{{ratio}}。此欄位僅展示換算後的價格。",
"这些价格都是可选项,不填也可以。": "這些價格都是可選項,不填也可以。",
"请先开启并填写音频输入价格。": "請先開啟並填寫音訊輸入價格。",
"输入模型名称,例如 gpt-4.1": "輸入模型名稱,例如 gpt-4.1",
"当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。": "目前模型同時存在按次價格與倍率配置,儲存時會依目前計費方式覆蓋。",
"当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。": "目前模型存在未明確設定輸入倍率的擴展倍率;填寫輸入價格後會自動換算為價格欄位。",
"按量计费下需要先填写输入价格,才能保存其它价格项。": "按量計費下需要先填寫輸入價格,才能儲存其它價格項。",
"填写音频补全价格前,需要先填写音频输入价格。": "填寫音訊補全價格前,需要先填寫音訊輸入價格。",
"模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率": "模型 {{name}} 缺少輸入價格,無法計算補全、快取、圖片與音訊價格對應的倍率",
"模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率": "模型 {{name}} 缺少音訊輸入價格,無法計算音訊補全倍率",
"批量应用当前模型价格": "批量套用目前模型價格",
"请先选择一个作为模板的模型": "請先選擇一個作為範本的模型",
"请先勾选需要批量设置的模型": "請先勾選需要批量設定的模型",
"已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型": "已將模型 {{name}} 的價格配置批量套用到 {{count}} 個模型",
"将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。": "會把目前編輯中的模型 {{name}} 的價格配置,批量套用到已勾選的 {{count}} 個模型。",
"适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。": "適合同系列模型一起定價,例如把 gpt-5.1 的價格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。",
"已勾选": "已勾選",
"当前编辑": "目前編輯",
"已勾选 {{count}} 个模型": "已勾選 {{count}} 個模型",
"基础价格": "基礎價格",
"扩展价格": "擴展價格",
"额外价格项": "額外價格項",
"补全价格": "補全價格",
"提示缓存价格": "提示快取價格",
"缓存创建价格": "快取建立價格",
"图片输入价格": "圖片輸入價格",
"音频输入价格": "音訊輸入價格",
"音频补全价格": "音訊補全價格",
"适合 MJ / 任务类等按次收费模型。": "適合 MJ / 任務類等按次收費模型。",
"该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。": "該模型補全倍率由後端固定為 {{ratio}}。補全價格不能在這裡修改。",
"空": "空"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -265,7 +265,7 @@ export default function GroupRatioSettings(props) {
</Col>
</Row>
</Form>
<Button onClick={onSubmit}>{t('保存分组倍率设置')}</Button>
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
</Spin>
);
}

View File

@@ -18,47 +18,13 @@ For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState } from 'react';
import {
Table,
Button,
Input,
Modal,
Form,
Space,
Typography,
Radio,
Notification,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSearch,
IconSave,
IconBolt,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess } from '../../../helpers';
import { API, showError } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import ModelPricingEditor from './components/ModelPricingEditor';
export default function ModelRatioNotSetEditor(props) {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [visible, setVisible] = useState(false);
const [batchVisible, setBatchVisible] = useState(false);
const [currentModel, setCurrentModel] = useState(null);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [enabledModels, setEnabledModels] = useState([]);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [batchFillType, setBatchFillType] = useState('ratio');
const [batchFillValue, setBatchFillValue] = useState('');
const [batchRatioValue, setBatchRatioValue] = useState('');
const [batchCompletionRatioValue, setBatchCompletionRatioValue] =
useState('');
const { Text } = Typography;
// 定义可选的每页显示条数
const pageSizeOptions = [10, 20, 50, 100];
const getAllEnabledModels = async () => {
try {
@@ -79,540 +45,20 @@ export default function ModelRatioNotSetEditor(props) {
// 获取所有启用的模型
getAllEnabledModels();
}, []);
useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
// 找出所有未设置价格和倍率的模型
const unsetModels = enabledModels.filter((modelName) => {
const hasPrice = modelPrice[modelName] !== undefined;
const hasRatio = modelRatio[modelName] !== undefined;
// 如果模型没有价格或者没有倍率设置,则显示
return !hasPrice && !hasRatio;
});
// 创建模型数据
const modelData = unsetModels.map((name) => ({
name,
price: modelPrice[name] || '',
ratio: modelRatio[name] || '',
completionRatio: completionRatio[name] || '',
}));
setModels(modelData);
// 清空选择
setSelectedRowKeys([]);
} catch (error) {
console.error(t('JSON解析错误:'), error);
}
}, [props.options, enabledModels]);
// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return data.slice(start, end);
};
// 处理页面大小变化
const handlePageSizeChange = (size) => {
setPageSize(size);
// 重新计算当前页,避免数据丢失
const totalPages = Math.ceil(filteredModels.length / size);
if (currentPage > totalPages) {
setCurrentPage(totalPages || 1);
}
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) =>
searchText ? model.name.includes(searchText) : true,
);
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
const SubmitData = async () => {
setLoading(true);
const output = {
ModelPrice: JSON.parse(props.options.ModelPrice || '{}'),
ModelRatio: JSON.parse(props.options.ModelRatio || '{}'),
CompletionRatio: JSON.parse(props.options.CompletionRatio || '{}'),
};
try {
// 数据转换 - 只处理已修改的模型
models.forEach((model) => {
// 只有当用户设置了值时才更新
if (model.price !== '') {
// 如果价格不为空,则转换为浮点数,忽略倍率参数
output.ModelPrice[model.name] = parseFloat(model.price);
} else {
if (model.ratio !== '')
output.ModelRatio[model.name] = parseFloat(model.ratio);
if (model.completionRatio !== '')
output.CompletionRatio[model.name] = parseFloat(
model.completionRatio,
);
}
});
// 准备API请求数组
const finalOutput = {
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
};
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
return API.put('/api/option/', {
key,
value,
});
});
// 批量处理请求
const results = await Promise.all(requestQueue);
// 验证结果
if (requestQueue.length === 1) {
if (results.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (results.includes(undefined)) {
return showError(t('部分保存失败,请重试'));
}
}
// 检查每个请求的结果
for (const res of results) {
if (!res.data.success) {
return showError(res.data.message);
}
}
showSuccess(t('保存成功'));
props.refresh();
// 重新获取未设置的模型
getAllEnabledModels();
} catch (error) {
console.error(t('保存失败:'), error);
showError(t('保存失败,请重试'));
} finally {
setLoading(false);
}
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
},
{
title: t('模型固定价格'),
dataIndex: 'price',
key: 'price',
render: (text, record) => (
<Input
value={text}
placeholder={t('按量计费')}
onChange={(value) => updateModel(record.name, 'price', value)}
/>
),
},
{
title: t('模型倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('模型倍率') : t('输入模型倍率')}
disabled={record.price !== ''}
onChange={(value) => updateModel(record.name, 'ratio', value)}
/>
),
},
{
title: t('补全倍率'),
dataIndex: 'completionRatio',
key: 'completionRatio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('补全倍率') : t('输入补全倍率')}
disabled={record.price !== ''}
onChange={(value) =>
updateModel(record.name, 'completionRatio', value)
}
/>
),
},
];
const updateModel = (name, field, value) => {
if (value !== '' && isNaN(value)) {
showError(t('请输入数字'));
return;
}
setModels((prev) =>
prev.map((model) =>
model.name === name ? { ...model, [field]: value } : model,
),
);
};
const addModel = (values) => {
// 检查模型名称是否存在, 如果存在则拒绝添加
if (models.some((model) => model.name === values.name)) {
showError(t('模型名称已存在'));
return;
}
setModels((prev) => [
{
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
},
...prev,
]);
setVisible(false);
showSuccess(t('添加成功'));
};
// 批量填充功能
const handleBatchFill = () => {
if (selectedRowKeys.length === 0) {
showError(t('请先选择需要批量设置的模型'));
return;
}
if (batchFillType === 'bothRatio') {
if (batchRatioValue === '' || batchCompletionRatioValue === '') {
showError(t('请输入模型倍率和补全倍率'));
return;
}
if (isNaN(batchRatioValue) || isNaN(batchCompletionRatioValue)) {
showError(t('请输入有效的数字'));
return;
}
} else {
if (batchFillValue === '') {
showError(t('请输入填充值'));
return;
}
if (isNaN(batchFillValue)) {
showError(t('请输入有效的数字'));
return;
}
}
// 根据选择的类型批量更新模型
setModels((prev) =>
prev.map((model) => {
if (selectedRowKeys.includes(model.name)) {
if (batchFillType === 'price') {
return {
...model,
price: batchFillValue,
ratio: '',
completionRatio: '',
};
} else if (batchFillType === 'ratio') {
return {
...model,
price: '',
ratio: batchFillValue,
};
} else if (batchFillType === 'completionRatio') {
return {
...model,
price: '',
completionRatio: batchFillValue,
};
} else if (batchFillType === 'bothRatio') {
return {
...model,
price: '',
ratio: batchRatioValue,
completionRatio: batchCompletionRatioValue,
};
}
}
return model;
}),
);
setBatchVisible(false);
Notification.success({
title: t('批量设置成功'),
content: t('已为 {{count}} 个模型设置{{type}}', {
count: selectedRowKeys.length,
type:
batchFillType === 'price'
? t('固定价格')
: batchFillType === 'ratio'
? t('模型倍率')
: batchFillType === 'completionRatio'
? t('补全倍率')
: t('模型倍率和补全倍率'),
}),
duration: 3,
});
};
const handleBatchTypeChange = (value) => {
console.log(t('Changing batch type to:'), value);
setBatchFillType(value);
// 切换类型时清空对应的值
if (value !== 'bothRatio') {
setBatchFillValue('');
} else {
setBatchRatioValue('');
setBatchCompletionRatioValue('');
}
};
const rowSelection = {
selectedRowKeys,
onChange: (selectedKeys) => {
setSelectedRowKeys(selectedKeys);
},
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space className='mt-2'>
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
{t('添加模型')}
</Button>
<Button
icon={<IconBolt />}
type='secondary'
onClick={() => setBatchVisible(true)}
disabled={selectedRowKeys.length === 0}
>
{t('批量设置')} ({selectedRowKeys.length})
</Button>
<Button
type='primary'
icon={<IconSave />}
onClick={SubmitData}
loading={loading}
>
{t('应用更改')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => {
setSearchText(value);
setCurrentPage(1);
}}
style={{ width: 200 }}
/>
</Space>
<Text>
{t('此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除')}
</Text>
<Table
columns={columns}
dataSource={pagedData}
rowSelection={rowSelection}
rowKey='name'
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
onPageSizeChange: handlePageSizeChange,
pageSizeOptions: pageSizeOptions,
showTotal: true,
showSizeChanger: true,
}}
empty={
<div style={{ textAlign: 'center', padding: '20px' }}>
{t('没有未设置的模型')}
</div>
}
/>
</Space>
{/* 添加模型弹窗 */}
<Modal
title={t('添加模型')}
visible={visible}
onCancel={() => setVisible(false)}
onOk={() => {
currentModel && addModel(currentModel);
}}
>
<Form>
<Form.Input
field='name'
label={t('模型名称')}
placeholder='strawberry'
required
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, name: value }))
}
/>
<Form.Switch
field='priceMode'
label={
<>
{t('定价模式')}
{currentModel?.priceMode ? t('固定价格') : t('倍率模式')}
</>
}
onChange={(checked) => {
setCurrentModel((prev) => ({
...prev,
price: '',
ratio: '',
completionRatio: '',
priceMode: checked,
}));
}}
/>
{currentModel?.priceMode ? (
<Form.Input
field='price'
label={t('固定价格(每次)')}
placeholder={t('输入每次价格')}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, price: value }))
}
/>
) : (
<>
<Form.Input
field='ratio'
label={t('模型倍率')}
placeholder={t('输入模型倍率')}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, ratio: value }))
}
/>
<Form.Input
field='completionRatio'
label={t('补全倍率')}
placeholder={t('输入补全价格')}
onChange={(value) =>
setCurrentModel((prev) => ({
...prev,
completionRatio: value,
}))
}
/>
</>
)}
</Form>
</Modal>
{/* 批量设置弹窗 */}
<Modal
title={t('批量设置模型参数')}
visible={batchVisible}
onCancel={() => setBatchVisible(false)}
onOk={handleBatchFill}
width={500}
>
<Form>
<Form.Section text={t('设置类型')}>
<div style={{ marginBottom: '16px' }}>
<Space>
<Radio
checked={batchFillType === 'price'}
onChange={() => handleBatchTypeChange('price')}
>
{t('固定价格')}
</Radio>
<Radio
checked={batchFillType === 'ratio'}
onChange={() => handleBatchTypeChange('ratio')}
>
{t('模型倍率')}
</Radio>
<Radio
checked={batchFillType === 'completionRatio'}
onChange={() => handleBatchTypeChange('completionRatio')}
>
{t('补全倍率')}
</Radio>
<Radio
checked={batchFillType === 'bothRatio'}
onChange={() => handleBatchTypeChange('bothRatio')}
>
{t('模型倍率和补全倍率同时设置')}
</Radio>
</Space>
</div>
</Form.Section>
{batchFillType === 'bothRatio' ? (
<>
<Form.Input
field='batchRatioValue'
label={t('模型倍率值')}
placeholder={t('请输入模型倍率')}
value={batchRatioValue}
onChange={(value) => setBatchRatioValue(value)}
/>
<Form.Input
field='batchCompletionRatioValue'
label={t('补全倍率值')}
placeholder={t('请输入补全倍率')}
value={batchCompletionRatioValue}
onChange={(value) => setBatchCompletionRatioValue(value)}
/>
</>
) : (
<Form.Input
field='batchFillValue'
label={
batchFillType === 'price'
? t('固定价格值')
: batchFillType === 'ratio'
? t('模型倍率值')
: t('补全倍率值')
}
placeholder={t('请输入数值')}
value={batchFillValue}
onChange={(value) => setBatchFillValue(value)}
/>
)}
<Text type='tertiary'>
{t('将为选中的 ')} <Text strong>{selectedRowKeys.length}</Text>{' '}
{t(' 个模型设置相同的值')}
</Text>
<div style={{ marginTop: '8px' }}>
<Text type='tertiary'>
{t('当前设置类型: ')}{' '}
<Text strong>
{batchFillType === 'price'
? t('固定价格')
: batchFillType === 'ratio'
? t('模型倍率')
: batchFillType === 'completionRatio'
? t('补全倍率')
: t('模型倍率和补全倍率')}
</Text>
</Text>
</div>
</Form>
</Modal>
</>
<ModelPricingEditor
options={props.options}
refresh={props.refresh}
candidateModelNames={enabledModels}
filterMode='unset'
allowAddModel={false}
allowDeleteModel={false}
showConflictFilter={false}
listDescription={t(
'此页面仅显示未设置价格或基础倍率的模型,设置后会自动从列表中移出',
)}
emptyTitle={t('没有未设置定价的模型')}
emptyDescription={t('当前没有未设置定价的模型')}
/>
);
}

View File

@@ -17,741 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import {
Table,
Button,
Input,
Modal,
Form,
Space,
RadioGroup,
Radio,
Checkbox,
Tag,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSearch,
IconSave,
IconEdit,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
import { useTranslation } from 'react-i18next';
import React from 'react';
import ModelPricingEditor from './components/ModelPricingEditor';
export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
const [models, setModels] = useState([]);
const [visible, setVisible] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [currentModel, setCurrentModel] = useState(null);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [pricingMode, setPricingMode] = useState('per-token'); // 'per-token' or 'per-request'
const [pricingSubMode, setPricingSubMode] = useState('ratio'); // 'ratio' or 'token-price'
const [conflictOnly, setConflictOnly] = useState(false);
const formRef = useRef(null);
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();
useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
const modelRatio = JSON.parse(props.options.ModelRatio || '{}');
const completionRatio = JSON.parse(props.options.CompletionRatio || '{}');
// 合并所有模型名称
const modelNames = new Set([
...Object.keys(modelPrice),
...Object.keys(modelRatio),
...Object.keys(completionRatio),
]);
const modelData = Array.from(modelNames).map((name) => {
const price = modelPrice[name] === undefined ? '' : modelPrice[name];
const ratio = modelRatio[name] === undefined ? '' : modelRatio[name];
const comp =
completionRatio[name] === undefined ? '' : completionRatio[name];
return {
name,
price,
ratio,
completionRatio: comp,
hasConflict: price !== '' && (ratio !== '' || comp !== ''),
};
});
setModels(modelData);
} catch (error) {
console.error('JSON解析错误:', error);
}
}, [props.options]);
// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return data.slice(start, end);
};
// 在 return 语句之前,先处理过滤和分页逻辑
const filteredModels = models.filter((model) => {
const keywordMatch = searchText ? model.name.includes(searchText) : true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
// 然后基于过滤后的数据计算分页数据
const pagedData = getPagedData(filteredModels, currentPage, pageSize);
const SubmitData = async () => {
setLoading(true);
const output = {
ModelPrice: {},
ModelRatio: {},
CompletionRatio: {},
};
let currentConvertModelName = '';
try {
// 数据转换
models.forEach((model) => {
currentConvertModelName = model.name;
if (model.price !== '') {
// 如果价格不为空,则转换为浮点数,忽略倍率参数
output.ModelPrice[model.name] = parseFloat(model.price);
} else {
if (model.ratio !== '')
output.ModelRatio[model.name] = parseFloat(model.ratio);
if (model.completionRatio !== '')
output.CompletionRatio[model.name] = parseFloat(
model.completionRatio,
);
}
});
// 准备API请求数组
const finalOutput = {
ModelPrice: JSON.stringify(output.ModelPrice, null, 2),
ModelRatio: JSON.stringify(output.ModelRatio, null, 2),
CompletionRatio: JSON.stringify(output.CompletionRatio, null, 2),
};
const requestQueue = Object.entries(finalOutput).map(([key, value]) => {
return API.put('/api/option/', {
key,
value,
});
});
// 批量处理请求
const results = await Promise.all(requestQueue);
// 验证结果
if (requestQueue.length === 1) {
if (results.includes(undefined)) return;
} else if (requestQueue.length > 1) {
if (results.includes(undefined)) {
return showError('部分保存失败,请重试');
}
}
// 检查每个请求的结果
for (const res of results) {
if (!res.data.success) {
return showError(res.data.message);
}
}
showSuccess('保存成功');
props.refresh();
} catch (error) {
console.error('保存失败:', error);
showError('保存失败,请重试');
} finally {
setLoading(false);
}
};
const columns = [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<span>
{text}
{record.hasConflict && (
<Tag color='red' shape='circle' className='ml-2'>
{t('矛盾')}
</Tag>
)}
</span>
),
},
{
title: t('模型固定价格'),
dataIndex: 'price',
key: 'price',
render: (text, record) => (
<Input
value={text}
placeholder={t('按量计费')}
onChange={(value) => updateModel(record.name, 'price', value)}
/>
),
},
{
title: t('模型倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('模型倍率') : t('默认补全倍率')}
disabled={record.price !== ''}
onChange={(value) => updateModel(record.name, 'ratio', value)}
/>
),
},
{
title: t('补全倍率'),
dataIndex: 'completionRatio',
key: 'completionRatio',
render: (text, record) => (
<Input
value={text}
placeholder={record.price !== '' ? t('补全倍率') : t('默认补全倍率')}
disabled={record.price !== ''}
onChange={(value) =>
updateModel(record.name, 'completionRatio', value)
}
/>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
<Button
type='primary'
icon={<IconEdit />}
onClick={() => editModel(record)}
></Button>
<Button
icon={<IconDelete />}
type='danger'
onClick={() => deleteModel(record.name)}
/>
</Space>
),
},
];
const updateModel = (name, field, value) => {
if (isNaN(value)) {
showError('请输入数字');
return;
}
setModels((prev) =>
prev.map((model) => {
if (model.name !== name) return model;
const updated = { ...model, [field]: value };
updated.hasConflict =
updated.price !== '' &&
(updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
};
const deleteModel = (name) => {
setModels((prev) => prev.filter((model) => model.name !== name));
};
const calculateRatioFromTokenPrice = (tokenPrice) => {
return tokenPrice / 2;
};
const calculateCompletionRatioFromPrices = (
modelTokenPrice,
completionTokenPrice,
) => {
if (!modelTokenPrice || modelTokenPrice === '0') {
showError('模型价格不能为0');
return '';
}
return completionTokenPrice / modelTokenPrice;
};
const handleTokenPriceChange = (value) => {
// Use a temporary variable to hold the new state
let newState = {
...(currentModel || {}),
tokenPrice: value,
ratio: 0,
};
if (!isNaN(value) && value !== '') {
const tokenPrice = parseFloat(value);
const ratio = calculateRatioFromTokenPrice(tokenPrice);
newState.ratio = ratio;
}
// Set the state with the complete updated object
setCurrentModel(newState);
};
const handleCompletionTokenPriceChange = (value) => {
// Use a temporary variable to hold the new state
let newState = {
...(currentModel || {}),
completionTokenPrice: value,
completionRatio: 0,
};
if (!isNaN(value) && value !== '' && currentModel?.tokenPrice) {
const completionTokenPrice = parseFloat(value);
const modelTokenPrice = parseFloat(currentModel.tokenPrice);
if (modelTokenPrice > 0) {
const completionRatio = calculateCompletionRatioFromPrices(
modelTokenPrice,
completionTokenPrice,
);
newState.completionRatio = completionRatio;
}
}
// Set the state with the complete updated object
setCurrentModel(newState);
};
const addOrUpdateModel = (values) => {
// Check if we're editing an existing model or adding a new one
const existingModelIndex = models.findIndex(
(model) => model.name === values.name,
);
if (existingModelIndex >= 0) {
// Update existing model
setModels((prev) =>
prev.map((model, index) => {
if (index !== existingModelIndex) return model;
const updated = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
updated.hasConflict =
updated.price !== '' &&
(updated.ratio !== '' || updated.completionRatio !== '');
return updated;
}),
);
setVisible(false);
showSuccess(t('更新成功'));
} else {
// Add new model
// Check if model name already exists
if (models.some((model) => model.name === values.name)) {
showError(t('模型名称已存在'));
return;
}
setModels((prev) => {
const newModel = {
name: values.name,
price: values.price || '',
ratio: values.ratio || '',
completionRatio: values.completionRatio || '',
};
newModel.hasConflict =
newModel.price !== '' &&
(newModel.ratio !== '' || newModel.completionRatio !== '');
return [newModel, ...prev];
});
setVisible(false);
showSuccess(t('添加成功'));
}
};
const calculateTokenPriceFromRatio = (ratio) => {
return ratio * 2;
};
const resetModalState = () => {
setCurrentModel(null);
setPricingMode('per-token');
setPricingSubMode('ratio');
setIsEditMode(false);
};
const editModel = (record) => {
setIsEditMode(true);
// Determine which pricing mode to use based on the model's current configuration
let initialPricingMode = 'per-token';
let initialPricingSubMode = 'ratio';
if (record.price !== '') {
initialPricingMode = 'per-request';
} else {
initialPricingMode = 'per-token';
// We default to ratio mode, but could set to token-price if needed
}
// Set the pricing modes for the form
setPricingMode(initialPricingMode);
setPricingSubMode(initialPricingSubMode);
// Create a copy of the model data to avoid modifying the original
const modelCopy = { ...record };
// If the model has ratio data and we want to populate token price fields
if (record.ratio) {
modelCopy.tokenPrice = calculateTokenPriceFromRatio(
parseFloat(record.ratio),
).toString();
if (record.completionRatio) {
modelCopy.completionTokenPrice = (
parseFloat(modelCopy.tokenPrice) * parseFloat(record.completionRatio)
).toString();
}
}
// Set the current model
setCurrentModel(modelCopy);
// Open the modal
setVisible(true);
// Use setTimeout to ensure the form is rendered before setting values
setTimeout(() => {
if (formRef.current) {
// Update the form fields based on pricing mode
const formValues = {
name: modelCopy.name,
};
if (initialPricingMode === 'per-request') {
formValues.priceInput = modelCopy.price;
} else if (initialPricingMode === 'per-token') {
formValues.ratioInput = modelCopy.ratio;
formValues.completionRatioInput = modelCopy.completionRatio;
formValues.modelTokenPrice = modelCopy.tokenPrice;
formValues.completionTokenPrice = modelCopy.completionTokenPrice;
}
formRef.current.setValues(formValues);
}
}, 0);
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space className='mt-2'>
<Button
icon={<IconPlus />}
onClick={() => {
resetModalState();
setVisible(true);
}}
>
{t('添加模型')}
</Button>
<Button type='primary' icon={<IconSave />} onClick={SubmitData}>
{t('应用更改')}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => {
setSearchText(value);
setCurrentPage(1);
}}
style={{ width: 200 }}
showClear
/>
<Checkbox
checked={conflictOnly}
onChange={(e) => {
setConflictOnly(e.target.checked);
setCurrentPage(1);
}}
>
{t('仅显示矛盾倍率')}
</Checkbox>
</Space>
<Table
columns={columns}
dataSource={pagedData}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
showTotal: true,
showSizeChanger: false,
}}
/>
</Space>
<Modal
title={isEditMode ? t('编辑模型') : t('添加模型')}
visible={visible}
onCancel={() => {
resetModalState();
setVisible(false);
}}
onOk={() => {
if (currentModel) {
// If we're in token price mode, make sure ratio values are properly set
const valuesToSave = { ...currentModel };
if (
pricingMode === 'per-token' &&
pricingSubMode === 'token-price' &&
currentModel.tokenPrice
) {
// Calculate and set ratio from token price
const tokenPrice = parseFloat(currentModel.tokenPrice);
valuesToSave.ratio = (tokenPrice / 2).toString();
// Calculate and set completion ratio if both token prices are available
if (
currentModel.completionTokenPrice &&
currentModel.tokenPrice
) {
const completionPrice = parseFloat(
currentModel.completionTokenPrice,
);
const modelPrice = parseFloat(currentModel.tokenPrice);
if (modelPrice > 0) {
valuesToSave.completionRatio = (
completionPrice / modelPrice
).toString();
}
}
}
// Clear price if we're in per-token mode
if (pricingMode === 'per-token') {
valuesToSave.price = '';
} else {
// Clear ratios if we're in per-request mode
valuesToSave.ratio = '';
valuesToSave.completionRatio = '';
}
addOrUpdateModel(valuesToSave);
}
}}
>
<Form getFormApi={(api) => (formRef.current = api)}>
<Form.Input
field='name'
label={t('模型名称')}
placeholder='strawberry'
required
disabled={isEditMode}
onChange={(value) =>
setCurrentModel((prev) => ({ ...prev, name: value }))
}
/>
<Form.Section text={t('定价模式')}>
<div style={{ marginBottom: '16px' }}>
<RadioGroup
type='button'
value={pricingMode}
onChange={(e) => {
const newMode = e.target.value;
const oldMode = pricingMode;
setPricingMode(newMode);
// Instead of resetting all values, convert between modes
if (currentModel) {
const updatedModel = { ...currentModel };
// Update formRef with converted values
if (formRef.current) {
const formValues = {
name: updatedModel.name,
};
if (newMode === 'per-request') {
formValues.priceInput = updatedModel.price || '';
} else if (newMode === 'per-token') {
formValues.ratioInput = updatedModel.ratio || '';
formValues.completionRatioInput =
updatedModel.completionRatio || '';
formValues.modelTokenPrice =
updatedModel.tokenPrice || '';
formValues.completionTokenPrice =
updatedModel.completionTokenPrice || '';
}
formRef.current.setValues(formValues);
}
// Update the model state
setCurrentModel(updatedModel);
}
}}
>
<Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio>
</RadioGroup>
</div>
</Form.Section>
{pricingMode === 'per-token' && (
<>
<Form.Section text={t('价格设置方式')}>
<div style={{ marginBottom: '16px' }}>
<RadioGroup
type='button'
value={pricingSubMode}
onChange={(e) => {
const newSubMode = e.target.value;
const oldSubMode = pricingSubMode;
setPricingSubMode(newSubMode);
// Handle conversion between submodes
if (currentModel) {
const updatedModel = { ...currentModel };
// Convert between ratio and token price
if (
oldSubMode === 'ratio' &&
newSubMode === 'token-price'
) {
if (updatedModel.ratio) {
updatedModel.tokenPrice =
calculateTokenPriceFromRatio(
parseFloat(updatedModel.ratio),
).toString();
if (updatedModel.completionRatio) {
updatedModel.completionTokenPrice = (
parseFloat(updatedModel.tokenPrice) *
parseFloat(updatedModel.completionRatio)
).toString();
}
}
} else if (
oldSubMode === 'token-price' &&
newSubMode === 'ratio'
) {
// Ratio values should already be calculated by the handlers
}
// Update the form values
if (formRef.current) {
const formValues = {};
if (newSubMode === 'ratio') {
formValues.ratioInput = updatedModel.ratio || '';
formValues.completionRatioInput =
updatedModel.completionRatio || '';
} else if (newSubMode === 'token-price') {
formValues.modelTokenPrice =
updatedModel.tokenPrice || '';
formValues.completionTokenPrice =
updatedModel.completionTokenPrice || '';
}
formRef.current.setValues(formValues);
}
setCurrentModel(updatedModel);
}
}}
>
<Radio value='ratio'>{t('按倍率设置')}</Radio>
<Radio value='token-price'>{t('按价格设置')}</Radio>
</RadioGroup>
</div>
</Form.Section>
{pricingSubMode === 'ratio' && (
<>
<Form.Input
field='ratioInput'
label={t('模型倍率')}
placeholder={t('输入模型倍率')}
onChange={(value) =>
setCurrentModel((prev) => ({
...(prev || {}),
ratio: value,
}))
}
initValue={currentModel?.ratio || ''}
/>
<Form.Input
field='completionRatioInput'
label={t('补全倍率')}
placeholder={t('输入补全倍率')}
onChange={(value) =>
setCurrentModel((prev) => ({
...(prev || {}),
completionRatio: value,
}))
}
initValue={currentModel?.completionRatio || ''}
/>
</>
)}
{pricingSubMode === 'token-price' && (
<>
<Form.Input
field='modelTokenPrice'
label={t('输入价格')}
onChange={(value) => {
handleTokenPriceChange(value);
}}
initValue={currentModel?.tokenPrice || ''}
suffix={t('$/1M tokens')}
/>
<Form.Input
field='completionTokenPrice'
label={t('输出价格')}
onChange={(value) => {
handleCompletionTokenPriceChange(value);
}}
initValue={currentModel?.completionTokenPrice || ''}
suffix={t('$/1M tokens')}
/>
</>
)}
</>
)}
{pricingMode === 'per-request' && (
<Form.Input
field='priceInput'
label={t('固定价格(每次)')}
placeholder={t('输入每次价格')}
onChange={(value) =>
setCurrentModel((prev) => ({
...(prev || {}),
price: value,
}))
}
initValue={currentModel?.price || ''}
/>
)}
</Form>
</Modal>
</>
);
return <ModelPricingEditor options={props.options} refresh={props.refresh} />;
}

View File

@@ -0,0 +1,739 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useMemo, useState } from 'react';
import {
Banner,
Button,
Card,
Checkbox,
Empty,
Input,
Modal,
Radio,
RadioGroup,
Space,
Switch,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconDelete,
IconPlus,
IconSave,
IconSearch,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import {
PAGE_SIZE,
PRICE_SUFFIX,
buildSummaryText,
hasValue,
useModelPricingEditorState,
} from '../hooks/useModelPricingEditorState';
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
const { Text } = Typography;
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const PriceInput = ({
label,
value,
placeholder,
onChange,
suffix = PRICE_SUFFIX,
disabled = false,
extraText = '',
headerAction = null,
hidden = false,
}) => (
<div style={{ marginBottom: 16 }}>
<div className='mb-1 font-medium text-gray-700 flex items-center justify-between gap-3'>
<span>{label}</span>
{headerAction}
</div>
{!hidden ? (
<Input
value={value}
placeholder={placeholder}
onChange={onChange}
suffix={suffix}
disabled={disabled}
/>
) : null}
{extraText ? (
<div className='mt-1 text-xs text-gray-500'>{extraText}</div>
) : null}
</div>
);
export default function ModelPricingEditor({
options,
refresh,
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
filterMode = 'all',
allowAddModel = true,
allowDeleteModel = true,
showConflictFilter = true,
listDescription = '',
emptyTitle = '',
emptyDescription = '',
}) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [addVisible, setAddVisible] = useState(false);
const [batchVisible, setBatchVisible] = useState(false);
const [newModelName, setNewModelName] = useState('');
const {
selectedModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
setSelectedModelNames,
searchText,
setSearchText,
currentPage,
setCurrentPage,
loading,
conflictOnly,
setConflictOnly,
filteredModels,
pagedData,
selectedWarnings,
previewRows,
isOptionalFieldEnabled,
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleSubmit,
addModel,
deleteModel,
applySelectedModelPricing,
} = useModelPricingEditorState({
options,
refresh,
t,
candidateModelNames,
filterMode,
});
const columns = useMemo(
() => [
{
title: t('模型名称'),
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<Space>
<Button
theme='borderless'
type='tertiary'
onClick={() => setSelectedModelName(record.name)}
style={{
padding: 0,
color:
record.name === selectedModelName
? 'var(--semi-color-primary)'
: undefined,
}}
>
{text}
</Button>
{selectedModelNames.includes(record.name) ? (
<Tag color='green' shape='circle'>
{t('已勾选')}
</Tag>
) : null}
{record.hasConflict ? (
<Tag color='red' shape='circle'>
{t('矛盾')}
</Tag>
) : null}
</Space>
),
},
{
title: t('计费方式'),
dataIndex: 'billingMode',
key: 'billingMode',
render: (_, record) => (
<Tag color={record.billingMode === 'per-request' ? 'teal' : 'violet'}>
{record.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
</Tag>
),
},
{
title: t('价格摘要'),
dataIndex: 'summary',
key: 'summary',
render: (_, record) => buildSummaryText(record, t),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Space>
{allowDeleteModel ? (
<Button
size='small'
type='danger'
icon={<IconDelete />}
onClick={() => deleteModel(record.name)}
/>
) : null}
</Space>
),
},
],
[
allowDeleteModel,
deleteModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
t,
],
);
const handleAddModel = () => {
if (addModel(newModelName)) {
setNewModelName('');
setAddVisible(false);
}
};
const rowSelection = {
selectedRowKeys: selectedModelNames,
onChange: (selectedRowKeys) => setSelectedModelNames(selectedRowKeys),
};
return (
<>
<Space vertical align='start' style={{ width: '100%' }}>
<Space wrap className='mt-2'>
{allowAddModel ? (
<Button
icon={<IconPlus />}
onClick={() => setAddVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('添加模型')}
</Button>
) : null}
<Button
type='primary'
icon={<IconSave />}
loading={loading}
onClick={handleSubmit}
style={isMobile ? { width: '100%' } : undefined}
>
{t('应用更改')}
</Button>
<Button
disabled={!selectedModel || selectedModelNames.length === 0}
onClick={() => setBatchVisible(true)}
style={isMobile ? { width: '100%' } : undefined}
>
{t('批量应用当前模型价格')}
{selectedModelNames.length > 0 ? ` (${selectedModelNames.length})` : ''}
</Button>
<Input
prefix={<IconSearch />}
placeholder={t('搜索模型名称')}
value={searchText}
onChange={(value) => setSearchText(value)}
style={{ width: isMobile ? '100%' : 220 }}
showClear
/>
{showConflictFilter ? (
<Checkbox
checked={conflictOnly}
onChange={(event) => setConflictOnly(event.target.checked)}
>
{t('仅显示矛盾倍率')}
</Checkbox>
) : null}
</Space>
{listDescription ? (
<div className='text-sm text-gray-500'>{listDescription}</div>
) : null}
{selectedModelNames.length > 0 ? (
<div
style={{
width: '100%',
padding: '10px 12px',
borderRadius: 8,
background: 'var(--semi-color-primary-light-default)',
border: '1px solid var(--semi-color-primary)',
color: 'var(--semi-color-primary)',
fontWeight: 600,
}}
>
{t('已勾选 {{count}} 个模型', { count: selectedModelNames.length })}
</div>
) : null}
<div
style={{
width: '100%',
display: 'grid',
gap: 16,
gridTemplateColumns: isMobile
? 'minmax(0, 1fr)'
: 'minmax(360px, 1.1fr) minmax(420px, 1fr)',
}}
>
<Card
bodyStyle={{ padding: 0 }}
style={isMobile ? { order: 2 } : undefined}
>
<div style={{ overflowX: 'auto' }}>
<Table
columns={columns}
dataSource={pagedData}
rowKey='name'
rowSelection={rowSelection}
pagination={{
currentPage,
pageSize: PAGE_SIZE,
total: filteredModels.length,
onPageChange: (page) => setCurrentPage(page),
showTotal: true,
showSizeChanger: false,
}}
empty={
<div style={{ textAlign: 'center', padding: '20px' }}>
{emptyTitle || t('暂无模型')}
</div>
}
onRow={(record) => ({
style: {
background: selectedModelNames.includes(record.name)
? 'var(--semi-color-success-light-default)'
: record.name === selectedModelName
? 'var(--semi-color-primary-light-default)'
: undefined,
boxShadow: selectedModelNames.includes(record.name)
? 'inset 4px 0 0 var(--semi-color-success)'
: record.name === selectedModelName
? 'inset 4px 0 0 var(--semi-color-primary)'
: undefined,
transition: 'background 0.2s ease, box-shadow 0.2s ease',
},
onClick: () => setSelectedModelName(record.name),
})}
scroll={isMobile ? { x: 720 } : undefined}
/>
</div>
</Card>
<Card
style={isMobile ? { order: 1 } : undefined}
title={selectedModel ? selectedModel.name : t('模型计费编辑器')}
headerExtraContent={
selectedModel ? (
<Tag color='blue'>
{selectedModel.billingMode === 'per-request'
? t('按次计费')
: t('按量计费')}
</Tag>
) : null
}
>
{!selectedModel ? (
<Empty
title={emptyTitle || t('暂无模型')}
description={
emptyDescription || t('请先新增模型或从左侧列表选择一个模型')
}
/>
) : (
<div>
<div className='mb-4'>
<div className='mb-2 font-medium text-gray-700'>
{t('计费方式')}
</div>
<RadioGroup
type='button'
value={selectedModel.billingMode}
onChange={(event) => handleBillingModeChange(event.target.value)}
>
<Radio value='per-token'>{t('按量计费')}</Radio>
<Radio value='per-request'>{t('按次计费')}</Radio>
</RadioGroup>
<div className='mt-2 text-xs text-gray-500'>
{t(
'这个界面默认按价格填写,保存时会自动换算回后端需要的倍率 JSON。',
)}
</div>
</div>
{selectedWarnings.length > 0 ? (
<Card
bodyStyle={{ padding: 12 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-warning-light-default)',
}}
>
<div className='font-medium mb-2'>{t('当前提示')}</div>
{selectedWarnings.map((warning) => (
<div key={warning} className='text-sm text-gray-700 mb-1'>
{warning}
</div>
))}
</Card>
) : null}
{selectedModel.billingMode === 'per-request' ? (
<PriceInput
label={t('固定价格')}
value={selectedModel.fixedPrice}
placeholder={t('输入每次调用价格')}
suffix={t('$/次')}
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
) : (
<>
<Card
bodyStyle={{ padding: 16 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='font-medium mb-3'>{t('基础价格')}</div>
<PriceInput
label={t('输入价格')}
value={selectedModel.inputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
/>
{selectedModel.completionRatioLocked ? (
<Banner
type='warning'
bordered
fullMode={false}
closeIcon={null}
style={{ marginBottom: 12 }}
title={t('补全价格已锁定')}
description={t(
'该模型补全倍率由后端固定为 {{ratio}}。补全价格不能在这里修改。',
{
ratio: selectedModel.lockedCompletionRatio || '-',
},
)}
/>
) : null}
<PriceInput
label={t('补全价格')}
value={selectedModel.completionPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('completionPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'completionPrice',
)}
disabled={selectedModel.completionRatioLocked}
onChange={(checked) =>
handleOptionalFieldToggle('completionPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'completionPrice')
}
disabled={
!hasValue(selectedModel.inputPrice) ||
selectedModel.completionRatioLocked
}
extraText={
selectedModel.completionRatioLocked
? t(
'后端固定倍率:{{ratio}}。该字段仅展示换算后的价格。',
{
ratio: selectedModel.lockedCompletionRatio || '-',
},
)
: !isOptionalFieldEnabled(
selectedModel,
'completionPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('提示缓存价格')}
value={selectedModel.cachePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(selectedModel, 'cachePrice')}
onChange={(checked) =>
handleOptionalFieldToggle('cachePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'cachePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'cachePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('缓存创建价格')}
value={selectedModel.createCachePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('createCachePrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('createCachePrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'createCachePrice')
}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'createCachePrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
</Card>
<Card
bodyStyle={{ padding: 16 }}
style={{
marginBottom: 16,
background: 'var(--semi-color-fill-0)',
}}
>
<div className='mb-3'>
<div className='font-medium'>{t('扩展价格')}</div>
<div className='text-xs text-gray-500 mt-1'>
{t('这些价格都是可选项,不填也可以。')}
</div>
</div>
<PriceInput
label={t('图片输入价格')}
value={selectedModel.imagePrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(selectedModel, 'imagePrice')}
onChange={(checked) =>
handleOptionalFieldToggle('imagePrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'imagePrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(selectedModel, 'imagePrice')
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('音频输入价格')}
value={selectedModel.audioInputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioInputPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('audioInputPrice', checked)
}
/>
}
hidden={!isOptionalFieldEnabled(selectedModel, 'audioInputPrice')}
disabled={!hasValue(selectedModel.inputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
<PriceInput
label={t('音频补全价格')}
value={selectedModel.audioOutputPrice}
placeholder={t('输入 $/1M tokens')}
onChange={(value) =>
handleNumericFieldChange('audioOutputPrice', value)
}
headerAction={
<Switch
size='small'
checked={isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)}
disabled={!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)}
onChange={(checked) =>
handleOptionalFieldToggle('audioOutputPrice', checked)
}
/>
}
hidden={
!isOptionalFieldEnabled(selectedModel, 'audioOutputPrice')
}
disabled={!hasValue(selectedModel.audioInputPrice)}
extraText={
!isOptionalFieldEnabled(
selectedModel,
'audioInputPrice',
)
? t('请先开启并填写音频输入价格。')
: !isOptionalFieldEnabled(
selectedModel,
'audioOutputPrice',
)
? t('当前未启用,需要时再打开即可。')
: ''
}
/>
</Card>
</>
)}
<Card
bodyStyle={{ padding: 16 }}
style={{ background: 'var(--semi-color-fill-0)' }}
>
<div className='font-medium mb-3'>{t('保存预览')}</div>
<div className='text-xs text-gray-500 mb-3'>
{t(
'下面展示这个模型保存后会写入哪些后端字段,便于和原始 JSON 编辑框保持一致。',
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(140px, 180px) 1fr',
gap: 8,
}}
>
{previewRows.map((row) => (
<React.Fragment key={row.key}>
<Text strong>{row.label}</Text>
<Text>{row.value}</Text>
</React.Fragment>
))}
</div>
</Card>
</div>
)}
</Card>
</div>
</Space>
{allowAddModel ? (
<Modal
title={t('添加模型')}
visible={addVisible}
onCancel={() => {
setAddVisible(false);
setNewModelName('');
}}
onOk={handleAddModel}
>
<Input
value={newModelName}
placeholder={t('输入模型名称,例如 gpt-4.1')}
onChange={(value) => setNewModelName(value)}
/>
</Modal>
) : null}
<Modal
title={t('批量应用当前模型价格')}
visible={batchVisible}
onCancel={() => setBatchVisible(false)}
onOk={() => {
if (applySelectedModelPricing()) {
setBatchVisible(false);
}
}}
>
<div className='text-sm text-gray-600'>
{selectedModel
? t(
'将把当前编辑中的模型 {{name}} 的价格配置,批量应用到已勾选的 {{count}} 个模型。',
{
name: selectedModel.name,
count: selectedModelNames.length,
},
)
: t('请先选择一个作为模板的模型')}
</div>
{selectedModel ? (
<div className='text-xs text-gray-500 mt-3'>
{t(
'适合同系列模型一起定价,例如把 gpt-5.1 的价格批量同步到 gpt-5.1-high、gpt-5.1-low 等模型。',
)}
</div>
) : null}
</Modal>
</>
);
}

View File

@@ -0,0 +1,937 @@
import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
export const PAGE_SIZE = 10;
export const PRICE_SUFFIX = '$/1M tokens';
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const EMPTY_MODEL = {
name: '',
billingMode: 'per-token',
fixedPrice: '',
inputPrice: '',
completionPrice: '',
lockedCompletionRatio: '',
completionRatioLocked: false,
cachePrice: '',
createCachePrice: '',
imagePrice: '',
audioInputPrice: '',
audioOutputPrice: '',
rawRatios: {
modelRatio: '',
completionRatio: '',
cacheRatio: '',
createCacheRatio: '',
imageRatio: '',
audioRatio: '',
audioCompletionRatio: '',
},
hasConflict: false,
};
const NUMERIC_INPUT_REGEX = /^(\d+(\.\d*)?|\.\d*)?$/;
export const hasValue = (value) =>
value !== '' && value !== null && value !== undefined && value !== false;
const toNumericString = (value) => {
if (!hasValue(value) && value !== 0) {
return '';
}
const num = Number(value);
return Number.isFinite(num) ? String(num) : '';
};
const toNumberOrNull = (value) => {
if (!hasValue(value) && value !== 0) {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
const formatNumber = (value) => {
const num = toNumberOrNull(value);
if (num === null) {
return '';
}
return parseFloat(num.toFixed(12)).toString();
};
const parseOptionJSON = (rawValue) => {
if (!rawValue || rawValue.trim() === '') {
return {};
}
try {
const parsed = JSON.parse(rawValue);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (error) {
console.error('JSON解析错误:', error);
return {};
}
};
const ratioToBasePrice = (ratio) => {
const num = toNumberOrNull(ratio);
if (num === null) return '';
return formatNumber(num * 2);
};
const normalizeCompletionRatioMeta = (rawMeta) => {
if (!rawMeta || typeof rawMeta !== 'object' || Array.isArray(rawMeta)) {
return {
locked: false,
ratio: '',
};
}
return {
locked: Boolean(rawMeta.locked),
ratio: toNumericString(rawMeta.ratio),
};
};
const buildModelState = (name, sourceMaps) => {
const modelRatio = toNumericString(sourceMaps.ModelRatio[name]);
const completionRatio = toNumericString(sourceMaps.CompletionRatio[name]);
const completionRatioMeta = normalizeCompletionRatioMeta(
sourceMaps.CompletionRatioMeta?.[name],
);
const cacheRatio = toNumericString(sourceMaps.CacheRatio[name]);
const createCacheRatio = toNumericString(sourceMaps.CreateCacheRatio[name]);
const imageRatio = toNumericString(sourceMaps.ImageRatio[name]);
const audioRatio = toNumericString(sourceMaps.AudioRatio[name]);
const audioCompletionRatio = toNumericString(
sourceMaps.AudioCompletionRatio[name],
);
const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]);
const inputPrice = ratioToBasePrice(modelRatio);
const inputPriceNumber = toNumberOrNull(inputPrice);
const audioInputPrice =
inputPriceNumber !== null && hasValue(audioRatio)
? formatNumber(inputPriceNumber * Number(audioRatio))
: '';
return {
...EMPTY_MODEL,
name,
billingMode: hasValue(fixedPrice) ? 'per-request' : 'per-token',
fixedPrice,
inputPrice,
completionRatioLocked: completionRatioMeta.locked,
lockedCompletionRatio: completionRatioMeta.ratio,
completionPrice:
inputPriceNumber !== null &&
hasValue(completionRatioMeta.locked ? completionRatioMeta.ratio : completionRatio)
? formatNumber(
inputPriceNumber *
Number(
completionRatioMeta.locked
? completionRatioMeta.ratio
: completionRatio,
),
)
: '',
cachePrice:
inputPriceNumber !== null && hasValue(cacheRatio)
? formatNumber(inputPriceNumber * Number(cacheRatio))
: '',
createCachePrice:
inputPriceNumber !== null && hasValue(createCacheRatio)
? formatNumber(inputPriceNumber * Number(createCacheRatio))
: '',
imagePrice:
inputPriceNumber !== null && hasValue(imageRatio)
? formatNumber(inputPriceNumber * Number(imageRatio))
: '',
audioInputPrice,
audioOutputPrice:
toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio)
? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio))
: '',
rawRatios: {
modelRatio,
completionRatio,
cacheRatio,
createCacheRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
},
hasConflict:
hasValue(fixedPrice) &&
[
modelRatio,
completionRatio,
cacheRatio,
createCacheRatio,
imageRatio,
audioRatio,
audioCompletionRatio,
].some(hasValue),
};
};
export const isBasePricingUnset = (model) =>
!hasValue(model.fixedPrice) && !hasValue(model.inputPrice);
export const getModelWarnings = (model, t) => {
if (!model) {
return [];
}
const warnings = [];
const hasDerivedPricing = [
model.inputPrice,
model.completionPrice,
model.cachePrice,
model.createCachePrice,
model.imagePrice,
model.audioInputPrice,
model.audioOutputPrice,
].some(hasValue);
if (model.hasConflict) {
warnings.push(t('当前模型同时存在按次价格和倍率配置,保存时会按当前计费方式覆盖。'));
}
if (
!hasValue(model.inputPrice) &&
[
model.rawRatios.completionRatio,
model.rawRatios.cacheRatio,
model.rawRatios.createCacheRatio,
model.rawRatios.imageRatio,
model.rawRatios.audioRatio,
model.rawRatios.audioCompletionRatio,
].some(hasValue)
) {
warnings.push(
t('当前模型存在未显式设置输入倍率的扩展倍率;填写输入价格后会自动换算为价格字段。'),
);
}
if (model.billingMode === 'per-token' && hasDerivedPricing && !hasValue(model.inputPrice)) {
warnings.push(t('按量计费下需要先填写输入价格,才能保存其它价格项。'));
}
if (
model.billingMode === 'per-token' &&
hasValue(model.audioOutputPrice) &&
!hasValue(model.audioInputPrice)
) {
warnings.push(t('填写音频补全价格前,需要先填写音频输入价格。'));
}
return warnings;
};
export const buildSummaryText = (model, t) => {
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
}
if (hasValue(model.inputPrice)) {
const extraCount = [
model.completionPrice,
model.cachePrice,
model.createCachePrice,
model.imagePrice,
model.audioInputPrice,
model.audioOutputPrice,
].filter(hasValue).length;
const extraLabel =
extraCount > 0 ? `${t('额外价格项')} ${extraCount}` : '';
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
}
return t('未设置价格');
};
export const buildOptionalFieldToggles = (model) => ({
completionPrice: model.completionRatioLocked || hasValue(model.completionPrice),
cachePrice: hasValue(model.cachePrice),
createCachePrice: hasValue(model.createCachePrice),
imagePrice: hasValue(model.imagePrice),
audioInputPrice: hasValue(model.audioInputPrice),
audioOutputPrice: hasValue(model.audioOutputPrice),
});
const serializeModel = (model, t) => {
const result = {
ModelPrice: null,
ModelRatio: null,
CompletionRatio: null,
CacheRatio: null,
CreateCacheRatio: null,
ImageRatio: null,
AudioRatio: null,
AudioCompletionRatio: null,
};
if (model.billingMode === 'per-request') {
if (hasValue(model.fixedPrice)) {
result.ModelPrice = Number(model.fixedPrice);
}
return result;
}
const inputPrice = toNumberOrNull(model.inputPrice);
const completionPrice = toNumberOrNull(model.completionPrice);
const cachePrice = toNumberOrNull(model.cachePrice);
const createCachePrice = toNumberOrNull(model.createCachePrice);
const imagePrice = toNumberOrNull(model.imagePrice);
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
const hasDependentPrice = [
completionPrice,
cachePrice,
createCachePrice,
imagePrice,
audioInputPrice,
audioOutputPrice,
].some((value) => value !== null);
if (inputPrice === null) {
if (hasDependentPrice) {
throw new Error(
t('模型 {{name}} 缺少输入价格,无法计算补全/缓存/图片/音频价格对应的倍率', {
name: model.name,
}),
);
}
if (hasValue(model.rawRatios.modelRatio)) {
result.ModelRatio = Number(model.rawRatios.modelRatio);
}
if (hasValue(model.rawRatios.completionRatio)) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
}
if (hasValue(model.rawRatios.cacheRatio)) {
result.CacheRatio = Number(model.rawRatios.cacheRatio);
}
if (hasValue(model.rawRatios.createCacheRatio)) {
result.CreateCacheRatio = Number(model.rawRatios.createCacheRatio);
}
if (hasValue(model.rawRatios.imageRatio)) {
result.ImageRatio = Number(model.rawRatios.imageRatio);
}
if (hasValue(model.rawRatios.audioRatio)) {
result.AudioRatio = Number(model.rawRatios.audioRatio);
}
if (hasValue(model.rawRatios.audioCompletionRatio)) {
result.AudioCompletionRatio = Number(model.rawRatios.audioCompletionRatio);
}
return result;
}
result.ModelRatio = inputPrice / 2;
if (!model.completionRatioLocked && completionPrice !== null) {
result.CompletionRatio = completionPrice / inputPrice;
} else if (
model.completionRatioLocked &&
hasValue(model.rawRatios.completionRatio)
) {
result.CompletionRatio = Number(model.rawRatios.completionRatio);
}
if (cachePrice !== null) {
result.CacheRatio = cachePrice / inputPrice;
}
if (createCachePrice !== null) {
result.CreateCacheRatio = createCachePrice / inputPrice;
}
if (imagePrice !== null) {
result.ImageRatio = imagePrice / inputPrice;
}
if (audioInputPrice !== null) {
result.AudioRatio = audioInputPrice / inputPrice;
}
if (audioOutputPrice !== null) {
if (audioInputPrice === null || audioInputPrice === 0) {
throw new Error(
t('模型 {{name}} 缺少音频输入价格,无法计算音频补全倍率', {
name: model.name,
}),
);
}
result.AudioCompletionRatio = audioOutputPrice / audioInputPrice;
}
return result;
};
export const buildPreviewRows = (model, t) => {
if (!model) return [];
if (model.billingMode === 'per-request') {
return [
{
key: 'ModelPrice',
label: 'ModelPrice',
value: hasValue(model.fixedPrice) ? model.fixedPrice : t('空'),
},
];
}
const inputPrice = toNumberOrNull(model.inputPrice);
if (inputPrice === null) {
return [
{
key: 'ModelRatio',
label: 'ModelRatio',
value: hasValue(model.rawRatios.modelRatio)
? model.rawRatios.modelRatio
: t('空'),
},
{
key: 'CompletionRatio',
label: 'CompletionRatio',
value: hasValue(model.rawRatios.completionRatio)
? model.rawRatios.completionRatio
: t('空'),
},
{
key: 'CacheRatio',
label: 'CacheRatio',
value: hasValue(model.rawRatios.cacheRatio)
? model.rawRatios.cacheRatio
: t('空'),
},
{
key: 'CreateCacheRatio',
label: 'CreateCacheRatio',
value: hasValue(model.rawRatios.createCacheRatio)
? model.rawRatios.createCacheRatio
: t('空'),
},
{
key: 'ImageRatio',
label: 'ImageRatio',
value: hasValue(model.rawRatios.imageRatio)
? model.rawRatios.imageRatio
: t('空'),
},
{
key: 'AudioRatio',
label: 'AudioRatio',
value: hasValue(model.rawRatios.audioRatio)
? model.rawRatios.audioRatio
: t('空'),
},
{
key: 'AudioCompletionRatio',
label: 'AudioCompletionRatio',
value: hasValue(model.rawRatios.audioCompletionRatio)
? model.rawRatios.audioCompletionRatio
: t('空'),
},
];
}
const completionPrice = toNumberOrNull(model.completionPrice);
const cachePrice = toNumberOrNull(model.cachePrice);
const createCachePrice = toNumberOrNull(model.createCachePrice);
const imagePrice = toNumberOrNull(model.imagePrice);
const audioInputPrice = toNumberOrNull(model.audioInputPrice);
const audioOutputPrice = toNumberOrNull(model.audioOutputPrice);
return [
{
key: 'ModelRatio',
label: 'ModelRatio',
value: formatNumber(inputPrice / 2),
},
{
key: 'CompletionRatio',
label: 'CompletionRatio',
value: model.completionRatioLocked
? `${model.lockedCompletionRatio || t('空')} (${t('后端固定')})`
: completionPrice !== null
? formatNumber(completionPrice / inputPrice)
: t('空'),
},
{
key: 'CacheRatio',
label: 'CacheRatio',
value: cachePrice !== null ? formatNumber(cachePrice / inputPrice) : t('空'),
},
{
key: 'CreateCacheRatio',
label: 'CreateCacheRatio',
value:
createCachePrice !== null
? formatNumber(createCachePrice / inputPrice)
: t('空'),
},
{
key: 'ImageRatio',
label: 'ImageRatio',
value: imagePrice !== null ? formatNumber(imagePrice / inputPrice) : t('空'),
},
{
key: 'AudioRatio',
label: 'AudioRatio',
value:
audioInputPrice !== null
? formatNumber(audioInputPrice / inputPrice)
: t('空'),
},
{
key: 'AudioCompletionRatio',
label: 'AudioCompletionRatio',
value:
audioOutputPrice !== null && audioInputPrice !== null && audioInputPrice !== 0
? formatNumber(audioOutputPrice / audioInputPrice)
: t('空'),
},
];
};
export function useModelPricingEditorState({
options,
refresh,
t,
candidateModelNames = EMPTY_CANDIDATE_MODEL_NAMES,
filterMode = 'all',
}) {
const [models, setModels] = useState([]);
const [initialVisibleModelNames, setInitialVisibleModelNames] = useState([]);
const [selectedModelName, setSelectedModelName] = useState('');
const [selectedModelNames, setSelectedModelNames] = useState([]);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const [conflictOnly, setConflictOnly] = useState(false);
const [optionalFieldToggles, setOptionalFieldToggles] = useState({});
useEffect(() => {
const sourceMaps = {
ModelPrice: parseOptionJSON(options.ModelPrice),
ModelRatio: parseOptionJSON(options.ModelRatio),
CompletionRatio: parseOptionJSON(options.CompletionRatio),
CompletionRatioMeta: parseOptionJSON(options.CompletionRatioMeta),
CacheRatio: parseOptionJSON(options.CacheRatio),
CreateCacheRatio: parseOptionJSON(options.CreateCacheRatio),
ImageRatio: parseOptionJSON(options.ImageRatio),
AudioRatio: parseOptionJSON(options.AudioRatio),
AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio),
};
const names = new Set([
...candidateModelNames,
...Object.keys(sourceMaps.ModelPrice),
...Object.keys(sourceMaps.ModelRatio),
...Object.keys(sourceMaps.CompletionRatio),
...Object.keys(sourceMaps.CompletionRatioMeta),
...Object.keys(sourceMaps.CacheRatio),
...Object.keys(sourceMaps.CreateCacheRatio),
...Object.keys(sourceMaps.ImageRatio),
...Object.keys(sourceMaps.AudioRatio),
...Object.keys(sourceMaps.AudioCompletionRatio),
]);
const nextModels = Array.from(names)
.map((name) => buildModelState(name, sourceMaps))
.sort((a, b) => a.name.localeCompare(b.name));
setModels(nextModels);
setInitialVisibleModelNames(
filterMode === 'unset'
? nextModels
.filter((model) => isBasePricingUnset(model))
.map((model) => model.name)
: nextModels.map((model) => model.name),
);
setOptionalFieldToggles(
nextModels.reduce((acc, model) => {
acc[model.name] = buildOptionalFieldToggles(model);
return acc;
}, {}),
);
setSelectedModelName((previous) => {
if (previous && nextModels.some((model) => model.name === previous)) {
return previous;
}
const nextVisibleModels =
filterMode === 'unset'
? nextModels.filter((model) => isBasePricingUnset(model))
: nextModels;
return nextVisibleModels[0]?.name || '';
});
}, [candidateModelNames, filterMode, options]);
const visibleModels = useMemo(() => {
return filterMode === 'unset'
? models.filter((model) => initialVisibleModelNames.includes(model.name))
: models;
}, [filterMode, initialVisibleModelNames, models]);
const filteredModels = useMemo(() => {
return visibleModels.filter((model) => {
const keyword = searchText.trim().toLowerCase();
const keywordMatch = keyword
? model.name.toLowerCase().includes(keyword)
: true;
const conflictMatch = conflictOnly ? model.hasConflict : true;
return keywordMatch && conflictMatch;
});
}, [conflictOnly, searchText, visibleModels]);
const pagedData = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredModels.slice(start, start + PAGE_SIZE);
}, [currentPage, filteredModels]);
const selectedModel = useMemo(
() => visibleModels.find((model) => model.name === selectedModelName) || null,
[selectedModelName, visibleModels],
);
const selectedWarnings = useMemo(
() => getModelWarnings(selectedModel, t),
[selectedModel, t],
);
const previewRows = useMemo(
() => buildPreviewRows(selectedModel, t),
[selectedModel, t],
);
useEffect(() => {
setCurrentPage(1);
}, [searchText, conflictOnly, filterMode, candidateModelNames]);
useEffect(() => {
setSelectedModelNames((previous) =>
previous.filter((name) => visibleModels.some((model) => model.name === name)),
);
}, [visibleModels]);
useEffect(() => {
if (visibleModels.length === 0) {
setSelectedModelName('');
return;
}
if (!visibleModels.some((model) => model.name === selectedModelName)) {
setSelectedModelName(visibleModels[0].name);
}
}, [selectedModelName, visibleModels]);
const upsertModel = (name, updater) => {
setModels((previous) =>
previous.map((model) => {
if (model.name !== name) return model;
return typeof updater === 'function' ? updater(model) : updater;
}),
);
};
const isOptionalFieldEnabled = (model, field) => {
if (!model) return false;
const modelToggles = optionalFieldToggles[model.name];
if (modelToggles && typeof modelToggles[field] === 'boolean') {
return modelToggles[field];
}
return buildOptionalFieldToggles(model)[field];
};
const updateOptionalFieldToggle = (modelName, field, checked) => {
setOptionalFieldToggles((prev) => ({
...prev,
[modelName]: {
...(prev[modelName] || {}),
[field]: checked,
},
}));
};
const handleOptionalFieldToggle = (field, checked) => {
if (!selectedModel) return;
updateOptionalFieldToggle(selectedModel.name, field, checked);
if (checked) {
return;
}
upsertModel(selectedModel.name, (model) => {
const nextModel = { ...model, [field]: '' };
if (field === 'audioInputPrice') {
nextModel.audioOutputPrice = '';
setOptionalFieldToggles((prev) => ({
...prev,
[selectedModel.name]: {
...(prev[selectedModel.name] || {}),
audioInputPrice: false,
audioOutputPrice: false,
},
}));
}
return nextModel;
});
};
const fillDerivedPricesFromBase = (model, nextInputPrice) => {
const baseNumber = toNumberOrNull(nextInputPrice);
if (baseNumber === null) {
return model;
}
return {
...model,
completionPrice:
model.completionRatioLocked && hasValue(model.lockedCompletionRatio)
? formatNumber(baseNumber * Number(model.lockedCompletionRatio))
: !hasValue(model.completionPrice) &&
hasValue(model.rawRatios.completionRatio)
? formatNumber(baseNumber * Number(model.rawRatios.completionRatio))
: model.completionPrice,
cachePrice:
!hasValue(model.cachePrice) && hasValue(model.rawRatios.cacheRatio)
? formatNumber(baseNumber * Number(model.rawRatios.cacheRatio))
: model.cachePrice,
createCachePrice:
!hasValue(model.createCachePrice) &&
hasValue(model.rawRatios.createCacheRatio)
? formatNumber(baseNumber * Number(model.rawRatios.createCacheRatio))
: model.createCachePrice,
imagePrice:
!hasValue(model.imagePrice) && hasValue(model.rawRatios.imageRatio)
? formatNumber(baseNumber * Number(model.rawRatios.imageRatio))
: model.imagePrice,
audioInputPrice:
!hasValue(model.audioInputPrice) && hasValue(model.rawRatios.audioRatio)
? formatNumber(baseNumber * Number(model.rawRatios.audioRatio))
: model.audioInputPrice,
audioOutputPrice:
!hasValue(model.audioOutputPrice) &&
hasValue(model.rawRatios.audioRatio) &&
hasValue(model.rawRatios.audioCompletionRatio)
? formatNumber(
baseNumber *
Number(model.rawRatios.audioRatio) *
Number(model.rawRatios.audioCompletionRatio),
)
: model.audioOutputPrice,
};
};
const handleNumericFieldChange = (field, value) => {
if (!selectedModel || !NUMERIC_INPUT_REGEX.test(value)) {
return;
}
upsertModel(selectedModel.name, (model) => {
const updatedModel = { ...model, [field]: value };
if (field === 'inputPrice') {
return fillDerivedPricesFromBase(updatedModel, value);
}
return updatedModel;
});
};
const handleBillingModeChange = (value) => {
if (!selectedModel) return;
upsertModel(selectedModel.name, (model) => ({
...model,
billingMode: value,
}));
};
const addModel = (modelName) => {
const trimmedName = modelName.trim();
if (!trimmedName) {
showError(t('请输入模型名称'));
return false;
}
if (models.some((model) => model.name === trimmedName)) {
showError(t('模型名称已存在'));
return false;
}
const nextModel = {
...EMPTY_MODEL,
name: trimmedName,
rawRatios: { ...EMPTY_MODEL.rawRatios },
};
setModels((previous) => [nextModel, ...previous]);
setOptionalFieldToggles((prev) => ({
...prev,
[trimmedName]: buildOptionalFieldToggles(nextModel),
}));
setSelectedModelName(trimmedName);
setCurrentPage(1);
return true;
};
const deleteModel = (name) => {
const nextModels = models.filter((model) => model.name !== name);
setModels(nextModels);
setOptionalFieldToggles((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
setSelectedModelNames((previous) => previous.filter((item) => item !== name));
if (selectedModelName === name) {
setSelectedModelName(nextModels[0]?.name || '');
}
};
const applySelectedModelPricing = () => {
if (!selectedModel) {
showError(t('请先选择一个作为模板的模型'));
return false;
}
if (selectedModelNames.length === 0) {
showError(t('请先勾选需要批量设置的模型'));
return false;
}
const sourceToggles = optionalFieldToggles[selectedModel.name] || {};
setModels((previous) =>
previous.map((model) => {
if (!selectedModelNames.includes(model.name)) {
return model;
}
const nextModel = {
...model,
billingMode: selectedModel.billingMode,
fixedPrice: selectedModel.fixedPrice,
inputPrice: selectedModel.inputPrice,
completionPrice: selectedModel.completionPrice,
cachePrice: selectedModel.cachePrice,
createCachePrice: selectedModel.createCachePrice,
imagePrice: selectedModel.imagePrice,
audioInputPrice: selectedModel.audioInputPrice,
audioOutputPrice: selectedModel.audioOutputPrice,
};
if (
nextModel.billingMode === 'per-token' &&
nextModel.completionRatioLocked &&
hasValue(nextModel.inputPrice) &&
hasValue(nextModel.lockedCompletionRatio)
) {
nextModel.completionPrice = formatNumber(
Number(nextModel.inputPrice) * Number(nextModel.lockedCompletionRatio),
);
}
return nextModel;
}),
);
setOptionalFieldToggles((previous) => {
const next = { ...previous };
selectedModelNames.forEach((modelName) => {
const targetModel = models.find((item) => item.name === modelName);
next[modelName] = {
completionPrice: targetModel?.completionRatioLocked
? true
: Boolean(sourceToggles.completionPrice),
cachePrice: Boolean(sourceToggles.cachePrice),
createCachePrice: Boolean(sourceToggles.createCachePrice),
imagePrice: Boolean(sourceToggles.imagePrice),
audioInputPrice: Boolean(sourceToggles.audioInputPrice),
audioOutputPrice:
Boolean(sourceToggles.audioInputPrice) &&
Boolean(sourceToggles.audioOutputPrice),
};
});
return next;
});
showSuccess(
t('已将模型 {{name}} 的价格配置批量应用到 {{count}} 个模型', {
name: selectedModel.name,
count: selectedModelNames.length,
}),
);
return true;
};
const handleSubmit = async () => {
setLoading(true);
try {
const output = {
ModelPrice: {},
ModelRatio: {},
CompletionRatio: {},
CacheRatio: {},
CreateCacheRatio: {},
ImageRatio: {},
AudioRatio: {},
AudioCompletionRatio: {},
};
for (const model of models) {
const serialized = serializeModel(model, t);
Object.entries(serialized).forEach(([key, value]) => {
if (value !== null) {
output[key][model.name] = value;
}
});
}
const requestQueue = Object.entries(output).map(([key, value]) =>
API.put('/api/option/', {
key,
value: JSON.stringify(value, null, 2),
}),
);
const results = await Promise.all(requestQueue);
for (const res of results) {
if (!res?.data?.success) {
throw new Error(res?.data?.message || t('保存失败,请重试'));
}
}
showSuccess(t('保存成功'));
await refresh();
} catch (error) {
console.error('保存失败:', error);
showError(error.message || t('保存失败,请重试'));
} finally {
setLoading(false);
}
};
return {
models,
selectedModel,
selectedModelName,
selectedModelNames,
setSelectedModelName,
setSelectedModelNames,
searchText,
setSearchText,
currentPage,
setCurrentPage,
loading,
conflictOnly,
setConflictOnly,
filteredModels,
pagedData,
selectedWarnings,
previewRows,
isOptionalFieldEnabled,
handleOptionalFieldToggle,
handleNumericFieldChange,
handleBillingModeChange,
handleSubmit,
addModel,
deleteModel,
applySelectedModelPricing,
};
}