| @@ -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 = () => { | |||
| <Card style={{ marginTop: '10px' }}> | |||
| <SettingsPlayground options={inputs} refresh={onRefresh} /> | |||
| </Card> | |||
| <ModelPricing /> | |||
| </Spin> | |||
| </> | |||
| ); | |||
| @@ -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 @", | |||
| @@ -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" | |||
| }, | |||
| "邮箱后缀": "邮箱后缀", | |||
| "邮箱后缀必须以 @ 开头": "邮箱后缀必须以 @ 开头", | |||
| @@ -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) => <Input value={value} onChange={(key) => update(index, { key })} /> }, | |||
| { | |||
| title: t('来源'), | |||
| dataIndex: 'source', | |||
| render: (value, _, index) => ( | |||
| <Select value={value} onChange={(source) => update(index, { source })} style={{ width: 190 }}> | |||
| {SOURCE_OPTIONS.map((source) => ( | |||
| <Select.Option key={source} value={source}> | |||
| {source} | |||
| </Select.Option> | |||
| ))} | |||
| </Select> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('类型'), | |||
| dataIndex: 'type', | |||
| width: 130, | |||
| render: (value, _, index) => ( | |||
| <Select value={value} onChange={(type) => update(index, { type, default: null })} style={{ width: 110 }}> | |||
| <Select.Option value='string'>{t('字符串')}</Select.Option> | |||
| <Select.Option value='number'>{t('数字')}</Select.Option> | |||
| <Select.Option value='boolean'>{t('布尔值')}</Select.Option> | |||
| </Select> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('可选'), | |||
| dataIndex: 'optional', | |||
| width: 80, | |||
| render: (value, row, index) => ( | |||
| <Checkbox | |||
| checked={Boolean(value)} | |||
| onChange={(event) => update(index, { optional: event.target.checked, default: event.target.checked ? defaultForType(row.type) : null })} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('默认值'), | |||
| dataIndex: 'default', | |||
| render: (value, row, index) => ( | |||
| <Input | |||
| value={value ?? ''} | |||
| disabled={!row.optional} | |||
| onChange={(text) => update(index, { default: text === '' ? null : parseDimensionValue(text, row.type) })} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 80, | |||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => remove(index)} />, | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Table columns={columns} dataSource={dimensions} pagination={false} rowKey={(_, index) => `${index}`} /> | |||
| <Button icon={<Plus size={16} />} onClick={add}> | |||
| {t('添加维度')} | |||
| </Button> | |||
| </Space> | |||
| ); | |||
| } | |||
| const defaultForType = (type) => { | |||
| if (type === 'number') return 0; | |||
| if (type === 'boolean') return false; | |||
| return ''; | |||
| }; | |||
| @@ -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 ( | |||
| <SideSheet | |||
| visible={visible} | |||
| title={name ? t('编辑模型多维计费') : t('添加模型多维计费')} | |||
| width={920} | |||
| onCancel={onClose} | |||
| footer={ | |||
| <Space> | |||
| <Button icon={<Undo2 size={16} />} onClick={undo} disabled={history.length === 0} /> | |||
| <Button icon={<Redo2 size={16} />} onClick={redo} disabled={future.length === 0} /> | |||
| <Button onClick={onClose}>{t('取消')}</Button> | |||
| <Button type='primary' icon={<Save size={16} />} onClick={save} loading={saving}> | |||
| {t('保存')} | |||
| </Button> | |||
| </Space> | |||
| } | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space wrap> | |||
| <Input prefix={t('模型')} value={name} onChange={setName} style={{ width: 260 }} disabled={Boolean(modelName)} /> | |||
| <Select value={draft.billing_unit} onChange={setBillingUnit} style={{ width: 180 }}> | |||
| <Select.Option value='per_call'>{t('每次调用')}</Select.Option> | |||
| <Select.Option value='per_1m_tokens'>{t('每百万 tokens')}</Select.Option> | |||
| </Select> | |||
| <Select | |||
| value={draft.fallback?.strategy} | |||
| onChange={(value) => updateDraft({ ...draft, fallback: { ...draft.fallback, strategy: value } })} | |||
| style={{ width: 160 }} | |||
| > | |||
| <Select.Option value={FALLBACKS.reject}>{t('拒绝')}</Select.Option> | |||
| <Select.Option value={FALLBACKS.max}>{t('使用最高价')}</Select.Option> | |||
| <Select.Option value={FALLBACKS.default}>{t('使用默认价')}</Select.Option> | |||
| </Select> | |||
| <Button icon={<Wand2 size={16} />} onClick={() => setGeneratorVisible(true)}> | |||
| {t('生成价格行')} | |||
| </Button> | |||
| </Space> | |||
| {!validation.ok && ( | |||
| <Typography.Text type='danger' style={{ whiteSpace: 'pre-wrap' }}> | |||
| {validation.errors.join('\n')} | |||
| </Typography.Text> | |||
| )} | |||
| <Tabs type='line' style={{ width: '100%' }}> | |||
| <TabPane tab={t('维度')} itemKey='dimensions'> | |||
| <DimensionEditor dimensions={draft.dimensions || []} onChange={(dimensions) => updateDraft({ ...draft, dimensions })} /> | |||
| </TabPane> | |||
| <TabPane tab={t('价格表')} itemKey='table'> | |||
| <PricingGrid dimensions={draft.dimensions || []} rows={draft.table || []} onChange={(table) => updateDraft({ ...draft, table })} /> | |||
| </TabPane> | |||
| <TabPane tab={t('预览')} itemKey='preview'> | |||
| <PreviewPanel config={draft} /> | |||
| </TabPane> | |||
| <TabPane tab={t('JSON')} itemKey='json'> | |||
| <TextArea value={jsonText} onChange={setJsonText} autosize={{ minRows: 18, maxRows: 28 }} /> | |||
| <Button style={{ marginTop: 12 }} onClick={applyJson}> | |||
| {t('应用 JSON')} | |||
| </Button> | |||
| </TabPane> | |||
| </Tabs> | |||
| </Space> | |||
| <GeneratorModal | |||
| visible={generatorVisible} | |||
| dimensions={draft.dimensions || []} | |||
| existingRows={draft.table || []} | |||
| onCancel={() => setGeneratorVisible(false)} | |||
| onGenerate={(table) => { | |||
| updateDraft({ ...draft, table }); | |||
| setGeneratorVisible(false); | |||
| }} | |||
| /> | |||
| </SideSheet> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Modal | |||
| visible={visible} | |||
| title={t('生成价格行')} | |||
| onCancel={onCancel} | |||
| footer={ | |||
| <Space> | |||
| <Button onClick={onCancel}>{t('取消')}</Button> | |||
| <Button type='primary' onClick={apply} disabled={Boolean(preview.error)}> | |||
| {t('应用生成结果')} | |||
| </Button> | |||
| </Space> | |||
| } | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <InputNumber value={basePrice} min={0} step={0.000001} onChange={(value) => setBasePrice(Number(value || 0))} prefix={t('基础价格')} /> | |||
| <Radio.Group value={strategy} onChange={(event) => setStrategy(event.target.value)}> | |||
| <Radio value='preserve_manual'>{t('保留手动行')}</Radio> | |||
| <Radio value='replace_all'>{t('全部替换')}</Radio> | |||
| </Radio.Group> | |||
| <TextArea value={ratioText} onChange={setRatioText} autosize={{ minRows: 8, maxRows: 14 }} /> | |||
| {preview.error ? ( | |||
| <Typography.Text type='danger'>{preview.error}</Typography.Text> | |||
| ) : ( | |||
| <Typography.Text>{t('将生成 {{count}} 行,示例:{{sample}}', { count: preview.rows.length, sample: JSON.stringify(preview.rows.slice(0, 3)) })}</Typography.Text> | |||
| )} | |||
| </Space> | |||
| </Modal> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <> | |||
| <TextArea value={requestText} onChange={setRequestText} autosize={{ minRows: 8, maxRows: 14 }} /> | |||
| <Descriptions | |||
| style={{ marginTop: 16 }} | |||
| data={[ | |||
| { key: t('命中模式'), value: result.mode }, | |||
| { key: t('最终价格'), value: result.price ?? '-' }, | |||
| { key: t('快照'), value: result.snapshot ? JSON.stringify(result.snapshot) : '-' }, | |||
| ]} | |||
| /> | |||
| {result.error && ( | |||
| <Typography.Text type='danger' style={{ whiteSpace: 'pre-wrap' }}> | |||
| {result.error} | |||
| </Typography.Text> | |||
| )} | |||
| </> | |||
| ); | |||
| } | |||
| @@ -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) => ( | |||
| <Input value={value ?? ''} onChange={(text) => update(index, { [dim.key]: text === '*' ? '*' : parseDimensionValue(text, dim.type) })} /> | |||
| ), | |||
| })), | |||
| { | |||
| title: t('价格'), | |||
| dataIndex: 'price', | |||
| width: 140, | |||
| render: (value, _, index) => ( | |||
| <InputNumber value={Number(value || 0)} min={0} step={0.000001} onChange={(price) => update(index, { price: Number(price || 0), source: ROW_SOURCES.manual })} /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('来源'), | |||
| dataIndex: 'source', | |||
| width: 140, | |||
| render: (value, _, index) => ( | |||
| <Select value={value || ROW_SOURCES.manual} onChange={(source) => update(index, { source })} style={{ width: 120 }}> | |||
| <Select.Option value={ROW_SOURCES.manual}>{t('手动')}</Select.Option> | |||
| <Select.Option value={ROW_SOURCES.generated}>{t('生成')}</Select.Option> | |||
| </Select> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 80, | |||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => remove(index)} />, | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Table columns={columns} dataSource={rows} pagination={{ pageSize: 8 }} rowKey={(_, index) => `${index}`} /> | |||
| <Button icon={<Plus size={16} />} onClick={add}> | |||
| {t('添加价格行')} | |||
| </Button> | |||
| </Space> | |||
| ); | |||
| } | |||
| @@ -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) => <Tag color={configured ? 'green' : 'grey'}>{configured ? t('已配置') : t('Legacy 回退')}</Tag>, | |||
| }, | |||
| { 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) => ( | |||
| <Space> | |||
| <Button icon={<Edit size={16} />} theme='borderless' onClick={() => setEditingModel(row.model)} /> | |||
| {row.configured && ( | |||
| <Popconfirm | |||
| title={t('删除后将回退到 Legacy 价格:{{price}}', { price: row.legacyPrice ?? '-' })} | |||
| onConfirm={() => remove(row.model)} | |||
| > | |||
| <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' /> | |||
| </Popconfirm> | |||
| )} | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Card style={{ marginTop: 10 }}> | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title> | |||
| <Space> | |||
| <Button icon={<RefreshCw size={16} />} onClick={refresh} loading={loading}> | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button icon={<Plus size={16} />} type='primary' onClick={() => setEditingModel('')}> | |||
| {t('添加模型')} | |||
| </Button> | |||
| </Space> | |||
| </Space> | |||
| <Input placeholder={t('搜索模型')} value={keyword} onChange={setKeyword} style={{ width: 280 }} /> | |||
| <Table columns={columns} dataSource={data} rowKey='model' pagination={{ pageSize: 10 }} loading={loading} /> | |||
| </Space> | |||
| <Editor | |||
| visible={editingModel !== null} | |||
| modelName={editingModel || ''} | |||
| config={editingModel ? rules[editingModel] : null} | |||
| onClose={() => setEditingModel(null)} | |||
| onSaved={refresh} | |||
| /> | |||
| </Card> | |||
| ); | |||
| } | |||
| const parseJsonObject = (text) => { | |||
| try { | |||
| const data = JSON.parse(text); | |||
| return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; | |||
| } catch { | |||
| return {}; | |||
| } | |||
| }; | |||