style(admin): 管理后台 - 统一工作区与模型编辑器布局

This commit is contained in:
LUHANG0 authored and ddCat committed 2026-08-26 14:41:46 +08:00
1 parent c8a073d5ae
commit cbf5b9f054
10 files changed
+2505 -544

No files matched your search

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd";
import type { ColumnsType } from "antd/es/table";
import { FlaskConical, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIcon } from "@/components/model-picker";
@@ -23,18 +23,18 @@ type FormValues = {
displayName?: string;
capability: EditableCapability;
protocol?: ModelProtocol;
priceTiers: PriceTierFormValues[];
priceTiers: PriceTierFormValues[];
enabled: boolean;
capabilityConfig?: ModelCapabilityConfig;
};
type PriceTierFormValues = {
operation: string;
quality: string;
size: string;
resolution: string;
videoSeconds: number;
imageCount: number;
operation: string;
quality: string;
size: string;
resolution: string;
videoSeconds: number;
imageCount: number;
providerModelKey?: string;
billingMode: ChannelModel["billingMode"];
unitPrice: number;
@@ -65,7 +65,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const modelProtocol = Form.useWatch("protocol", form);
const modelKey = Form.useWatch("modelKey", form) || "";
const providerModelKey = Form.useWatch("providerModelKey", form) || "";
const capabilityConfig = Form.useWatch("capabilityConfig", form);
const capabilityConfig = Form.useWatch("capabilityConfig", form);
const modelEnabled = Form.useWatch("enabled", form) !== false;
const reload = async () => {
if (!channel) return;
@@ -115,7 +116,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
displayName: "",
capability: "text",
protocol: availableProtocols.find((item) => item.capability === "text")?.value,
priceTiers: [defaultPriceTier()],
priceTiers: [defaultPriceTier()],
enabled: true,
capabilityConfig: defaultModelCapabilityConfig(availableProtocols.find((item) => item.capability === "text")?.value, ""),
});
@@ -130,11 +131,12 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
displayName: item.displayName,
capability: item.capability || undefined,
protocol: item.protocol,
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
enabled: item.enabled,
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video"
? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey))
: undefined,
capabilityConfig:
item.capability === "text" || item.capability === "image" || item.capability === "video"
? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey))
: undefined,
});
setEditorOpen(true);
};
@@ -142,9 +144,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const save = async () => {
const values = await form.validateFields();
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel))
: undefined;
const capabilityConfig =
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
setSaving(true);
try {
const payload = {
@@ -153,19 +154,19 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
displayName: values.displayName?.trim() || values.modelKey.trim(),
capability: values.capability,
protocol: values.protocol,
priceTiers: values.priceTiers.map((tier) => ({
selector: skuSelectorFromForm(values.capability, tier),
resolution: values.capability === "video" ? (tier.resolution || "*") : "*",
videoSeconds: values.capability === "video" ? Number(tier.videoSeconds || 0) : 0,
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
billingMode: tier.billingMode,
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: tier.priceConfigured !== false,
enabled: tier.enabled !== false,
})),
priceTiers: values.priceTiers.map((tier) => ({
selector: skuSelectorFromForm(values.capability, tier),
resolution: values.capability === "video" ? tier.resolution || "*" : "*",
videoSeconds: values.capability === "video" ? Number(tier.videoSeconds || 0) : 0,
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
billingMode: tier.billingMode,
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: tier.priceConfigured !== false,
enabled: tier.enabled !== false,
})),
enabled: values.enabled !== false,
capabilityConfig,
};
@@ -186,9 +187,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const testModel = async () => {
const values = await form.validateFields(["modelKey", "providerModelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]);
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel))
: undefined;
const capabilityConfig =
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
setTesting(true);
try {
const result = await testAdminChannelModel(channel.id, {
@@ -228,17 +228,17 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
form.setFieldValue("protocol", nextProtocol);
form.setFieldValue("capabilityConfig", changed.capability === "text" || changed.capability === "image" || changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol, form.getFieldValue("modelKey")) : undefined);
}
const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({
...tier,
operation: tier.operation || "*",
quality: changed.capability === "image" ? tier.quality || "*" : "*",
size: changed.capability === "image" ? tier.size || "*" : "*",
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0,
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
}));
form.setFieldValue("priceTiers", nextTiers);
const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({
...tier,
operation: tier.operation || "*",
quality: changed.capability === "image" ? tier.quality || "*" : "*",
size: changed.capability === "image" ? tier.size || "*" : "*",
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0,
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
}));
form.setFieldValue("priceTiers", nextTiers);
};
const columns: ColumnsType<ChannelModel> = [
@@ -272,7 +272,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<AdminStatusBadge label="待配置" tone="warning" />
),
},
{ title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
{ title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => <AdminStatusBadge label={enabled ? "启用" : "停用"} tone={enabled ? "success" : "neutral"} /> },
{
@@ -317,18 +317,50 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
>
<AdminDataTable
toolbar={<Input
allowClear
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索模型标识或显示名称"
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
/>}
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => { setKeyword(""); setPage(1); }} /> : null}{capability !== "all" ? <AdminFilterChip label={`能力:${capability}`} onRemove={() => { setCapability("all"); setPage(1); }} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${status === "enabled" ? "已启用" : "已停用"}`} onRemove={() => { setStatus("all"); setPage(1); }} /> : null}</>}
toolbar={
<Input
allowClear
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索模型标识或显示名称"
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
/>
}
toolbarActiveFilters={
<>
{keyword ? (
<AdminFilterChip
label={`搜索:${keyword}`}
onRemove={() => {
setKeyword("");
setPage(1);
}}
/>
) : null}
{capability !== "all" ? (
<AdminFilterChip
label={`能力:${capability}`}
onRemove={() => {
setCapability("all");
setPage(1);
}}
/>
) : null}
{status !== "all" ? (
<AdminFilterChip
label={`状态:${status === "enabled" ? "已启用" : "已停用"}`}
onRemove={() => {
setStatus("all");
setPage(1);
}}
/>
) : null}
</>
}
toolbarActive={Boolean(keyword || capability !== "all" || status !== "all")}
toolbarFilters={
<>
@@ -339,7 +371,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
setCapability(value);
setPage(1);
}}
options={[{ label: "全部能力", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]}
options={[
{ label: "全部能力", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
]}
/>
<Select
className="w-32"
@@ -348,7 +386,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
setStatus(value);
setPage(1);
}}
options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]}
options={[
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "enabled" },
{ label: "已停用", value: "disabled" },
]}
/>
</>
}
@@ -368,20 +410,45 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
pagination: false,
scroll: { x: 990 },
}}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filteredItems.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
footer={
<PaginationBar
alwaysShow
current={page}
pageSize={pageSize}
total={filteredItems.length}
onChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage);
setPageSize(nextPageSize);
}}
/>
}
/>
<Drawer
title={editing ? `编辑模型 / ${editing.displayName || editing.modelKey}` : "新增模型"}
open={editorOpen}
size="min(1080px, 100vw)"
onClose={() => !saving && setEditorOpen(false)}
rootClassName="admin-drawer"
rootClassName="admin-drawer admin-model-editor-drawer"
footer={
<div className="flex items-center justify-between gap-3">
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>测试模型</Button>
<div className="flex items-center gap-2">
<Button disabled={saving || testing} onClick={() => setEditorOpen(false)}>取消</Button>
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>{editing ? "保存修改" : "添加模型"}</Button>
<div className="admin-model-editor-footer-actions flex items-center justify-between gap-3">
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>
测试模型
</Button>
<div className="admin-model-editor-footer-primary flex items-center gap-2">
<div className="admin-model-editor-footer-status">
<span className={modelEnabled ? "is-enabled" : ""} />
<div>
<strong>{modelEnabled ? "模型启用" : "模型停用"}</strong>
<small>保存后生效</small>
</div>
<Switch aria-label="启用模型" checked={modelEnabled} disabled={saving || testing} onChange={(checked) => form.setFieldValue("enabled", checked)} />
</div>
<Button disabled={saving || testing} onClick={() => setEditorOpen(false)}>
取消
</Button>
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>
{editing ? "保存修改" : "添加模型"}
</Button>
</div>
</div>
}
@@ -393,12 +460,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
) : null
}
>
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">模型身份</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Form className="admin-model-editor-form" form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
<Form.Item name="capabilityConfig" noStyle>
<CapabilityConfigField />
</Form.Item>
<Form.Item name="enabled" noStyle>
<EnabledConfigField />
</Form.Item>
<section className="admin-form-section admin-model-editor-section">
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
<div className="admin-model-editor-section-content admin-model-identity-grid">
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, message: "请输入产品模型标识" }]}>
<Input
prefix={
@@ -418,58 +489,93 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</div>
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">能力与协议</h2>
</div>
<div className="space-y-4">
<Form.Item className="mb-0" name="capability" label="模型能力" rules={[{ required: true }]}>
<section className="admin-form-section admin-model-editor-section">
<SectionHeading title="模型能力" description="决定模型在前台可用于哪类生成任务。" />
<div className="admin-model-editor-section-content">
<Form.Item className="mb-0" name="capability" rules={[{ required: true }]}>
<CapabilityCardPicker density="compact" />
</Form.Item>
{availableProtocols.length ? <Form.Item className="mb-0" name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
<ProtocolCardPicker capability={modelCapability} density="compact" protocols={availableProtocols} />
</Form.Item> : null}
</div>
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
<Form.Item name="capabilityConfig" rules={[{ required: true, message: `请配置${capabilityLabel(modelCapability)}能力参数` }]}>
<ModelCapabilityEditor capability={modelCapability} model={providerModelKey || modelKey} protocol={form.getFieldValue("protocol")} />
</Form.Item>
) : null}
</section>
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">规格价格档</h2>
</div>
<Form.List name="priceTiers" rules={[{ validator: async (_, value) => { if (!value?.length) throw new Error("请至少配置一个价格档"); } }]}>
{(fields, { add, remove }, { errors }) => (
<div className="space-y-2">
{fields.map((field, index) => (
<PriceTierFields
key={field.key}
index={field.name}
ordinal={index + 1}
form={form}
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
onRemove={() => remove(field.name)}
/>
))}
<Button icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier())}>新增价格档</Button>
<Form.ErrorList errors={errors} />
</div>
)}
</Form.List>
</section>
{availableProtocols.length ? (
<section className="admin-form-section admin-model-editor-section">
<SectionHeading title="请求协议" description="选择发送到上游的接口格式与响应处理方式。" />
<div className="admin-model-editor-section-content">
<Form.Item className="mb-0" name="protocol" rules={[{ required: true, message: "请选择模型请求协议" }]}>
<ProtocolCardPicker capability={modelCapability} density="compact" protocols={availableProtocols} />
</Form.Item>
</div>
</section>
) : null}
<section className="admin-form-section">
<div className="mb-4">
<h2 className="text-sm font-semibold">启用状态</h2>
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
<section className="admin-form-section admin-model-editor-section admin-model-editor-references">
<SectionHeading title="引用与限制" description="按媒体类型纵向配置数量、大小、时长及通用约束。" />
<div className="admin-model-editor-section-content">
<ModelCapabilityEditor
capability={modelCapability}
model={providerModelKey || modelKey}
protocol={form.getFieldValue("protocol")}
section="references"
value={capabilityConfig}
onChange={(next) => form.setFieldValue("capabilityConfig", next)}
/>
</div>
</section>
) : null}
{modelCapability === "image" || modelCapability === "video" ? (
<section className="admin-form-section admin-model-editor-section admin-model-editor-parameters">
<SectionHeading title="协议参数" description="配置可发送参数、支持值与默认值;仅影响当前模型。" />
<div className="admin-model-editor-section-content">
<ModelCapabilityEditor
capability={modelCapability}
model={providerModelKey || modelKey}
protocol={form.getFieldValue("protocol")}
section="protocol"
value={capabilityConfig}
onChange={(next) => form.setFieldValue("capabilityConfig", next)}
/>
</div>
</section>
) : null}
<section className="admin-form-section admin-model-editor-section">
<SectionHeading title="规格价格档" description="按请求条件匹配上游模型与计费规则。" />
<div className="admin-model-editor-section-content">
<Form.List
name="priceTiers"
rules={[
{
validator: async (_, value) => {
if (!value?.length) throw new Error("请至少配置一个价格档");
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<div className="space-y-3">
{fields.map((field, index) => (
<PriceTierFields
key={field.key}
index={field.name}
ordinal={index + 1}
form={form}
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
onRemove={() => remove(field.name)}
/>
))}
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier())}>
新增价格档
</Button>
<Form.ErrorList errors={errors} />
</div>
)}
</Form.List>
</div>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</section>
</Form>
</Drawer>
@@ -477,6 +583,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
);
}
function SectionHeading({ title, description }: { title: string; description: string }) {
return (
<header className="admin-model-editor-section-heading">
<h2>{title}</h2>
<p>{description}</p>
</header>
);
}
function CapabilityConfigField(_: { value?: ModelCapabilityConfig; onChange?: (value: ModelCapabilityConfig) => void }) {
return null;
}
function EnabledConfigField(_: { value?: boolean; onChange?: (value: boolean) => void }) {
return null;
}
function capabilityLabel(value: ChannelModel["capability"]) {
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
}
@@ -502,81 +625,125 @@ function PriceTierFields({
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
const isVideo = capability === "video";
const isImage = capability === "image";
const selectorColumnClass = isVideo || isImage ? "lg:col-span-3" : "lg:col-span-6";
const controlsColumnClass = billingMode === "token" && !isVideo ? "lg:col-span-3" : "lg:col-span-5";
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
const isVideo = capability === "video";
const isImage = capability === "image";
return (
<div className="rounded-md border border-border bg-muted/10 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium">价格档 {ordinal}</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} title="删除价格档" icon={<X className="size-4" />} onClick={onRemove} />
<div className="admin-price-tier-card">
<div className="admin-price-tier-card-header">
<div>
<div className="text-sm font-medium">价格档 {ordinal}</div>
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/45">请求按以下条件命中后,使用本档模型与价格。</div>
</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
删除
</Button>
</div>
<div className="grid gap-3 lg:grid-cols-12">
<Form.Item className={`mb-0 ${selectorColumnClass}`} name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
<Select options={operationOptions(capability)} />
</Form.Item>
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
<Select options={[{ label: "任意质量", value: "*" }, { label: "1K", value: "1k" }, { label: "2K", value: "2k" }, { label: "4K", value: "4k" }]} />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "size"]} label="画幅/尺寸">
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
</Form.Item>
) : null}
<Form.Item className={`mb-0 ${selectorColumnClass}`} name={[index, "providerModelKey"]} label="上游模型 ID">
<Input placeholder="留空则使用模型默认上游 ID" />
</Form.Item>
</div>
<div className="grid items-end gap-3 lg:grid-cols-12">
<Form.Item className="mb-0 lg:col-span-4" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
<Segmented
className="w-full"
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: "Token", value: "token", disabled: !tokenEnabled },
]}
/>
</Form.Item>
{billingMode === "token" ? (
isVideo ? (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
<div className="admin-price-tier-card-body">
<div className="admin-price-tier-block admin-price-tier-match-block">
<div className="admin-price-tier-block-title">
<span>01</span> 匹配条件
</div>
<div className="admin-price-tier-match-grid">
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
<Select options={operationOptions(capability)} />
</Form.Item>
) : (
<div className="grid gap-3 sm:grid-cols-3 lg:col-span-5">
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
{isVideo ? (
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
</Form.Item>
) : null}
{isVideo ? (
<Form.Item className="mb-0" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
<Select
options={[
{ label: "任意质量", value: "*" },
{ label: "1K", value: "1k" },
{ label: "2K", value: "2k" },
{ label: "4K", value: "4k" },
]}
/>
</Form.Item>
) : null}
{isImage ? (
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
</Form.Item>
) : null}
<Form.Item className="admin-price-tier-upstream mb-0" name={[index, "providerModelKey"]} label="命中后使用的上游模型 ID">
<Input placeholder="留空则使用模型默认上游 ID" />
</Form.Item>
</div>
</div>
<div className="admin-price-tier-block admin-price-tier-billing-block">
<div className="admin-price-tier-block-title">
<span>02</span> 计费与状态
</div>
<div className="admin-price-tier-billing-grid">
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
<Segmented
className="w-full"
options={[
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: "Token", value: "token", disabled: !tokenEnabled },
]}
/>
</Form.Item>
{billingMode === "token" ? (
isVideo ? (
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
) : (
<div className="admin-price-tier-token-grid">
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</div>
)
) : (
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
<div className="admin-price-tier-controls flex items-center gap-6">
<div className="admin-price-tier-toggle">
<div>
<strong>价格已配置</strong>
<span>允许按此价格计费</span>
</div>
<Form.Item name={[index, "priceConfigured"]} noStyle valuePropName="checked">
<Switch aria-label="价格已配置" />
</Form.Item>
</div>
<div className="admin-price-tier-toggle">
<div>
<strong>启用价格档</strong>
<span>允许参与请求匹配</span>
</div>
<Form.Item name={[index, "enabled"]} noStyle valuePropName="checked">
<Switch aria-label="启用价格档" />
</Form.Item>
</div>
</div>
)
) : (
<Form.Item className="mb-0 lg:col-span-3" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
<div className={`flex items-center gap-6 ${controlsColumnClass}`}>
<Form.Item name={[index, "priceConfigured"]} label="价格已配置" valuePropName="checked" className="mb-0"><Switch /></Form.Item>
<Form.Item name={[index, "enabled"]} label="启用此价格档" valuePropName="checked" className="mb-0"><Switch /></Form.Item>
</div>
</div>
</div>
</div>
@@ -584,32 +751,73 @@ function PriceTierFields({
}
function defaultPriceTier(): PriceTierFormValues {
return { operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, imageCount: 0, providerModelKey: "", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, priceConfigured: true, enabled: true };
return {
operation: "*",
quality: "*",
size: "*",
resolution: "*",
videoSeconds: 0,
imageCount: 0,
providerModelKey: "",
billingMode: "fixed_request",
unitPrice: 0,
inputTokenPrice: 0,
outputTokenPrice: 0,
cachedTokenPrice: 0,
priceConfigured: true,
enabled: true,
};
}
function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
return {
operation: tier.selector?.operation || "*", quality: tier.selector?.quality || "*", size: tier.selector?.size || "*",
resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, imageCount: Number(tier.selector?.imageCount || 0), providerModelKey: tier.providerModelKey || "", billingMode: tier.billingMode,
unitPrice: tier.unitPriceMicrocredits / 1_000_000, inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: tier.priceConfigured, enabled: tier.enabled,
operation: tier.selector?.operation || "*",
quality: tier.selector?.quality || "*",
size: tier.selector?.size || "*",
resolution: tier.resolution || "*",
videoSeconds: tier.videoSeconds || 0,
imageCount: Number(tier.selector?.imageCount || 0),
providerModelKey: tier.providerModelKey || "",
billingMode: tier.billingMode,
unitPrice: tier.unitPriceMicrocredits / 1_000_000,
inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: tier.priceConfigured,
enabled: tier.enabled,
};
}
function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
return {
operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, imageCount: 0, providerModelKey: item.providerModelKey || "", billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: item.priceConfigured, enabled: item.enabled,
operation: "*",
quality: "*",
size: "*",
resolution: "*",
videoSeconds: 0,
imageCount: 0,
providerModelKey: item.providerModelKey || "",
billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000,
inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
priceConfigured: item.priceConfigured,
enabled: item.enabled,
};
}
function billingSummary(item: ChannelModel) {
const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || [];
if (!tiers.length) return <AdminStatusBadge label="未配置价格" tone="warning" />;
return <div className="space-y-1 text-xs leading-5">{tiers.slice(0, 3).map((tier) => <div key={tier.id}>{priceTierLabel(tier)}</div>)}{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}</div>;
const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || [];
if (!tiers.length) return <AdminStatusBadge label="未配置价格" tone="warning" />;
return (
<div className="space-y-1 text-xs leading-5">
{tiers.slice(0, 3).map((tier) => (
<div key={tier.id}>{priceTierLabel(tier)}</div>
))}
{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}
</div>
);
}
function priceTierLabel(tier: ChannelModelPriceTier) {
@@ -619,8 +827,8 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
selector.size && selector.size !== "*" ? selector.size : "",
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
].filter(Boolean);
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
@@ -628,30 +836,30 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
}
function operationOptions(capability: EditableCapability | undefined) {
const options = [{ label: "任意生成方式", value: "*" }];
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
return options;
const options = [{ label: "任意生成方式", value: "*" }];
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
return options;
}
function operationLabel(operation: string) {
return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record<string, string>)[operation] || operation;
return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record<string, string>)[operation] || operation;
}
function skuSelectorFromForm(capability: EditableCapability, tier: PriceTierFormValues) {
const selector: Record<string, string> = {};
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
if (capability === "video") {
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
if (tier.size && tier.size !== "*") selector.size = tier.size;
}
return selector;
const selector: Record<string, string> = {};
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
if (capability === "video") {
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
if (tier.size && tier.size !== "*") selector.size = tier.size;
}
return selector;
}
function formatCredits(value: number) {