Преглед на файлове

test: add model pricing e2e selectors

master
fengsilin преди 3 седмици
родител
ревизия
32014dde92
променени са 5 файла, в които са добавени 256 реда и са изтрити 48 реда
  1. +6
    -0
      web/src/components/settings/RatioSetting.jsx
  2. +73
    -12
      web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx
  3. +7
    -5
      web/src/pages/Setting/Operation/ModelPricing/Editor.jsx
  4. +165
    -26
      web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx
  5. +5
    -5
      web/src/pages/Setting/Operation/ModelPricing/index.jsx

+ 6
- 0
web/src/components/settings/RatioSetting.jsx Целия файл

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


+ 73
- 12
web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx Целия файл

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

+ 7
- 5
web/src/pages/Setting/Operation/ModelPricing/Editor.jsx Целия файл

@@ -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) => {


+ 165
- 26
web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx Целия файл

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

+ 5
- 5
web/src/pages/Setting/Operation/ModelPricing/index.jsx Целия файл

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


Зареждане…
Отказ
Запис