| @@ -27,6 +27,7 @@ import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVi | |||
| import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor'; | |||
| import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync'; | |||
| import PricingTagManager from '../../pages/Setting/Ratio/PricingTagManager'; | |||
| import ModelPricing from '../../pages/Setting/Operation/ModelPricing'; | |||
| import { API, showError, toBoolean } from '../../helpers'; | |||
| @@ -111,6 +112,11 @@ const RatioSetting = () => { | |||
| <Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'> | |||
| <ModelRatioNotSetEditor options={inputs} refresh={onRefresh} /> | |||
| </Tabs.TabPane> | |||
| <Tabs.TabPane tab={t('模型多维计费')} itemKey='model_pricing'> | |||
| <div data-testid='model-pricing-tab'> | |||
| <ModelPricing /> | |||
| </div> | |||
| </Tabs.TabPane> | |||
| <Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'> | |||
| <UpstreamRatioSync options={inputs} refresh={onRefresh} /> | |||
| </Tabs.TabPane> | |||
| @@ -1,10 +1,8 @@ | |||
| import React from 'react'; | |||
| import { Button, Checkbox, Input, Select, Space, Table } from '@douyinfe/semi-ui'; | |||
| import { Plus, Trash2 } from 'lucide-react'; | |||
| import { Button, Checkbox, Input, Select, Space, Table, Tooltip, Typography } from '@douyinfe/semi-ui'; | |||
| import { Info, 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']; | |||
| import { DIMENSION_SOURCE_TYPES, composeDimensionSource, parseDimensionValue, splitDimensionSource } from './pricingConfig'; | |||
| export default function DimensionEditor({ dimensions, onChange }) { | |||
| const { t } = useTranslation(); | |||
| @@ -19,13 +17,66 @@ export default function DimensionEditor({ dimensions, onChange }) { | |||
| 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('Key'), dataIndex: 'key', width: 140, render: (value, _, index) => <Input value={value} onChange={(key) => update(index, { key })} data-testid={`dimension-key-${index}`} /> }, | |||
| { | |||
| title: ( | |||
| <Space spacing={4}> | |||
| {t('来源类型')} | |||
| <Tooltip | |||
| content={t( | |||
| '来源决定系统从哪里读取这个维度的值。请求体会从原始请求 JSON 按路径取值,派生值会从系统解析上下文取值,高级模式可直接填写完整 source。', | |||
| )} | |||
| > | |||
| <Info size={14} /> | |||
| </Tooltip> | |||
| </Space> | |||
| ), | |||
| dataIndex: 'source_type', | |||
| width: 140, | |||
| render: (_, row, index) => { | |||
| const parsed = splitDimensionSource(row.source); | |||
| return ( | |||
| <Select value={parsed.sourceType} onChange={(sourceType) => update(index, { source: composeDimensionSource(sourceType, parsed.path) })} style={{ width: 120 }}> | |||
| <Select.Option value={DIMENSION_SOURCE_TYPES.request}>{t('请求体')}</Select.Option> | |||
| <Select.Option value={DIMENSION_SOURCE_TYPES.derived}>{t('派生值')}</Select.Option> | |||
| <Select.Option value={DIMENSION_SOURCE_TYPES.advanced}>{t('高级')}</Select.Option> | |||
| </Select> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| title: t('路径'), | |||
| dataIndex: 'source_path', | |||
| width: 210, | |||
| render: (_, row, index) => { | |||
| const parsed = splitDimensionSource(row.source); | |||
| return ( | |||
| <Input | |||
| value={parsed.path} | |||
| placeholder={parsed.sourceType === DIMENSION_SOURCE_TYPES.advanced ? 'request.options.size' : 'duration'} | |||
| onChange={(path) => update(index, { source: composeDimensionSource(parsed.sourceType, path) })} | |||
| data-testid={`dimension-source-path-${index}`} | |||
| /> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| title: t('来源'), | |||
| title: t('保存值'), | |||
| dataIndex: 'source', | |||
| render: (value, _, index) => ( | |||
| <Select value={value} onChange={(source) => update(index, { source })} style={{ width: 190 }}> | |||
| {SOURCE_OPTIONS.map((source) => ( | |||
| width: 190, | |||
| render: (value) => ( | |||
| <Typography.Text size='small' ellipsis={{ showTooltip: true }} style={{ maxWidth: 180 }}> | |||
| {value} | |||
| </Typography.Text> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('常用示例'), | |||
| dataIndex: 'source_examples', | |||
| width: 160, | |||
| render: (_, row, index) => ( | |||
| <Select placeholder={t('选择示例')} onChange={(source) => update(index, { source })} style={{ width: 140 }}> | |||
| {getSourceExamples(row.key).map((source) => ( | |||
| <Select.Option key={source} value={source}> | |||
| {source} | |||
| </Select.Option> | |||
| @@ -59,6 +110,7 @@ export default function DimensionEditor({ dimensions, onChange }) { | |||
| { | |||
| title: t('默认值'), | |||
| dataIndex: 'default', | |||
| width: 140, | |||
| render: (value, row, index) => ( | |||
| <Input | |||
| value={value ?? ''} | |||
| @@ -76,8 +128,11 @@ export default function DimensionEditor({ dimensions, onChange }) { | |||
| 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}> | |||
| <Table columns={columns} dataSource={dimensions} pagination={false} rowKey={(_, index) => `${index}`} scroll={{ x: 1280 }} /> | |||
| <Typography.Text type='tertiary'> | |||
| {t('读取不到必填维度时会拒绝计费;读取不到可选维度时使用默认值。高级模式会直接保存路径内容。')} | |||
| </Typography.Text> | |||
| <Button icon={<Plus size={16} />} data-testid='dimension-add' onClick={add}> | |||
| {t('添加维度')} | |||
| </Button> | |||
| </Space> | |||
| @@ -89,3 +144,9 @@ const defaultForType = (type) => { | |||
| if (type === 'boolean') return false; | |||
| return ''; | |||
| }; | |||
| const getSourceExamples = (key) => { | |||
| const examples = ['request.resolution', 'request.duration', 'request.options.size', 'derived.input_mode', 'derived.megapixels', 'derived.aspect_ratio']; | |||
| if (key && !examples.includes(`request.${key}`)) return [`request.${key}`, ...examples]; | |||
| return examples; | |||
| }; | |||
| @@ -102,15 +102,16 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| return ( | |||
| <SideSheet | |||
| visible={visible} | |||
| data-testid='model-pricing-editor' | |||
| title={name ? t('编辑模型多维计费') : t('添加模型多维计费')} | |||
| width={920} | |||
| width='min(1280px, calc(100vw - 48px))' | |||
| 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}> | |||
| <Button type='primary' icon={<Save size={16} />} data-testid='model-pricing-save' onClick={save} loading={saving}> | |||
| {t('保存')} | |||
| </Button> | |||
| </Space> | |||
| @@ -118,7 +119,7 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| > | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space wrap> | |||
| <Input prefix={t('模型')} value={name} onChange={setName} style={{ width: 260 }} disabled={Boolean(modelName)} /> | |||
| <Input prefix={t('模型')} value={name} onChange={setName} style={{ width: 260 }} disabled={Boolean(modelName)} data-testid='model-pricing-model-name' /> | |||
| <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> | |||
| @@ -132,7 +133,7 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| <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)}> | |||
| <Button icon={<Wand2 size={16} />} data-testid='model-pricing-open-generator' onClick={() => setGeneratorVisible(true)}> | |||
| {t('生成价格行')} | |||
| </Button> | |||
| </Space> | |||
| @@ -152,7 +153,7 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| <PreviewPanel config={draft} /> | |||
| </TabPane> | |||
| <TabPane tab={t('JSON')} itemKey='json'> | |||
| <TextArea value={jsonText} onChange={setJsonText} autosize={{ minRows: 18, maxRows: 28 }} /> | |||
| <TextArea value={jsonText} onChange={setJsonText} autosize={{ minRows: 18, maxRows: 28 }} data-testid='model-pricing-json-editor' /> | |||
| <Button style={{ marginTop: 12 }} onClick={applyJson}> | |||
| {t('应用 JSON')} | |||
| </Button> | |||
| @@ -162,6 +163,7 @@ export default function Editor({ visible, modelName, config, onClose, onSaved }) | |||
| <GeneratorModal | |||
| visible={generatorVisible} | |||
| dimensions={draft.dimensions || []} | |||
| billingUnit={draft.billing_unit} | |||
| existingRows={draft.table || []} | |||
| onCancel={() => setGeneratorVisible(false)} | |||
| onGenerate={(table) => { | |||
| @@ -1,61 +1,200 @@ | |||
| import React, { useMemo, useState } from 'react'; | |||
| import { Button, InputNumber, Modal, Radio, Space, TextArea, Typography } from '@douyinfe/semi-ui'; | |||
| import React, { useEffect, useMemo, useState } from 'react'; | |||
| import { Button, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from '@douyinfe/semi-ui'; | |||
| import { Plus, Trash2 } from 'lucide-react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { generateRowsFromRatios, mergeGeneratedRows } from './pricingConfig'; | |||
| import { getCurrencyConfig } from '../../../../helpers'; | |||
| import { | |||
| BILLING_UNITS, | |||
| countRatioEntries, | |||
| displayCurrencyToUSD, | |||
| estimateGeneratedRowCount, | |||
| formatDisplayCurrency, | |||
| generateRowsFromRatios, | |||
| mergeGeneratedRows, | |||
| parseDimensionValue, | |||
| } 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 }) { | |||
| export default function GeneratorModal({ visible, dimensions, billingUnit, existingRows, onCancel, onGenerate }) { | |||
| const { t } = useTranslation(); | |||
| const [basePrice, setBasePrice] = useState(0.1); | |||
| const [ratioText, setRatioText] = useState(JSON.stringify(DEFAULT_RATIOS, null, 2)); | |||
| const [ratioEntries, setRatioEntries] = useState([]); | |||
| const [strategy, setStrategy] = useState('preserve_manual'); | |||
| const currencyConfig = useMemo(() => getCurrencyConfig(), [visible]); | |||
| const basePriceUSD = useMemo(() => displayCurrencyToUSD(basePrice, currencyConfig), [basePrice, currencyConfig]); | |||
| const unitLabel = billingUnit === BILLING_UNITS.per1MTokens ? t('每 1M tokens') : t('每次调用'); | |||
| useEffect(() => { | |||
| if (!visible) return; | |||
| setRatioEntries(createDefaultRatioEntries(dimensions)); | |||
| }, [visible, dimensions]); | |||
| const ratios = useMemo(() => entriesToRatios(ratioEntries, dimensions), [ratioEntries, dimensions]); | |||
| 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 rows = generateRowsFromRatios({ dimensions, basePrice: basePriceUSD, ratios }); | |||
| return { | |||
| rows, | |||
| linearCount: countRatioEntries(ratios), | |||
| expandedCount: estimateGeneratedRowCount(dimensions, ratios), | |||
| }; | |||
| }, [dimensions, basePriceUSD, ratios]); | |||
| const apply = () => { | |||
| const table = mergeGeneratedRows({ existingRows, generatedRows: preview.rows, strategy }); | |||
| onGenerate(table); | |||
| }; | |||
| const updateEntry = (index, patch) => { | |||
| const next = [...ratioEntries]; | |||
| next[index] = { ...next[index], ...patch }; | |||
| setRatioEntries(next); | |||
| }; | |||
| const addEntry = () => { | |||
| const firstDimension = dimensions?.[0]?.key || ''; | |||
| setRatioEntries([...ratioEntries, { dimensionKey: firstDimension, value: '', ratio: 1 }]); | |||
| }; | |||
| const removeEntry = (index) => setRatioEntries(ratioEntries.filter((_, itemIndex) => itemIndex !== index)); | |||
| const ratioColumns = [ | |||
| { | |||
| title: t('维度'), | |||
| dataIndex: 'dimensionKey', | |||
| width: 160, | |||
| render: (value, _, index) => ( | |||
| <Select value={value} onChange={(dimensionKey) => updateEntry(index, { dimensionKey, value: '' })} style={{ width: 140 }}> | |||
| {(dimensions || []).map((dim) => ( | |||
| <Select.Option key={dim.key} value={dim.key}> | |||
| {dim.key} | |||
| </Select.Option> | |||
| ))} | |||
| </Select> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('候选值'), | |||
| dataIndex: 'value', | |||
| render: (value, _, index) => <Input value={value} onChange={(nextValue) => updateEntry(index, { value: nextValue })} data-testid={`generator-ratio-value-${index}`} />, | |||
| }, | |||
| { | |||
| title: t('倍率'), | |||
| dataIndex: 'ratio', | |||
| width: 150, | |||
| render: (value, _, index) => ( | |||
| <InputNumber value={Number(value || 0)} min={0} step={0.000001} onChange={(ratio) => updateEntry(index, { ratio: Number(ratio || 0) })} data-testid={`generator-ratio-ratio-${index}`} /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| width: 80, | |||
| render: (_, __, index) => <Button icon={<Trash2 size={16} />} type='danger' theme='borderless' onClick={() => removeEntry(index)} />, | |||
| }, | |||
| ]; | |||
| const previewColumns = [ | |||
| ...(dimensions || []).map((dim) => ({ title: dim.key, dataIndex: dim.key })), | |||
| { | |||
| title: t('展示价格'), | |||
| dataIndex: 'display_price', | |||
| width: 140, | |||
| render: (_, row) => formatDisplayCurrency(row.price, currencyConfig, 6), | |||
| }, | |||
| { | |||
| title: t('保存价格 USD'), | |||
| dataIndex: 'price', | |||
| width: 140, | |||
| render: (price) => `$${Number(price || 0).toFixed(6)}`, | |||
| }, | |||
| { title: t('来源'), dataIndex: 'source', width: 120 }, | |||
| ]; | |||
| return ( | |||
| <Modal | |||
| visible={visible} | |||
| title={t('生成价格行')} | |||
| data-testid='model-pricing-generator' | |||
| title={t('生成价格表')} | |||
| onCancel={onCancel} | |||
| width={920} | |||
| footer={ | |||
| <Space> | |||
| <Button onClick={onCancel}>{t('取消')}</Button> | |||
| <Button type='primary' onClick={apply} disabled={Boolean(preview.error)}> | |||
| <Button type='primary' data-testid='generator-apply' onClick={apply} disabled={preview.rows.length === 0}> | |||
| {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> | |||
| )} | |||
| <Typography.Text> | |||
| {t('基础价格是所有倍率计算的起点,当前单位为 {{unitLabel}}。最终价格 = 基础价格 × 各维度倍率。', { unitLabel })} | |||
| </Typography.Text> | |||
| <Typography.Text type='tertiary'> | |||
| {t('当前按 {{symbol}} 展示和输入,应用时会按汇率换算为 USD 保存到 table[].price。当前输入 {{symbol}}{{displayPrice}} = ${{usdPrice}}。', { | |||
| symbol: currencyConfig.symbol, | |||
| displayPrice: Number(basePrice || 0).toFixed(6), | |||
| usdPrice: basePriceUSD.toFixed(6), | |||
| })} | |||
| </Typography.Text> | |||
| <Space wrap> | |||
| <InputNumber | |||
| value={basePrice} | |||
| min={0} | |||
| step={0.000001} | |||
| onChange={(value) => setBasePrice(Number(value || 0))} | |||
| prefix={`${t('基础价格')}(${currencyConfig.symbol}/${unitLabel})`} | |||
| data-testid='generator-base-price' | |||
| /> | |||
| <Radio.Group value={strategy} onChange={(event) => setStrategy(event.target.value)}> | |||
| <Radio value='preserve_manual'>{t('保留手动行')}</Radio> | |||
| <Radio value='replace_all'>{t('全部替换')}</Radio> | |||
| </Radio.Group> | |||
| </Space> | |||
| <Table columns={ratioColumns} dataSource={ratioEntries} pagination={false} rowKey={(_, index) => `${index}`} /> | |||
| <Button icon={<Plus size={16} />} data-testid='generator-add-ratio' onClick={addEntry}> | |||
| {t('添加倍率项')} | |||
| </Button> | |||
| <Typography.Text type='tertiary'> | |||
| {t('线性倍率项 {{linearCount}} 项,最终展开 {{expandedCount}} 行。任一维度没有候选值时不会生成结果。预览最多展示前 50 行。', { | |||
| linearCount: preview.linearCount, | |||
| expandedCount: preview.expandedCount, | |||
| })} | |||
| </Typography.Text> | |||
| <Table columns={previewColumns} dataSource={preview.rows.slice(0, 50)} pagination={false} rowKey={(_, index) => `${index}`} scroll={{ x: 720 }} /> | |||
| </Space> | |||
| </Modal> | |||
| ); | |||
| } | |||
| const createDefaultRatioEntries = (dimensions) => { | |||
| const entries = []; | |||
| for (const dim of dimensions || []) { | |||
| const values = DEFAULT_RATIOS[dim.key]; | |||
| if (values) { | |||
| for (const [value, ratio] of Object.entries(values)) { | |||
| entries.push({ dimensionKey: dim.key, value, ratio }); | |||
| } | |||
| continue; | |||
| } | |||
| entries.push({ dimensionKey: dim.key, value: '', ratio: 1 }); | |||
| } | |||
| return entries; | |||
| }; | |||
| const entriesToRatios = (entries, dimensions) => { | |||
| const dimByKey = new Map((dimensions || []).map((dim) => [dim.key, dim])); | |||
| const ratios = {}; | |||
| for (const entry of entries || []) { | |||
| const dim = dimByKey.get(entry.dimensionKey); | |||
| if (!dim || entry.value === '') continue; | |||
| const parsedValue = String(parseDimensionValue(entry.value, dim.type)); | |||
| ratios[dim.key] = { ...(ratios[dim.key] || {}), [parsedValue]: Number(entry.ratio || 0) }; | |||
| } | |||
| return ratios; | |||
| }; | |||
| @@ -86,7 +86,7 @@ export default function ModelPricing() { | |||
| width: 120, | |||
| render: (_, row) => ( | |||
| <Space> | |||
| <Button icon={<Edit size={16} />} theme='borderless' onClick={() => setEditingModel(row.model)} /> | |||
| <Button icon={<Edit size={16} />} theme='borderless' data-testid={`model-pricing-edit-${row.model}`} onClick={() => setEditingModel(row.model)} /> | |||
| {row.configured && ( | |||
| <Popconfirm | |||
| title={t('删除后将回退到 Legacy 价格:{{price}}', { price: row.legacyPrice ?? '-' })} | |||
| @@ -101,7 +101,7 @@ export default function ModelPricing() { | |||
| ]; | |||
| return ( | |||
| <Card style={{ marginTop: 10 }}> | |||
| <Card style={{ marginTop: 10 }} data-testid='model-pricing-page'> | |||
| <Space vertical align='start' style={{ width: '100%' }}> | |||
| <Space style={{ width: '100%', justifyContent: 'space-between' }}> | |||
| <Typography.Title heading={5}>{t('模型多维计费')}</Typography.Title> | |||
| @@ -109,13 +109,13 @@ export default function ModelPricing() { | |||
| <Button icon={<RefreshCw size={16} />} onClick={refresh} loading={loading}> | |||
| {t('刷新')} | |||
| </Button> | |||
| <Button icon={<Plus size={16} />} type='primary' onClick={() => setEditingModel('')}> | |||
| <Button icon={<Plus size={16} />} type='primary' data-testid='model-pricing-add-model' 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} /> | |||
| <Input placeholder={t('搜索模型')} value={keyword} onChange={setKeyword} style={{ width: 280 }} data-testid='model-pricing-search' /> | |||
| <Table columns={columns} dataSource={data} rowKey='model' pagination={{ pageSize: 10 }} loading={loading} data-testid='model-pricing-table' /> | |||
| </Space> | |||
| <Editor | |||
| visible={editingModel !== null} | |||