feat(web): add reusable admin form dialog
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import type {
|
||||
BaseModelCatalogItem,
|
||||
BaseModelUpsertRequest,
|
||||
CatalogProvider,
|
||||
} from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select, Textarea } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type ModelForm = {
|
||||
providerKey: string;
|
||||
canonicalModelKey: string;
|
||||
providerModelName: string;
|
||||
modelType: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
pricingVersion: string;
|
||||
capabilitiesJson: string;
|
||||
billingJson: string;
|
||||
rateLimitJson: string;
|
||||
metadataJson: string;
|
||||
};
|
||||
|
||||
const statuses = ['active', 'deprecated', 'hidden'];
|
||||
const fallbackTypes = [
|
||||
'text_generate',
|
||||
'image_generate',
|
||||
'image_edit',
|
||||
'image_analysis',
|
||||
'video_generate',
|
||||
'image_to_video',
|
||||
'omni_video',
|
||||
'text_embedding',
|
||||
'text_to_speech',
|
||||
'audio_generate',
|
||||
'digital_human_generate',
|
||||
'text_to_model',
|
||||
'image_to_model',
|
||||
'multiview_to_model',
|
||||
'mesh_edit',
|
||||
];
|
||||
|
||||
const emptyForm: ModelForm = {
|
||||
providerKey: '',
|
||||
canonicalModelKey: '',
|
||||
providerModelName: '',
|
||||
modelType: 'text_generate',
|
||||
displayName: '',
|
||||
status: 'active',
|
||||
pricingVersion: '1',
|
||||
capabilitiesJson: '{}',
|
||||
billingJson: '{}',
|
||||
rateLimitJson: '{}',
|
||||
metadataJson: '{}',
|
||||
};
|
||||
|
||||
export function BaseModelCatalogPanel(props: {
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
message: string;
|
||||
providers: CatalogProvider[];
|
||||
state: LoadState;
|
||||
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
|
||||
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<ModelForm>(emptyForm);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [providerFilter, setProviderFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
|
||||
const providerNames = useMemo(
|
||||
() => new Map(props.providers.map((item) => [item.providerKey, item.displayName])),
|
||||
[props.providers],
|
||||
);
|
||||
const typeOptions = useMemo(
|
||||
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.map((item) => item.modelType).filter(Boolean)])),
|
||||
[props.baseModels],
|
||||
);
|
||||
const providerOptions = useMemo(
|
||||
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])),
|
||||
[props.baseModels, props.providers],
|
||||
);
|
||||
const filteredModels = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return props.baseModels.filter((item) => {
|
||||
const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter;
|
||||
const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter);
|
||||
const text = `${item.displayName} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
|
||||
return matchesProvider && matchesType && (!keyword || text.includes(keyword));
|
||||
});
|
||||
}, [props.baseModels, providerFilter, query, typeFilter]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSaveBaseModel(formToPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '模型保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
const providerKey = providerOptions[0] ?? '';
|
||||
setEditingId('');
|
||||
setForm({ ...emptyForm, providerKey, canonicalModelKey: providerKey ? `${providerKey}:` : '' });
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editModel(model: BaseModelCatalogItem) {
|
||||
setEditingId(model.id);
|
||||
setForm(modelToForm(model));
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setEditingId('');
|
||||
setForm(emptyForm);
|
||||
setLocalError('');
|
||||
setDialogOpen(false);
|
||||
}
|
||||
|
||||
async function deleteModel(model: BaseModelCatalogItem) {
|
||||
const confirmed = window.confirm(`确认删除基准模型 ${model.displayName}?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await props.onDeleteBaseModel(model.id);
|
||||
if (editingId === model.id) closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '模型删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基准模型库</CardTitle>
|
||||
<Badge variant="secondary">{props.baseModels.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>默认模型来自原 server-main integration-platform,保留模型类型、能力、图标、计费和限流元数据。</p>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增模型
|
||||
</Button>
|
||||
</div>
|
||||
<div className="modelCatalogFilters">
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
|
||||
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
|
||||
<option value="all">全部厂商</option>
|
||||
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
|
||||
</Select>
|
||||
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
|
||||
<option value="all">全部能力</option>
|
||||
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="baseModelGrid">
|
||||
{filteredModels.map((model) => (
|
||||
<ModelCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
providerName={providerNames.get(model.providerKey)}
|
||||
onDelete={() => void deleteModel(model)}
|
||||
onEdit={() => editModel(model)}
|
||||
/>
|
||||
))}
|
||||
{!filteredModels.length && (
|
||||
<Card>
|
||||
<CardContent className="emptyState">
|
||||
<strong>没有匹配的基准模型</strong>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑基准模型' : '新增基准模型'}
|
||||
className="baseModelDialog"
|
||||
eyebrow={editingId ? 'Edit Base Model' : 'New Base Model'}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>
|
||||
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
|
||||
{editingId ? '保存修改' : '新增模型'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
<RotateCcw size={15} />
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
formClassName="baseModelForm"
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑基准模型' : '新增基准模型'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>
|
||||
Provider
|
||||
<Select value={form.providerKey} onChange={(event) => setForm({ ...form, providerKey: event.target.value })}>
|
||||
<option value="">选择厂商</option>
|
||||
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
模型类型
|
||||
<Select value={form.modelType} onChange={(event) => setForm({ ...form, modelType: event.target.value })}>
|
||||
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
模型名
|
||||
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
展示名称
|
||||
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} />
|
||||
</Label>
|
||||
<Label className="spanTwo">
|
||||
Canonical Key
|
||||
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
状态
|
||||
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
|
||||
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label>
|
||||
定价版本
|
||||
<Input value={form.pricingVersion} onChange={(event) => setForm({ ...form, pricingVersion: event.target.value })} />
|
||||
</Label>
|
||||
<JsonField label="能力 JSON" value={form.capabilitiesJson} onChange={(value) => setForm({ ...form, capabilitiesJson: value })} />
|
||||
<JsonField label="基准计费 JSON" value={form.billingJson} onChange={(value) => setForm({ ...form, billingJson: value })} />
|
||||
<JsonField label="限流 JSON" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
|
||||
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard(props: {
|
||||
model: BaseModelCatalogItem;
|
||||
providerName?: string;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const types = readModelTypes(props.model);
|
||||
return (
|
||||
<article className="baseModelCard">
|
||||
<ModelIcon model={props.model} />
|
||||
<div className="baseModelCardBody">
|
||||
<div>
|
||||
<strong>{props.model.displayName}</strong>
|
||||
<span>{props.model.providerModelName}</span>
|
||||
</div>
|
||||
<div className="providerCatalogMeta">
|
||||
<Badge variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>
|
||||
<span>{props.providerName ?? props.model.providerKey}</span>
|
||||
<span>{props.model.canonicalModelKey}</span>
|
||||
</div>
|
||||
<div className="modelAbilityChips">
|
||||
{types.slice(0, 5).map((item) => <span key={item}>{item}</span>)}
|
||||
{types.length > 5 && <span>+{types.length - 5}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<Label className="spanTwo">
|
||||
{props.label}
|
||||
<Textarea value={props.value} onChange={(event) => props.onChange(event.target.value)} rows={5} spellCheck={false} />
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelIcon(props: { model: BaseModelCatalogItem }) {
|
||||
const iconPath = readString(props.model.metadata?.iconPath) || readString(readRecord(props.model.metadata?.rawModel)?.icon_path);
|
||||
if (iconPath) {
|
||||
return (
|
||||
<div className="providerCatalogLogo">
|
||||
<img src={iconPath} alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="providerCatalogLogo">{props.model.providerKey.slice(0, 2).toUpperCase()}</div>;
|
||||
}
|
||||
|
||||
function modelToForm(model: BaseModelCatalogItem): ModelForm {
|
||||
return {
|
||||
providerKey: model.providerKey,
|
||||
canonicalModelKey: model.canonicalModelKey,
|
||||
providerModelName: model.providerModelName,
|
||||
modelType: model.modelType,
|
||||
displayName: model.displayName,
|
||||
status: model.status,
|
||||
pricingVersion: String(model.pricingVersion || 1),
|
||||
capabilitiesJson: stringifyJson(model.capabilities),
|
||||
billingJson: stringifyJson(model.baseBillingConfig),
|
||||
rateLimitJson: stringifyJson(model.defaultRateLimitPolicy),
|
||||
metadataJson: stringifyJson(model.metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: ModelForm): BaseModelUpsertRequest {
|
||||
return {
|
||||
providerKey: form.providerKey.trim(),
|
||||
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
|
||||
providerModelName: form.providerModelName.trim(),
|
||||
modelType: form.modelType.trim(),
|
||||
displayName: form.displayName.trim() || form.providerModelName.trim(),
|
||||
capabilities: parseJsonObject(form.capabilitiesJson, '能力 JSON'),
|
||||
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
|
||||
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
|
||||
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
|
||||
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
function readModelTypes(model: BaseModelCatalogItem) {
|
||||
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
|
||||
if (Array.isArray(values)) return values.map(String);
|
||||
return [model.modelType].filter(Boolean);
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string) {
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error(`${label} 必须是 JSON object`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('必须是')) throw err;
|
||||
throw new Error(`${label} 格式不正确`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJson(value?: Record<string, unknown>) {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function readRecord(value: unknown) {
|
||||
return value && !Array.isArray(value) && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Calculator, Plus, Trash2 } from 'lucide-react';
|
||||
import type { PricingRuleInput } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Input, Label, Select } from '../../components/ui';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
type ModeKey = 'text' | 'image' | 'video' | 'digitalHuman' | 'speech' | 'music';
|
||||
|
||||
type ModeDefinition = {
|
||||
key: ModeKey;
|
||||
label: string;
|
||||
formula: string;
|
||||
match: (rule: PricingRuleInput) => boolean;
|
||||
templates: (currency: string) => PricingRuleInput[];
|
||||
weightGroups: Array<{ key: string; title: string; defaults: RecordValue }>;
|
||||
};
|
||||
|
||||
const currencies = ['resource', 'credit', 'cny', 'usd'];
|
||||
const calculators = ['token_usage', 'unit_weight', 'duration_weight', 'formula'];
|
||||
const statuses = ['active', 'deprecated', 'hidden'];
|
||||
|
||||
const modeDefinitions: ModeDefinition[] = [
|
||||
{
|
||||
key: 'text',
|
||||
label: '大语言模型',
|
||||
formula: '扣费 = 输入 Token 单价 × 输入 Token 数 / 1000 + 输出 Token 单价 × 输出 Token 数 / 1000。',
|
||||
match: (rule) => rule.resourceType.startsWith('text_'),
|
||||
templates: (currency) => [
|
||||
createRule('text_input_tokens', '文本输入 Token', 'text_input', '1k_tokens', 0.01, currency, 'token_usage', 'ceil(input_tokens / 1000) * base_price', { meter: 'input_tokens' }, {}, { metrics: ['input_tokens'], unitScale: 1000 }),
|
||||
createRule('text_output_tokens', '文本输出 Token', 'text_output', '1k_tokens', 0.03, currency, 'token_usage', 'ceil(output_tokens / 1000) * base_price', { meter: 'output_tokens' }, {}, { metrics: ['output_tokens'], unitScale: 1000 }),
|
||||
],
|
||||
weightGroups: [],
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
label: '图像生成',
|
||||
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重,例如 2K 或高清质量会按对应倍数计费。',
|
||||
match: (rule) => rule.resourceType === 'image' || rule.resourceType === 'image_edit',
|
||||
templates: (currency) => [
|
||||
createRule('image_generation', '图像生成', 'image', 'image', 10, currency, 'unit_weight', 'count * base_price * resolution_weight * quality_weight * mode_weight', { meter: 'count', base: 1 }, {
|
||||
resolutionWeights: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 },
|
||||
qualityWeights: { standard: 1, hd: 1.5 },
|
||||
modeWeights: { generation: 1 },
|
||||
}, { dimensions: ['count', 'resolution', 'quality', 'mode'], defaults: { count: 1, resolution: '1024x1024', quality: 'standard', mode: 'generation' } }),
|
||||
],
|
||||
weightGroups: [
|
||||
{ key: 'resolutionWeights', title: '分辨率', defaults: { '512x512': 0.5, '1024x1024': 1, '2K': 1.5, '4K': 2 } },
|
||||
{ key: 'qualityWeights', title: '图像质量', defaults: { standard: 1, hd: 1.5 } },
|
||||
{ key: 'referenceImageWeights', title: '参考图数量', defaults: { single: 1, multiple: 1.3 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
label: '视频生成',
|
||||
formula: '扣费 = 基础单价 × 基础权重 × 动态参数权重。例如生成 1080p 且带音频时,会叠加分辨率和音频权重。',
|
||||
match: (rule) => rule.resourceType === 'video',
|
||||
templates: (currency) => [
|
||||
createRule('video_generation', '视频生成', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_weight * audio_weight * reference_video_weight * voice_specified_weight', { meter: 'duration', unitSeconds: 5, base: 1 }, {
|
||||
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
|
||||
audioWeights: { true: 2, false: 1 },
|
||||
referenceVideoWeights: { true: 1.5, false: 1 },
|
||||
voiceSpecifiedWeights: { true: 1.2, false: 1 },
|
||||
}, { dimensions: ['count', 'duration_seconds', 'resolution', 'audio', 'reference_video', 'voice_specified'], defaults: { count: 1, duration_seconds: 5, resolution: '720p', audio: false, reference_video: false, voice_specified: false } }),
|
||||
],
|
||||
weightGroups: [
|
||||
{ key: 'resolutionWeights', title: '分辨率', defaults: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 } },
|
||||
{ key: 'audioWeights', title: '是否生成音频', defaults: { true: 2, false: 1 } },
|
||||
{ key: 'referenceVideoWeights', title: '是否含有参考视频', defaults: { true: 1.5, false: 1 } },
|
||||
{ key: 'voiceSpecifiedWeights', title: '是否指定音色', defaults: { true: 1.2, false: 1 } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'digitalHuman',
|
||||
label: '数字人生成',
|
||||
formula: '扣费 = 基础单价 × 生成时长 × 分辨率、音频驱动等动态参数权重。',
|
||||
match: (rule) => rule.resourceType === 'digital_human',
|
||||
templates: (currency) => [createRule('digital_human_generation', '数字人生成', 'digital_human', 'second', 2, currency, 'duration_weight', 'duration_seconds * base_price * resolution_weight', { meter: 'duration_seconds', base: 1 }, { resolutionWeights: { '720p': 1, '1080p': 1.5 } }, { dimensions: ['duration_seconds', 'resolution'], defaults: { duration_seconds: 10, resolution: '720p' } })],
|
||||
weightGroups: [{ key: 'resolutionWeights', title: '分辨率', defaults: { '720p': 1, '1080p': 1.5 } }],
|
||||
},
|
||||
{
|
||||
key: 'speech',
|
||||
label: '语音合成',
|
||||
formula: '扣费 = 基础单价 × 字符数 / 1000 × 声音或质量权重。',
|
||||
match: (rule) => rule.resourceType === 'audio',
|
||||
templates: (currency) => [createRule('speech_generation', '语音合成', 'audio', 'character_1k', 1, currency, 'unit_weight', 'ceil(characters / 1000) * base_price * voice_weight', { meter: 'characters', base: 1 }, { voiceWeights: { standard: 1, premium: 1.5 } }, { dimensions: ['characters', 'voice'], defaults: { voice: 'standard' } })],
|
||||
weightGroups: [{ key: 'voiceWeights', title: '音色', defaults: { standard: 1, premium: 1.5 } }],
|
||||
},
|
||||
{
|
||||
key: 'music',
|
||||
label: '音乐生成',
|
||||
formula: '扣费 = 基础单价 × 生成数量 × 时长或质量权重。',
|
||||
match: (rule) => rule.resourceType === 'music',
|
||||
templates: (currency) => [createRule('music_generation', '音乐生成', 'music', 'item', 20, currency, 'unit_weight', 'count * base_price * duration_weight * quality_weight', { meter: 'count', base: 1 }, { durationWeights: { short: 1, long: 1.8 }, qualityWeights: { standard: 1, high: 1.5 } }, { dimensions: ['count', 'duration', 'quality'], defaults: { count: 1, duration: 'short', quality: 'standard' } })],
|
||||
weightGroups: [
|
||||
{ key: 'durationWeights', title: '时长', defaults: { short: 1, long: 1.8 } },
|
||||
{ key: 'qualityWeights', title: '质量', defaults: { standard: 1, high: 1.5 } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function PricingRuleVisualEditor(props: {
|
||||
currency: string;
|
||||
rules: PricingRuleInput[];
|
||||
onChange: (rules: PricingRuleInput[]) => void;
|
||||
}) {
|
||||
const [activeMode, setActiveMode] = useState<ModeKey>('video');
|
||||
const mode = modeDefinitions.find((item) => item.key === activeMode) ?? modeDefinitions[0];
|
||||
const activeRules = useMemo(() => props.rules.filter(mode.match), [mode, props.rules]);
|
||||
|
||||
function replaceModeRules(nextRules: PricingRuleInput[]) {
|
||||
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
|
||||
}
|
||||
|
||||
function completeMissing() {
|
||||
const existing = activeRules.length ? activeRules : mode.templates(props.currency);
|
||||
replaceModeRules(existing.map((rule) => completeRule(rule, mode)));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pricingModeEditor spanTwo">
|
||||
<div className="pricingModeHeader">
|
||||
<div>
|
||||
<strong>计费模式</strong>
|
||||
<span>选择能力后维护基础价格、计费公式和动态权重。</span>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={completeMissing}>一键补全缺失</Button>
|
||||
</div>
|
||||
<div className="pricingModeTabs">
|
||||
{modeDefinitions.map((item) => (
|
||||
<button className={item.key === activeMode ? 'active' : ''} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>{item.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeRules.length ? (
|
||||
activeRules.map((rule, index) => (
|
||||
<ModeRuleEditor
|
||||
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
|
||||
mode={mode}
|
||||
rule={rule}
|
||||
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
|
||||
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="pricingModeEmpty">
|
||||
<strong>{mode.label} 还没有计价配置</strong>
|
||||
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} />添加默认配置</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeRuleEditor(props: {
|
||||
mode: ModeDefinition;
|
||||
rule: PricingRuleInput;
|
||||
onChange: (rule: PricingRuleInput) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const rule = props.rule;
|
||||
const baseWeight = Number((rule.baseWeight ?? {}).base ?? 1);
|
||||
const formula = String((rule.formulaConfig ?? {}).formula ?? props.mode.formula);
|
||||
|
||||
function patch(patchValue: Partial<PricingRuleInput>) {
|
||||
props.onChange({ ...rule, ...patchValue });
|
||||
}
|
||||
|
||||
function patchDynamicWeight(key: string, value: RecordValue) {
|
||||
patch({ dynamicWeight: { ...(rule.dynamicWeight ?? {}), [key]: value } });
|
||||
}
|
||||
|
||||
function addCustomGroup() {
|
||||
patchDynamicWeight(nextKey(rule.dynamicWeight ?? {}, 'customWeights'), {});
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="pricingModeRule">
|
||||
<header>
|
||||
<div>
|
||||
<Badge variant="secondary">{rule.resourceType}</Badge>
|
||||
<strong>{rule.displayName || props.mode.label}</strong>
|
||||
<span>{rule.ruleKey}</span>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} />删除</Button>
|
||||
</header>
|
||||
|
||||
<div className="pricingModeBaseRows">
|
||||
<Label>规则 Key<Input value={rule.ruleKey ?? ''} onChange={(event) => patch({ ruleKey: event.target.value })} /></Label>
|
||||
<Label>展示名称<Input value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} /></Label>
|
||||
<Label>资源类型<Input value={rule.resourceType} onChange={(event) => patch({ resourceType: event.target.value })} /></Label>
|
||||
<Label>计算方式<Select value={rule.calculatorType ?? 'unit_weight'} onChange={(event) => patch({ calculatorType: event.target.value })}>{calculators.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
|
||||
<Label>货币<Select value={rule.currency ?? 'resource'} onChange={(event) => patch({ currency: event.target.value })}>{currencies.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
|
||||
<Label>状态<Select value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
|
||||
</div>
|
||||
|
||||
<div className="pricingModeInlineRows">
|
||||
<div className="pricingModeInlineInput"><span>基础单价/{rule.unit}</span><Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} /></div>
|
||||
<div className="pricingModeInlineInput"><span>基础权重</span><Input min="0" step="0.01" type="number" value={baseWeight} onChange={(event) => patch({ baseWeight: { ...(rule.baseWeight ?? {}), base: Number(event.target.value) } })} /></div>
|
||||
<div className="pricingModeInlineInput"><span>单位</span><Input value={rule.unit} onChange={(event) => patch({ unit: event.target.value })} /></div>
|
||||
</div>
|
||||
|
||||
<div className="pricingFormulaBox">
|
||||
<Calculator size={16} />
|
||||
<div>
|
||||
<strong>计费公式</strong>
|
||||
<Input value={formula} onChange={(event) => patch({ formulaConfig: { ...(rule.formulaConfig ?? {}), formula: event.target.value } })} />
|
||||
<p>{props.mode.formula}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pricingWeightStack">
|
||||
{props.mode.weightGroups.map((group) => (
|
||||
<WeightTable
|
||||
key={group.key}
|
||||
title={group.title}
|
||||
value={readGroup(rule.dynamicWeight, group)}
|
||||
onChange={(value) => patchDynamicWeight(group.key, value)}
|
||||
/>
|
||||
))}
|
||||
{Object.entries(rule.dynamicWeight ?? {}).filter(([key]) => !props.mode.weightGroups.some((group) => group.key === key)).map(([key, value]) => (
|
||||
<WeightTable key={key} editableTitle title={key} value={isPlainObject(value) ? value as RecordValue : { value }} onChange={(nextValue, nextTitle) => {
|
||||
const dynamicWeight = { ...(rule.dynamicWeight ?? {}) };
|
||||
delete dynamicWeight[key];
|
||||
dynamicWeight[nextTitle || key] = nextValue;
|
||||
patch({ dynamicWeight });
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button className="pricingAddWeightButton" type="button" variant="outline" onClick={addCustomGroup}><Plus size={15} />添加自定义计费权重</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function WeightTable(props: {
|
||||
editableTitle?: boolean;
|
||||
title: string;
|
||||
value: RecordValue;
|
||||
onChange: (value: RecordValue, title?: string) => void;
|
||||
}) {
|
||||
const rows: Array<[string, unknown]> = Object.entries(props.value ?? {});
|
||||
|
||||
function updateRows(nextRows: Array<[string, unknown]>) {
|
||||
props.onChange(rowsToRecord(nextRows), props.title);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pricingWeightTable">
|
||||
<header>
|
||||
{props.editableTitle ? (
|
||||
<Input value={props.title} onChange={(event) => props.onChange(props.value, event.target.value)} />
|
||||
) : (
|
||||
<strong>{props.title}</strong>
|
||||
)}
|
||||
</header>
|
||||
<div className="pricingWeightRows">
|
||||
{rows.map(([key, value], index) => (
|
||||
<div className="pricingWeightRow" key={`${key}-${index}`}>
|
||||
<Input value={key} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [event.target.value, value] : row)))} />
|
||||
<Input value={scalarToString(value)} onChange={(event) => updateRows(rows.map((row, rowIndex) => (rowIndex === index ? [key, parseScalar(event.target.value)] : row)))} />
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}><Trash2 size={14} /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button className="pricingInlineAdd" type="button" variant="outline" size="sm" onClick={() => updateRows([...rows, [nextKey(props.value, 'option'), 1]])}><Plus size={14} />添加</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyValueEditor(props: {
|
||||
className?: string;
|
||||
title: string;
|
||||
value: RecordValue;
|
||||
onChange: (value: RecordValue) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('pricingWeightTable', props.className)}>
|
||||
<header><strong>{props.title}</strong></header>
|
||||
<WeightTable title={props.title} value={props.value} onChange={props.onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function completeRule(rule: PricingRuleInput, mode: ModeDefinition): PricingRuleInput {
|
||||
return {
|
||||
...rule,
|
||||
dynamicWeight: {
|
||||
...Object.fromEntries(mode.weightGroups.map((group) => [group.key, { ...group.defaults, ...(isPlainObject(rule.dynamicWeight?.[group.key]) ? rule.dynamicWeight?.[group.key] as RecordValue : {}) }])),
|
||||
...(rule.dynamicWeight ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readGroup(value: RecordValue | undefined, group: ModeDefinition['weightGroups'][number]) {
|
||||
return isPlainObject(value?.[group.key]) ? value?.[group.key] as RecordValue : group.defaults;
|
||||
}
|
||||
|
||||
function createRule(ruleKey: string, displayName: string, resourceType: string, unit: string, basePrice: number, currency: string, calculatorType: string, formula: string, baseWeight: RecordValue, dynamicWeight: RecordValue, dimensionSchema: RecordValue): PricingRuleInput {
|
||||
return {
|
||||
ruleKey,
|
||||
displayName,
|
||||
resourceType,
|
||||
unit,
|
||||
basePrice,
|
||||
currency,
|
||||
calculatorType,
|
||||
baseWeight,
|
||||
dynamicWeight,
|
||||
dimensionSchema,
|
||||
formulaConfig: { formula },
|
||||
priority: 100,
|
||||
status: 'active',
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function scalarToString(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function parseScalar(value: string): unknown {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return '';
|
||||
if (trimmed === 'true') return true;
|
||||
if (trimmed === 'false') return false;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try { return JSON.parse(trimmed) as unknown; } catch { return value; }
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function rowsToRecord(rows: Array<[string, unknown]>): RecordValue {
|
||||
return Object.fromEntries(rows.filter(([key]) => key.trim()).map(([key, value]) => [key.trim(), value]));
|
||||
}
|
||||
|
||||
function nextKey(value: RecordValue, prefix: string) {
|
||||
let index = 1;
|
||||
while (`${prefix}${index}` in value) index += 1;
|
||||
return `${prefix}${index}`;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
import { KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
|
||||
|
||||
type PricingForm = {
|
||||
ruleSetKey: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
metadata: Record<string, unknown>;
|
||||
rules: PricingRuleInput[];
|
||||
};
|
||||
|
||||
const emptyForm: PricingForm = {
|
||||
ruleSetKey: '',
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'custom',
|
||||
currency: 'resource',
|
||||
status: 'active',
|
||||
metadata: {},
|
||||
rules: [],
|
||||
};
|
||||
|
||||
const categories = ['default', 'custom', 'provider', 'model'];
|
||||
const statuses = ['active', 'deprecated', 'hidden'];
|
||||
const currencies = ['resource', 'credit', 'cny', 'usd'];
|
||||
|
||||
export function PricingRulesPanel(props: {
|
||||
message: string;
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
state: LoadState;
|
||||
onDeletePricingRuleSet: (ruleSetId: string) => Promise<void>;
|
||||
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<PricingForm>(emptyForm);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) return props.pricingRuleSets;
|
||||
return props.pricingRuleSets.filter((item) => `${item.ruleSetKey} ${item.name} ${item.description ?? ''}`.toLowerCase().includes(keyword));
|
||||
}, [props.pricingRuleSets, query]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onSavePricingRuleSet(formToPayload(form), editingId || undefined);
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '定价规则保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setForm(emptyForm);
|
||||
setLocalError('');
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editRuleSet(ruleSet: PricingRuleSet) {
|
||||
setEditingId(ruleSet.id);
|
||||
setForm(ruleSetToForm(ruleSet));
|
||||
setLocalError('');
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setEditingId('');
|
||||
setForm(emptyForm);
|
||||
setLocalError('');
|
||||
setDialogOpen(false);
|
||||
}
|
||||
|
||||
async function deleteRuleSet(ruleSet: PricingRuleSet) {
|
||||
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await props.onDeletePricingRuleSet(ruleSet.id);
|
||||
if (editingId === ruleSet.id) closeDialog();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : '定价规则删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>定价规则</CardTitle>
|
||||
<Badge variant="secondary">{props.pricingRuleSets.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>规则集可包含文本、图像、视频等多个计价条目,平台或平台模型绑定规则集后再通过折扣率整体调价。</p>
|
||||
<Button type="button" onClick={openCreateDialog}><Plus size={15} />新增规则</Button>
|
||||
</div>
|
||||
<div className="modelCatalogFilters">
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称 / key" />
|
||||
</div>
|
||||
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="pricingRuleGrid">
|
||||
{filtered.map((ruleSet) => (
|
||||
<article className="pricingRuleCard" key={ruleSet.id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{ruleSet.name}</strong>
|
||||
<span>{ruleSet.ruleSetKey}</span>
|
||||
</div>
|
||||
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
|
||||
</header>
|
||||
<p>{ruleSet.description || '未填写说明'}</p>
|
||||
<div className="providerCatalogMeta">
|
||||
<span>{ruleSet.category}</span>
|
||||
<span>{ruleSet.currency}</span>
|
||||
<span>{ruleSet.rules?.length ?? 0} 条计价规则</span>
|
||||
</div>
|
||||
<div className="pricingRuleItems">
|
||||
{(ruleSet.rules ?? []).slice(0, 5).map((rule) => (
|
||||
<span key={rule.id || rule.ruleKey}>{rule.displayName || rule.resourceType}: {rule.basePrice}/{rule.unit}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} />修改</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteRuleSet(ruleSet)}><Trash2 size={14} />删除</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!filtered.length && (
|
||||
<Card><CardContent className="emptyState"><strong>暂无定价规则</strong></CardContent></Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑定价规则' : '新增定价规则'}
|
||||
bodyClassName="pricingRuleFormBody"
|
||||
className="pricingRuleDialog"
|
||||
eyebrow={editingId ? 'Edit Pricing Rule Set' : 'New Pricing Rule Set'}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>{editingId ? <Pencil size={15} /> : <Plus size={15} />}{editingId ? '保存修改' : '新增规则'}</Button>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}><RotateCcw size={15} />取消</Button>
|
||||
</>
|
||||
)}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑定价规则' : '新增定价规则'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>规则 Key<Input value={form.ruleSetKey} onChange={(event) => setForm({ ...form, ruleSetKey: event.target.value })} /></Label>
|
||||
<Label>名称<Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
|
||||
<Label className="spanTwo">说明<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
|
||||
<Label>类型<Select value={form.category} onChange={(event) => setForm({ ...form, category: event.target.value })}>{categories.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
|
||||
<Label>货币<Select value={form.currency} onChange={(event) => setForm({ ...form, currency: event.target.value })}>{currencies.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
|
||||
<Label>状态<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{statuses.map((item) => <option value={item} key={item}>{item}</option>)}</Select></Label>
|
||||
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
|
||||
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ruleSetToForm(ruleSet: PricingRuleSet): PricingForm {
|
||||
return {
|
||||
ruleSetKey: ruleSet.ruleSetKey,
|
||||
name: ruleSet.name,
|
||||
description: ruleSet.description ?? '',
|
||||
category: ruleSet.category,
|
||||
currency: ruleSet.currency,
|
||||
status: ruleSet.status,
|
||||
metadata: ruleSet.metadata ?? {},
|
||||
rules: (ruleSet.rules ?? []).map(ruleToInput),
|
||||
};
|
||||
}
|
||||
|
||||
function ruleToInput(rule: PricingRule): PricingRuleInput {
|
||||
return {
|
||||
ruleKey: rule.ruleKey,
|
||||
displayName: rule.displayName,
|
||||
resourceType: rule.resourceType,
|
||||
unit: rule.unit,
|
||||
basePrice: rule.basePrice,
|
||||
currency: rule.currency,
|
||||
baseWeight: rule.baseWeight ?? {},
|
||||
dynamicWeight: rule.dynamicWeight ?? {},
|
||||
calculatorType: rule.calculatorType,
|
||||
dimensionSchema: rule.dimensionSchema ?? {},
|
||||
formulaConfig: rule.formulaConfig ?? {},
|
||||
priority: rule.priority,
|
||||
status: rule.status,
|
||||
metadata: rule.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(form: PricingForm): PricingRuleSetUpsertRequest {
|
||||
const rules = form.rules.map((rule, index) => ({
|
||||
...rule,
|
||||
displayName: rule.displayName?.trim() || `${rule.resourceType} 计价`,
|
||||
priority: rule.priority ?? (index + 1) * 10,
|
||||
ruleKey: rule.ruleKey?.trim() || `${rule.resourceType}_${index + 1}`,
|
||||
}));
|
||||
if (!rules.length) throw new Error('计价规则至少需要一条');
|
||||
return {
|
||||
ruleSetKey: form.ruleSetKey.trim(),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
category: form.category,
|
||||
currency: form.currency,
|
||||
status: form.status,
|
||||
metadata: form.metadata,
|
||||
rules,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import type { CatalogProvider, CatalogProviderUpsertRequest } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
|
||||
import type { LoadState } from '../../types';
|
||||
|
||||
type ProviderForm = {
|
||||
code: string;
|
||||
displayName: string;
|
||||
providerType: string;
|
||||
iconPath: string;
|
||||
source: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const emptyForm: ProviderForm = {
|
||||
code: '',
|
||||
displayName: '',
|
||||
providerType: 'openai',
|
||||
iconPath: '',
|
||||
source: 'gateway',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const defaultSpecTypes = [
|
||||
'openai',
|
||||
'azure',
|
||||
'google-gemini',
|
||||
'LiblibAI',
|
||||
'keling',
|
||||
'runninghub',
|
||||
'blackforest',
|
||||
'dify',
|
||||
'ollama',
|
||||
'jimeng',
|
||||
'volces',
|
||||
'tripo3d',
|
||||
'tencent-hunyuan',
|
||||
'tencent-hunyuan-image',
|
||||
'tencent-hunyuan-video',
|
||||
'suno',
|
||||
'minimax',
|
||||
'midjourney',
|
||||
'tencent-lke',
|
||||
'easyai',
|
||||
'aliyun-bailian',
|
||||
'universal',
|
||||
'newapi',
|
||||
'vidu',
|
||||
'mock-test',
|
||||
'n8n',
|
||||
];
|
||||
const providerStatuses = ['active', 'deprecated', 'hidden'];
|
||||
|
||||
export function ProviderManagementPanel(props: {
|
||||
message: string;
|
||||
providers: CatalogProvider[];
|
||||
state: LoadState;
|
||||
onDeleteProvider: (providerId: string) => Promise<void>;
|
||||
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [form, setForm] = useState<ProviderForm>(emptyForm);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const editingProvider = useMemo(
|
||||
() => props.providers.find((item) => item.id === editingId),
|
||||
[editingId, props.providers],
|
||||
);
|
||||
const specTypes = useMemo(
|
||||
() => Array.from(new Set([...defaultSpecTypes, ...props.providers.map((item) => item.providerType).filter(Boolean)])),
|
||||
[props.providers],
|
||||
);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const payload = providerPayload(form, editingProvider);
|
||||
try {
|
||||
await props.onSaveProvider(payload, editingId || undefined);
|
||||
closeDialog();
|
||||
} catch {
|
||||
// The parent surfaces the operation message; keep the current form for correction.
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditingId('');
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function editProvider(provider: CatalogProvider) {
|
||||
setEditingId(provider.id);
|
||||
setForm({
|
||||
code: provider.code,
|
||||
displayName: provider.displayName,
|
||||
providerType: provider.providerType,
|
||||
iconPath: provider.iconPath ?? '',
|
||||
source: provider.source ?? 'gateway',
|
||||
status: provider.status,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setEditingId('');
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(false);
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: CatalogProvider) {
|
||||
const confirmed = window.confirm(`确认删除模型厂商 ${provider.displayName}?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await props.onDeleteProvider(provider.id);
|
||||
if (editingId === provider.id) {
|
||||
closeDialog();
|
||||
}
|
||||
} catch {
|
||||
// The parent surfaces the operation message.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pageStack">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>模型厂商</CardTitle>
|
||||
<Badge variant="secondary">{props.providers.length}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="providerToolbar">
|
||||
<p>默认数据来自原 server-main integration-platform,包含厂商名称、code 和图标。</p>
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={15} />
|
||||
新增厂商
|
||||
</Button>
|
||||
</div>
|
||||
{props.message && <p className="formMessage">{props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="providerCatalogGrid">
|
||||
{props.providers.map((provider) => (
|
||||
<article className="providerCatalogCard" key={provider.id}>
|
||||
<ProviderLogo provider={provider} />
|
||||
<div className="providerCatalogBody">
|
||||
<div>
|
||||
<strong>{provider.displayName}</strong>
|
||||
<span>Code: {provider.code}</span>
|
||||
</div>
|
||||
<div className="providerCatalogMeta">
|
||||
<Badge variant={provider.status === 'active' ? 'success' : 'secondary'}>{provider.status}</Badge>
|
||||
<span>spec_type: {provider.providerType}</span>
|
||||
<span>{provider.source ?? 'gateway'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="providerCatalogActions">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => editProvider(provider)}>
|
||||
<Pencil size={14} />
|
||||
修改
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteProvider(provider)}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!props.providers.length && (
|
||||
<Card>
|
||||
<CardContent className="emptyState">
|
||||
<strong>暂无模型厂商</strong>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<FormDialog
|
||||
ariaLabel={editingId ? '编辑模型厂商' : '新增模型厂商'}
|
||||
eyebrow={editingId ? 'Edit Provider' : 'New Provider'}
|
||||
footer={(
|
||||
<>
|
||||
<Button type="submit" disabled={props.state === 'loading'}>
|
||||
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
|
||||
{editingId ? '保存修改' : '新增厂商'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||
<RotateCcw size={15} />
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={dialogOpen}
|
||||
title={editingId ? '编辑模型厂商' : '新增模型厂商'}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Label>
|
||||
Code
|
||||
<Input value={form.code} onChange={(event) => setForm({ ...form, code: event.target.value })} placeholder="integration-platform code" />
|
||||
</Label>
|
||||
<Label>
|
||||
名称
|
||||
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} placeholder="OpenAI" />
|
||||
</Label>
|
||||
<Label>
|
||||
类型 spec_type
|
||||
<Select value={form.providerType} onChange={(event) => setForm({ ...form, providerType: event.target.value })}>
|
||||
{specTypes.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
<Label className="spanTwo">
|
||||
图标 URL
|
||||
<Input value={form.iconPath} onChange={(event) => setForm({ ...form, iconPath: event.target.value })} placeholder="https://static.51easyai.com/xxx.png" />
|
||||
</Label>
|
||||
<Label>
|
||||
来源
|
||||
<Input value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} />
|
||||
</Label>
|
||||
<Label>
|
||||
状态
|
||||
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
|
||||
{providerStatuses.map((item) => <option value={item} key={item}>{item}</option>)}
|
||||
</Select>
|
||||
</Label>
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderLogo(props: { provider: CatalogProvider }) {
|
||||
if (props.provider.iconPath) {
|
||||
return (
|
||||
<div className="providerCatalogLogo">
|
||||
<img src={props.provider.iconPath} alt="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="providerCatalogLogo">{providerInitials(props.provider.displayName)}</div>;
|
||||
}
|
||||
|
||||
function providerPayload(form: ProviderForm, current?: CatalogProvider): CatalogProviderUpsertRequest {
|
||||
const code = form.code.trim();
|
||||
return {
|
||||
providerKey: current?.providerKey ?? code,
|
||||
code,
|
||||
displayName: form.displayName.trim(),
|
||||
providerType: form.providerType.trim() || 'openai',
|
||||
iconPath: form.iconPath.trim(),
|
||||
source: form.source.trim() || 'gateway',
|
||||
status: form.status,
|
||||
capabilitySchema: current?.capabilitySchema ?? {},
|
||||
defaultRateLimitPolicy: current?.defaultRateLimitPolicy ?? {},
|
||||
metadata: current?.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function providerInitials(label: string) {
|
||||
return label
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'AI';
|
||||
}
|
||||
Reference in New Issue
Block a user