diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx
index ef0e427..a836c55 100644
--- a/web/src/components/settings/OperationSetting.jsx
+++ b/web/src/components/settings/OperationSetting.jsx
@@ -30,6 +30,7 @@ import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLim
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync';
import SettingsPlayground from '../../pages/Setting/Operation/SettingsPlayground';
+import ModelPricing from '../../pages/Setting/Operation/ModelPricing';
import { API, showError, toBoolean } from '../../helpers';
const OperationSetting = () => {
@@ -189,6 +190,7 @@ const OperationSetting = () => {
+
>
);
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 28a8fe5..9cbda58 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -3112,14 +3112,26 @@
",点击更新": ", click Update",
",音频 {{symbol}}{{price}} / 1M tokens": ", Audio {{symbol}}{{price}} / 1M tokens",
"???????????????": "Advanced ratios (leave empty to use global defaults)",
- "??????": "Audio output ratio",
+ "??????": "Apply generated rows",
"??????1.0?": "Global default (1.0)",
"?????????????0.5 = ????? token ???????????????????? 1.0": "Multiplier applied to input pricing when cache is hit. 0.5 means cached tokens are billed at half the input price. Leave empty to use the global default 1.0.",
"???????5 ???": "Cache creation ratio (5 minutes)",
"??????1.25?": "Global default (1.25)",
"????? token ???? ? ????????? 1.25?5 ?? TTL???? 1 ????? 1.6x ????????????": "Tokens written into cache are billed as this ratio ? input price. Default is 1.25 for a 5-minute TTL; cache creation over 1 hour is billed automatically at 1.6x. Leave empty to use the global default.",
- "????": "Image ratio",
- "?????": "Global default"
+ "????": "Final price",
+ "?????": "Preserve manual rows",
+ "??????????": "Failed to save model multidimensional pricing",
+ "?????????": "Model multidimensional pricing saved",
+ "???": "Default value",
+ "Legacy ??": "Legacy price",
+ "??????? Legacy ???{{price}}": "Deleting this rule falls back to Legacy price: {{price}}",
+ "????????": "Add model multidimensional pricing",
+ "??? tokens": "Per 1M tokens",
+ "??": "Refresh",
+ "?? JSON": "Apply JSON",
+ "??? {{count}} ?????{{sample}}": "Will generate {{count}} rows. Sample: {{sample}}",
+ "JSON ????": "JSON format error",
+ "Key": "Key"
},
"邮箱后缀": "Email Suffix",
"邮箱后缀必须以 @ 开头": "Email suffix must start with @",
diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json
index 5938369..058d04b 100644
--- a/web/src/i18n/locales/zh-CN.json
+++ b/web/src/i18n/locales/zh-CN.json
@@ -3087,7 +3087,22 @@
",当前无生效订阅,将自动使用钱包": ",当前无生效订阅,将自动使用钱包",
",时间:": ",时间:",
",点击更新": ",点击更新",
- ",音频 {{symbol}}{{price}} / 1M tokens": ",音频 {{symbol}}{{price}} / 1M tokens"
+ ",音频 {{symbol}}{{price}} / 1M tokens": ",音频 {{symbol}}{{price}} / 1M tokens",
+ "??????????": "??????????",
+ "?????????": "?????????",
+ "??????": "??????",
+ "???": "???",
+ "Legacy ??": "Legacy ??",
+ "????": "????",
+ "??????? Legacy ???{{price}}": "??????? Legacy ???{{price}}",
+ "????????": "????????",
+ "??? tokens": "??? tokens",
+ "??": "??",
+ "?????": "?????",
+ "?? JSON": "?? JSON",
+ "??? {{count}} ?????{{sample}}": "??? {{count}} ?????{{sample}}",
+ "JSON ????": "JSON ????",
+ "Key": "Key"
},
"邮箱后缀": "邮箱后缀",
"邮箱后缀必须以 @ 开头": "邮箱后缀必须以 @ 开头",
diff --git a/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx b/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx
new file mode 100644
index 0000000..9e863a5
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { Button, Checkbox, Input, Select, Space, Table } from '@douyinfe/semi-ui';
+import { Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { parseDimensionValue } from './pricingConfig';
+
+const SOURCE_OPTIONS = ['request.resolution', 'request.duration', 'request.options.size', 'derived.input_mode', 'derived.megapixels', 'derived.aspect_ratio'];
+
+export default function DimensionEditor({ dimensions, onChange }) {
+ const { t } = useTranslation();
+
+ const update = (index, patch) => {
+ const next = [...dimensions];
+ next[index] = { ...next[index], ...patch };
+ onChange(next);
+ };
+
+ const remove = (index) => onChange(dimensions.filter((_, itemIndex) => itemIndex !== index));
+ const add = () => onChange([...dimensions, { key: 'duration', source: 'request.duration', type: 'number', default: null }]);
+
+ const columns = [
+ { title: t('Key'), dataIndex: 'key', render: (value, _, index) => update(index, { key })} /> },
+ {
+ title: t('来源'),
+ dataIndex: 'source',
+ render: (value, _, index) => (
+
+ ),
+ },
+ {
+ title: t('类型'),
+ dataIndex: 'type',
+ width: 130,
+ render: (value, _, index) => (
+
+ ),
+ },
+ {
+ title: t('可选'),
+ dataIndex: 'optional',
+ width: 80,
+ render: (value, row, index) => (
+ update(index, { optional: event.target.checked, default: event.target.checked ? defaultForType(row.type) : null })}
+ />
+ ),
+ },
+ {
+ title: t('默认值'),
+ dataIndex: 'default',
+ render: (value, row, index) => (
+ update(index, { default: text === '' ? null : parseDimensionValue(text, row.type) })}
+ />
+ ),
+ },
+ {
+ title: t('操作'),
+ width: 80,
+ render: (_, __, index) => } type='danger' theme='borderless' onClick={() => remove(index)} />,
+ },
+ ];
+
+ return (
+
+ `${index}`} />
+ } onClick={add}>
+ {t('添加维度')}
+
+
+ );
+}
+
+const defaultForType = (type) => {
+ if (type === 'number') return 0;
+ if (type === 'boolean') return false;
+ return '';
+};
diff --git a/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
new file mode 100644
index 0000000..237ed52
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
@@ -0,0 +1,174 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Button, Input, Select, SideSheet, Space, TabPane, Tabs, TextArea, Typography } from '@douyinfe/semi-ui';
+import { Redo2, Save, Undo2, Wand2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../../../helpers';
+import DimensionEditor from './DimensionEditor';
+import GeneratorModal from './GeneratorModal';
+import PreviewPanel from './PreviewPanel';
+import PricingGrid from './PricingGrid';
+import { BILLING_UNITS, FALLBACKS, createDefaultConfig, validatePricingConfig } from './pricingConfig';
+
+export default function Editor({ visible, modelName, config, onClose, onSaved }) {
+ const { t } = useTranslation();
+ const [name, setName] = useState(modelName || '');
+ const [draft, setDraft] = useState(createDefaultConfig());
+ const [jsonText, setJsonText] = useState('');
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [generatorVisible, setGeneratorVisible] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ if (!visible) return;
+ const next = config ? structuredClone(config) : createDefaultConfig();
+ setName(modelName || '');
+ setDraft(next);
+ setJsonText(JSON.stringify(next, null, 2));
+ setHistory([]);
+ setFuture([]);
+ }, [visible, modelName, config]);
+
+ const validation = useMemo(() => validatePricingConfig(draft), [draft]);
+
+ const updateDraft = (next) => {
+ setHistory((items) => [...items, draft]);
+ setFuture([]);
+ setDraft(next);
+ setJsonText(JSON.stringify(next, null, 2));
+ };
+
+ const undo = () => {
+ const previous = history[history.length - 1];
+ if (!previous) return;
+ setHistory((items) => items.slice(0, -1));
+ setFuture((items) => [draft, ...items]);
+ setDraft(previous);
+ setJsonText(JSON.stringify(previous, null, 2));
+ };
+
+ const redo = () => {
+ const next = future[0];
+ if (!next) return;
+ setFuture((items) => items.slice(1));
+ setHistory((items) => [...items, draft]);
+ setDraft(next);
+ setJsonText(JSON.stringify(next, null, 2));
+ };
+
+ const applyJson = () => {
+ try {
+ const next = JSON.parse(jsonText || '{}');
+ updateDraft(next);
+ } catch (error) {
+ showError(`${t('JSON 格式错误')}: ${error.message}`);
+ }
+ };
+
+ const save = async () => {
+ if (!name.trim()) {
+ showError(t('模型名称不能为空'));
+ return;
+ }
+ if (!validation.ok) {
+ showError(validation.errors.join('\n'));
+ return;
+ }
+ setSaving(true);
+ try {
+ const res = await API.put(`/api/option/model_pricing/${encodeURIComponent(name.trim())}`, draft);
+ if (!res.data?.success) {
+ showError(res.data?.message || t('保存模型多维计费失败'));
+ return;
+ }
+ showSuccess(t('模型多维计费已保存'));
+ await onSaved?.();
+ onClose?.();
+ } catch (error) {
+ showError(error.message || t('保存模型多维计费失败'));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const setBillingUnit = (value) => {
+ updateDraft({
+ ...draft,
+ billing_unit: value,
+ preconsume_strategy: value === BILLING_UNITS.per1MTokens ? 'minimum' : 'exact',
+ });
+ };
+
+ return (
+
+ } onClick={undo} disabled={history.length === 0} />
+ } onClick={redo} disabled={future.length === 0} />
+
+ } onClick={save} loading={saving}>
+ {t('保存')}
+
+
+ }
+ >
+
+
+
+
+
+ } onClick={() => setGeneratorVisible(true)}>
+ {t('生成价格行')}
+
+
+ {!validation.ok && (
+
+ {validation.errors.join('\n')}
+
+ )}
+
+
+ updateDraft({ ...draft, dimensions })} />
+
+
+ updateDraft({ ...draft, table })} />
+
+
+
+
+
+
+
+
+
+
+ setGeneratorVisible(false)}
+ onGenerate={(table) => {
+ updateDraft({ ...draft, table });
+ setGeneratorVisible(false);
+ }}
+ />
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx b/web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
new file mode 100644
index 0000000..a281bc9
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
@@ -0,0 +1,61 @@
+import React, { useMemo, useState } from 'react';
+import { Button, InputNumber, Modal, Radio, Space, TextArea, Typography } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { generateRowsFromRatios, mergeGeneratedRows } from './pricingConfig';
+
+const DEFAULT_RATIOS = {
+ resolution: { '720P': 1, '1080P': 1.8 },
+ duration: { 5: 1, 10: 1.5 },
+};
+
+export default function GeneratorModal({ visible, dimensions, existingRows, onCancel, onGenerate }) {
+ const { t } = useTranslation();
+ const [basePrice, setBasePrice] = useState(0.1);
+ const [ratioText, setRatioText] = useState(JSON.stringify(DEFAULT_RATIOS, null, 2));
+ const [strategy, setStrategy] = useState('preserve_manual');
+
+ const preview = useMemo(() => {
+ try {
+ const ratios = JSON.parse(ratioText || '{}');
+ const rows = generateRowsFromRatios({ dimensions, basePrice, ratios });
+ return { rows, error: '' };
+ } catch (error) {
+ return { rows: [], error: error.message };
+ }
+ }, [dimensions, basePrice, ratioText]);
+
+ const apply = () => {
+ const table = mergeGeneratedRows({ existingRows, generatedRows: preview.rows, strategy });
+ onGenerate(table);
+ };
+
+ return (
+
+
+
+
+ }
+ >
+
+ setBasePrice(Number(value || 0))} prefix={t('基础价格')} />
+ setStrategy(event.target.value)}>
+ {t('保留手动行')}
+ {t('全部替换')}
+
+
+ {preview.error ? (
+ {preview.error}
+ ) : (
+ {t('将生成 {{count}} 行,示例:{{sample}}', { count: preview.rows.length, sample: JSON.stringify(preview.rows.slice(0, 3)) })}
+ )}
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx b/web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx
new file mode 100644
index 0000000..a6454f7
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx
@@ -0,0 +1,36 @@
+import React, { useMemo, useState } from 'react';
+import { Descriptions, TextArea, Typography } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { previewLookup } from './pricingConfig';
+
+export default function PreviewPanel({ config }) {
+ const { t } = useTranslation();
+ const [requestText, setRequestText] = useState('{\n "resolution": "720P"\n}');
+
+ const result = useMemo(() => {
+ try {
+ return previewLookup(config, JSON.parse(requestText || '{}'));
+ } catch (error) {
+ return { mode: 'invalid', error: error.message };
+ }
+ }, [config, requestText]);
+
+ return (
+ <>
+
+
+ {result.error && (
+
+ {result.error}
+
+ )}
+ >
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx b/web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx
new file mode 100644
index 0000000..b35fd71
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { Button, Input, InputNumber, Select, Space, Table } from '@douyinfe/semi-ui';
+import { Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { ROW_SOURCES, parseDimensionValue } from './pricingConfig';
+
+export default function PricingGrid({ dimensions, rows, onChange }) {
+ const { t } = useTranslation();
+
+ const update = (index, patch) => {
+ const next = [...rows];
+ next[index] = { ...next[index], ...patch };
+ onChange(next);
+ };
+
+ const add = () => {
+ const row = { price: 0, source: ROW_SOURCES.manual };
+ for (const dim of dimensions) row[dim.key] = '*';
+ onChange([...(rows || []), row]);
+ };
+
+ const remove = (index) => onChange(rows.filter((_, itemIndex) => itemIndex !== index));
+
+ const columns = [
+ ...dimensions.map((dim) => ({
+ title: dim.key,
+ dataIndex: dim.key,
+ render: (value, _, index) => (
+ update(index, { [dim.key]: text === '*' ? '*' : parseDimensionValue(text, dim.type) })} />
+ ),
+ })),
+ {
+ title: t('价格'),
+ dataIndex: 'price',
+ width: 140,
+ render: (value, _, index) => (
+ update(index, { price: Number(price || 0), source: ROW_SOURCES.manual })} />
+ ),
+ },
+ {
+ title: t('来源'),
+ dataIndex: 'source',
+ width: 140,
+ render: (value, _, index) => (
+
+ ),
+ },
+ {
+ title: t('操作'),
+ width: 80,
+ render: (_, __, index) => } type='danger' theme='borderless' onClick={() => remove(index)} />,
+ },
+ ];
+
+ return (
+
+ `${index}`} />
+ } onClick={add}>
+ {t('添加价格行')}
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/ModelPricing/index.jsx b/web/src/pages/Setting/Operation/ModelPricing/index.jsx
new file mode 100644
index 0000000..01ab459
--- /dev/null
+++ b/web/src/pages/Setting/Operation/ModelPricing/index.jsx
@@ -0,0 +1,138 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Button, Card, Input, Popconfirm, Space, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import { Edit, Plus, RefreshCw, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../../../helpers';
+import Editor from './Editor';
+
+export default function ModelPricing() {
+ const { t } = useTranslation();
+ const [rules, setRules] = useState({});
+ const [legacyPrices, setLegacyPrices] = useState({});
+ const [keyword, setKeyword] = useState('');
+ const [editingModel, setEditingModel] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const refresh = async () => {
+ setLoading(true);
+ try {
+ const [rulesRes, optionsRes] = await Promise.all([API.get('/api/option/model_pricing'), API.get('/api/option/')]);
+ if (rulesRes.data?.success) {
+ setRules(rulesRes.data.data || {});
+ } else {
+ showError(rulesRes.data?.message || t('加载模型多维计费失败'));
+ }
+ if (optionsRes.data?.success) {
+ const modelPrice = optionsRes.data.data?.find((item) => item.key === 'ModelPrice')?.value || '{}';
+ setLegacyPrices(parseJsonObject(modelPrice));
+ }
+ } catch (error) {
+ showError(error.message || t('加载模型多维计费失败'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ refresh();
+ }, []);
+
+ const data = useMemo(() => {
+ const names = new Set([...Object.keys(legacyPrices), ...Object.keys(rules)]);
+ return [...names]
+ .filter((name) => name.toLowerCase().includes(keyword.toLowerCase()))
+ .sort()
+ .map((name) => {
+ const cfg = rules[name];
+ return {
+ model: name,
+ configured: Boolean(cfg),
+ dimensions: cfg?.dimensions?.length || 0,
+ rows: cfg?.table?.length || 0,
+ billingUnit: cfg?.billing_unit || '-',
+ legacyPrice: legacyPrices[name],
+ };
+ });
+ }, [rules, legacyPrices, keyword]);
+
+ const remove = async (model) => {
+ try {
+ const res = await API.delete(`/api/option/model_pricing/${encodeURIComponent(model)}`);
+ if (!res.data?.success) {
+ showError(res.data?.message || t('删除模型多维计费失败'));
+ return;
+ }
+ showSuccess(t('模型多维计费已删除'));
+ await refresh();
+ } catch (error) {
+ showError(error.message || t('删除模型多维计费失败'));
+ }
+ };
+
+ const columns = [
+ { title: t('模型'), dataIndex: 'model' },
+ {
+ title: t('状态'),
+ dataIndex: 'configured',
+ width: 120,
+ render: (configured) => {configured ? t('已配置') : t('Legacy 回退')},
+ },
+ { title: t('维度'), dataIndex: 'dimensions', width: 90 },
+ { title: t('行数'), dataIndex: 'rows', width: 90 },
+ { title: t('计费单位'), dataIndex: 'billingUnit', width: 150 },
+ { title: t('Legacy 价格'), dataIndex: 'legacyPrice', width: 140, render: (value) => value ?? '-' },
+ {
+ title: t('操作'),
+ width: 120,
+ render: (_, row) => (
+
+ } theme='borderless' onClick={() => setEditingModel(row.model)} />
+ {row.configured && (
+ remove(row.model)}
+ >
+ } type='danger' theme='borderless' />
+
+ )}
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+ {t('模型多维计费')}
+
+ } onClick={refresh} loading={loading}>
+ {t('刷新')}
+
+ } type='primary' onClick={() => setEditingModel('')}>
+ {t('添加模型')}
+
+
+
+
+
+
+ setEditingModel(null)}
+ onSaved={refresh}
+ />
+
+ );
+}
+
+const parseJsonObject = (text) => {
+ try {
+ const data = JSON.parse(text);
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
+ } catch {
+ return {};
+ }
+};