Kaynağa Gözat

feat(ui): add model multidimensional pricing editor

master
fengsilin 3 hafta önce
ebeveyn
işleme
b36edf7f43
9 değiştirilmiş dosya ile 599 ekleme ve 4 silme
  1. +2
    -0
      web/src/components/settings/OperationSetting.jsx
  2. +15
    -3
      web/src/i18n/locales/en.json
  3. +16
    -1
      web/src/i18n/locales/zh-CN.json
  4. +91
    -0
      web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx
  5. +174
    -0
      web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
  6. +61
    -0
      web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
  7. +36
    -0
      web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx
  8. +66
    -0
      web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx
  9. +138
    -0
      web/src/pages/Setting/Operation/ModelPricing/index.jsx

+ 2
- 0
web/src/components/settings/OperationSetting.jsx Dosyayı Görüntüle

@@ -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>
</>
);


+ 15
- 3
web/src/i18n/locales/en.json Dosyayı Görüntüle

@@ -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 @",


+ 16
- 1
web/src/i18n/locales/zh-CN.json Dosyayı Görüntüle

@@ -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"
},
"邮箱后缀": "邮箱后缀",
"邮箱后缀必须以 @ 开头": "邮箱后缀必须以 @ 开头",


+ 91
- 0
web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx Dosyayı Görüntüle

@@ -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 '';
};

+ 174
- 0
web/src/pages/Setting/Operation/ModelPricing/Editor.jsx Dosyayı Görüntüle

@@ -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>
);
}

+ 61
- 0
web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx Dosyayı Görüntüle

@@ -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>
);
}

+ 36
- 0
web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx Dosyayı Görüntüle

@@ -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>
)}
</>
);
}

+ 66
- 0
web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx Dosyayı Görüntüle

@@ -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>
);
}

+ 138
- 0
web/src/pages/Setting/Operation/ModelPricing/index.jsx Dosyayı Görüntüle

@@ -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 {};
}
};

Yükleniyor…
İptal
Kaydet