feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)

This commit is contained in:
Ray
2026-09-08 20:56:42 +08:00
committed by GitHub
parent 381edf37e3
commit 8360b2c933
41 changed files with 1485 additions and 148 deletions

View File

@@ -45,7 +45,14 @@ export async function applyUserSession(payload: AuthSessionPayload) {
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
useUserStore.getState().setFeatures(payload.features);
await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate(), useCreationPreferencesStore.persist.rehydrate()]);
await Promise.all([
useCanvasStore.persist.rehydrate(),
useCanvasHistoryStore.persist.rehydrate(),
useAssetStore.persist.rehydrate(),
useConfigStore.persist.rehydrate(),
usePluginStore.persist.rehydrate(),
useCreationPreferencesStore.persist.rehydrate(),
]);
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] });
@@ -139,12 +146,13 @@ function managedModelChannels(models: PublicLogicalModel[]) {
}
// 系统渠道模型转换为前端配置格式
function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] {
export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] {
return channels.map((channel) => {
const availableModels = channel.models.filter((m) => m.available);
return {
id: channel.id,
name: channel.displayName,
sortOrder: channel.sortOrder,
// 系统渠道必须走带渠道 ID 的站内代理;/api 只是业务 API 根路径,
// 不能作为模型请求的运行时 Base URL 传给 channelRequest。
baseUrl: `/api/${channel.id}`,

View File

@@ -17,8 +17,20 @@ import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
import { ChannelModelManager } from "../components/channel-model-manager";
import { ChannelOrderDialog } from "../components/channel-order-dialog";
type ChannelFormValues = { name: string; baseUrl: string; allowLocalChannel?: boolean; apiKey?: string; secretKey?: string; headers?: ChannelHeader[]; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
type ChannelFormValues = {
name: string;
publicAlias?: string;
baseUrl: string;
allowLocalChannel?: boolean;
apiKey?: string;
secretKey?: string;
headers?: ChannelHeader[];
useGlobalConcurrency?: boolean;
concurrencyLimit?: number;
enabled?: boolean;
};
export function adminLocalChannelFormOwner(desktopLocalChannelsEnabled: boolean, hostname: string, requestedAllowLocalChannel?: boolean) {
const state = desktopLocalChannelFormState(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel);
@@ -37,7 +49,9 @@ export function AdminLocalChannelSwitch({ visible, checked, onChange }: { visibl
export function AdminLocalChannelFields({ visible, checked, form }: { visible: boolean; checked: boolean; form: Pick<FormInstance<ChannelFormValues>, "setFieldValue"> }) {
return (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} /></Form.Item>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}>
<Input placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} />
</Form.Item>
<AdminLocalChannelSwitch visible={visible} checked={checked} onChange={(value) => form.setFieldValue("allowLocalChannel", value)} />
</>
);
@@ -46,6 +60,7 @@ export function AdminLocalChannelFields({ visible, checked, form }: { visible: b
export function adminChannelSavePayload(values: ChannelFormValues, desktopLocalChannelsEnabled: boolean, hostname: string) {
return {
name: values.name.trim(),
publicAlias: values.publicAlias?.trim() || "",
baseUrl: values.baseUrl.trim(),
allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, hostname, values.allowLocalChannel).payloadValue,
apiKey: values.apiKey?.trim() || "",
@@ -126,8 +141,23 @@ export default function ChannelsPage() {
const openDrawer = (channel?: ModelChannel) => {
setEditingChannel(channel || null);
form.resetFields();
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked, apiKey: "", secretKey: "", headers: channel.headers || [], useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", allowLocalChannel: false, apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
form.setFieldsValue(
channel
? {
name: channel.name,
baseUrl: channel.baseUrl,
allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked,
apiKey: "",
secretKey: "",
headers: channel.headers || [],
useGlobalConcurrency: !channel.concurrencyLimit,
concurrencyLimit: channel.concurrencyLimit || undefined,
enabled: channel.enabled !== false,
}
: { name: "", baseUrl: "", allowLocalChannel: false, apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true },
);
setDrawerOpen(true);
form.setFieldsValue({ publicAlias: channel?.publicAlias || "" });
};
const closeDrawer = () => {
@@ -189,27 +219,142 @@ export default function ChannelsPage() {
};
const columns: ColumnsType<ModelChannel> = [
{ title: "渠道", dataIndex: "name", render: (_, channel) => <div><div className="font-medium">{channel.name}</div><div className="admin-monospace max-w-lg truncate text-foreground/45">{channel.baseUrl}</div></div> },
{
title: "渠道",
dataIndex: "name",
render: (_, channel) => (
<div>
<div className="font-medium">{channel.name}</div>
<div className="admin-monospace max-w-lg truncate text-foreground/45">{channel.baseUrl}</div>
</div>
),
},
{ title: "前台名称", width: 160, render: (_, channel) => <span className={channel.publicAlias ? "" : "text-foreground/45"}>{channel.publicAlias || channel.name}</span> },
{ title: "模型", dataIndex: "models", width: 100, align: "center", render: (models: string[]) => `${models?.length || 0} 个` },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => value > 0 ? value : <span className="text-foreground/45">跟随系统</span> },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : <span className="text-foreground/45">跟随系统</span>) },
{ title: "凭证", width: 130, align: "center", render: (_, channel) => <AdminStatusBadge label={channel.hasApiKey ? (channel.hasSecretKey ? "AK/SK 已配置" : "API Key 已配置") : "未配置"} tone={channel.hasApiKey ? "success" : "neutral"} /> },
{ title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (enabled) => <AdminStatusBadge label={enabled !== false ? "已启用" : "已停用"} tone={enabled !== false ? "success" : "neutral"} /> },
{ title: "操作", width: 250, align: "center", render: (_, channel) => <AdminRowActions primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }} actions={[{ key: "edit", label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", icon: <Power className="size-3.5" />, danger: channel.enabled !== false, confirm: { title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", okText: channel.enabled !== false ? "确认停用" : "确认启用" }, onClick: () => toggleChannel(channel) }, { key: "delete", label: "删除渠道", icon: <Trash2 className="size-3.5" />, danger: true, confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, onClick: () => removeChannel(channel) }]} /> },
{
title: "操作",
width: 250,
align: "center",
render: (_, channel) => (
<AdminRowActions
primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }}
actions={[
{ key: "edit", label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) },
{
key: "toggle",
label: channel.enabled !== false ? "停用渠道" : "启用渠道",
icon: <Power className="size-3.5" />,
danger: channel.enabled !== false,
confirm: {
title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?",
description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。",
okText: channel.enabled !== false ? "确认停用" : "确认启用",
},
onClick: () => toggleChannel(channel),
},
{
key: "delete",
label: "删除渠道",
icon: <Trash2 className="size-3.5" />,
danger: true,
confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" },
onClick: () => removeChannel(channel),
},
]}
/>
),
},
];
if (managingChannel) {
return <ChannelModelManager channel={managingChannel} onClose={() => setManagingChannel(null)} onChanged={async () => { await syncChannels(); await reload(); }} />;
return (
<ChannelModelManager
channel={managingChannel}
onClose={() => setManagingChannel(null)}
onChanged={async () => {
await syncChannels();
await reload();
}}
/>
);
}
return (
<AdminPageFrame title="系统渠道" description="渠道、模型与售价" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>}>
<AdminPageFrame
title="系统渠道"
description="列表顺序就是用户端展示顺序,点击“设置排序”即可调整。"
actions={
<div className="flex gap-2">
<ChannelOrderDialog
onSaved={async () => {
await syncChannels();
await reload();
}}
/>
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
新增系统渠道
</Button>
</div>
}
>
<AdminDataTable
toolbar={<Input id="admin-channel-search" aria-label="搜索系统渠道" autoComplete="off" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索渠道名称或地址" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />}
toolbarFilters={<Select aria-label="筛选渠道状态" className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]} />}
toolbar={
<Input
id="admin-channel-search"
aria-label="搜索系统渠道"
autoComplete="off"
allowClear
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索渠道名称、别名或地址"
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
/>
}
toolbarFilters={
<Select
aria-label="筛选渠道状态"
className="w-32"
value={status}
onChange={(value) => updateUrl({ status: value, page: 1 })}
options={[
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "enabled" },
{ label: "已停用", value: "disabled" },
]}
/>
}
toolbarActive={hasFilters}
onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}
skeletonColumns={6}
table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: channels, locale: { emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }, pagination: false, scroll: { x: 820 } }}
skeletonColumns={7}
table={{
className: "app-data-table",
size: "small",
rowKey: "id",
loading,
columns,
dataSource: channels,
locale: {
emptyText: (
<AdminTableEmpty
filtered={hasFilters}
title={hasFilters ? undefined : "还没有系统渠道"}
action={
hasFilters ? undefined : (
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
新增系统渠道
</Button>
)
}
/>
),
},
pagination: false,
scroll: { x: 1150 },
}}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
/>
<Modal
@@ -221,24 +366,77 @@ export default function ChannelsPage() {
onCancel={closeDrawer}
mask={{ closable: !saving }}
destroyOnHidden
footer={<div className="flex justify-end gap-2"><Button onClick={closeDrawer}>取消</Button><Button type="primary" loading={saving} onClick={() => void save()}>保存</Button></div>}
footer={
<div className="flex justify-end gap-2">
<Button onClick={closeDrawer}>取消</Button>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存
</Button>
</div>
}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}><Input placeholder="例如:OpenAI 官方渠道" /></Form.Item>
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}>
<Input placeholder="例如:OpenAI 官方渠道" />
</Form.Item>
<Form.Item name="publicAlias" label="前台显示别名" extra="留空时显示渠道名称;填写后用户端只显示此别名,后台仍保留原渠道名称。" rules={[{ max: 80, message: "别名不能超过 80 个字符" }]}>
<Input maxLength={80} placeholder="可选,例如:精选图片" />
</Form.Item>
<AdminLocalChannelFields visible={showDesktopLocalChannelControl} checked={allowLocalChannel} form={form} />
<Form.Item name="apiKey" label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]} extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} /></Form.Item>
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} /></Form.Item>
<div className="mb-6"><Form.Item name="headers" noStyle><ChannelHeadersEditor /></Form.Item></div>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item
name="apiKey"
label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"}
rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]}
extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"
>
<Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} />
</Form.Item>
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。">
<Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} />
</Form.Item>
<div className="mb-6">
<Form.Item name="headers" noStyle>
<ChannelHeadersEditor />
</Form.Item>
</div>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="concurrencyLimit"
label="渠道最大并发数"
extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。"
rules={
useGlobalConcurrency
? []
: [
{ required: true, message: "请填写渠道最大并发数" },
{ type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" },
]
}
>
<InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</AdminPageFrame>
);
}
function positiveInt(value: string | null, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }
function normalizePageSize(value: string | null) { const parsed = positiveInt(value, 20); return [20, 50, 100].includes(parsed) ? parsed : 20; }
function normalizeStatus(value: string | null): "all" | "enabled" | "disabled" { return value === "enabled" || value === "disabled" ? value : "all"; }
function channelSecretText(channel: ModelChannel) { return channel.hasSecretKey ? "已配置,留空不修改" : "未配置"; }
function positiveInt(value: string | null, fallback: number) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizePageSize(value: string | null) {
const parsed = positiveInt(value, 20);
return [20, 50, 100].includes(parsed) ? parsed : 20;
}
function normalizeStatus(value: string | null): "all" | "enabled" | "disabled" {
return value === "enabled" || value === "disabled" ? value : "all";
}
function channelSecretText(channel: ModelChannel) {
return channel.hasSecretKey ? "已配置,留空不修改" : "未配置";
}

View File

@@ -12,6 +12,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
import { ChannelModelEditor } from "./channel-model-editor";
import { AdminPageFrame } from "./admin-shell";
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
import { ChannelOrderDialog } from "./channel-order-dialog";
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
const { message, modal } = App.useApp();
@@ -252,9 +253,17 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
return (
<AdminPageFrame
title={`${channel.name} / 模型管理`}
description="模型按用户端展示顺序排列,点击“设置排序”即可调整。"
back={{ label: "返回系统渠道", onClick: onClose }}
actions={
<Space wrap>
<ChannelOrderDialog
channelId={channel.id}
onSaved={async () => {
await reload();
await onChanged();
}}
/>
<Button loading={fetching} icon={<RefreshCw className="size-4" />} onClick={() => void fetchModels()}>
拉取模型
</Button>
@@ -372,7 +381,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
columns,
dataSource: pagedItems,
pagination: false,
scroll: { x: 990 },
scroll: { x: 1150 },
}}
footer={
<PaginationBar

View File

@@ -0,0 +1,135 @@
import { App, Button, Empty, Modal, Spin } from "antd";
import { ArrowDown, ArrowUp, GripVertical, ListOrdered } from "lucide-react";
import { useRef, useState } from "react";
import { getChannelOrder, saveChannelOrder, type ChannelOrderItem } from "@/services/api/channel-order";
export function moveOrderItem<T extends { id: string }>(items: T[], id: string, target: number): T[] {
const from = items.findIndex((item) => item.id === id);
if (from < 0 || target < 0 || target >= items.length || from === target) return items;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(target, 0, item!);
return next;
}
export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; onSaved: () => Promise<void> }) {
const { message } = App.useApp();
const [open, setOpen] = useState(false),
[loading, setLoading] = useState(false),
[saving, setSaving] = useState(false);
const [items, setItems] = useState<ChannelOrderItem[]>([]),
[original, setOriginal] = useState<string[]>([]);
const dragged = useRef<string | null>(null),
pending = useRef(false);
const changed = items.some((item, index) => item.id !== original[index]);
const start = async () => {
setOpen(true);
setLoading(true);
setItems([]);
setOriginal([]);
try {
const result = await getChannelOrder(channelId);
setItems(result.items);
setOriginal(result.items.map((item) => item.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取排序失败");
setOpen(false);
} finally {
setLoading(false);
}
};
const save = async () => {
if (pending.current || !changed) return;
pending.current = true;
setSaving(true);
try {
await saveChannelOrder(
channelId,
items.map((item) => item.id),
original,
);
setOpen(false);
await onSaved();
message.success("排序已保存,用户端按此顺序展示");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存排序失败");
} finally {
pending.current = false;
setSaving(false);
}
};
return (
<>
<Button icon={<ListOrdered className="size-4" />} onClick={() => void start()}>
设置排序
</Button>
<Modal
title={channelId ? "调整模型顺序" : "调整渠道顺序"}
open={open}
onCancel={() => setOpen(false)}
closable={!saving && !loading}
mask={{ closable: !saving && !loading }}
keyboard={!saving && !loading}
width={600}
footer={
<div className="flex justify-end gap-2">
<Button disabled={saving || loading} onClick={() => setOpen(false)}>
取消
</Button>
<Button type="primary" loading={saving} disabled={!changed || loading} onClick={() => void save()}>
保存排序
</Button>
</div>
}
>
<p className="mb-4 text-sm text-foreground/60">拖动条目或点击上下箭头调整顺序,保存后用户端按此顺序展示。这里包含全部条目,不受列表筛选和分页影响。</p>
<Spin spinning={loading}>
<div className="max-h-[55vh] overflow-y-auto space-y-2" role="list" aria-label="展示顺序">
{!loading && !items.length ? (
<Empty description="暂无可排序条目" />
) : (
items.map((item, index) => (
<div
role="listitem"
key={item.id}
draggable={!saving}
onDragStart={(event) => {
event.dataTransfer.setData("text/plain", item.id);
dragged.current = item.id;
}}
onDragEnd={() => {
dragged.current = null;
}}
onDragOver={(event) => {
if (!saving) event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
const source = dragged.current;
if (!saving && source) setItems((current) => moveOrderItem(current, source, index));
dragged.current = null;
}}
className="flex items-center gap-3 rounded-lg border border-border bg-background p-3"
>
<GripVertical aria-hidden className="size-4 shrink-0 cursor-grab text-foreground/40" />
<span className="min-w-0 flex-1 break-words">
{item.name}
{!item.enabled && <span className="ml-2 text-xs text-foreground/45">已停用</span>}
</span>
<Button aria-label={`上移${item.name}`} title="上移" icon={<ArrowUp className="size-4" />} disabled={saving || index === 0} onClick={() => setItems((current) => moveOrderItem(current, item.id, index - 1))} />
<Button
aria-label={`下移${item.name}`}
title="下移"
icon={<ArrowDown className="size-4" />}
disabled={saving || index === items.length - 1}
onClick={() => setItems((current) => moveOrderItem(current, item.id, index + 1))}
/>
</div>
))
)}
</div>
</Spin>
</Modal>
</>
);
}

View File

@@ -61,8 +61,8 @@ const rateFields: PolicyField[] = [
{ group: "request", name: "sessionFilePerMinute", label: "会话附件", extra: "每账号每分钟上传会话附件的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "assetWritePerMinute", label: "素材写入", extra: "每账号每分钟写入素材的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "canvasWritePerMinute", label: "画布写入", extra: "每账号每分钟写入画布的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "registerPerHour", label: "账号注册", extra: "每 IP 每小时允许注册的次数。", unit: "次/小时", max: 999_999 },
{ group: "request", name: "emailCodePerHour", label: "邮箱验证码", extra: "每 IP 每小时允许请求验证码的次数。", unit: "次/小时", max: 999_999 },
{ group: "request", name: "registerPerHour", label: "账号注册", extra: "每 IP 每小时允许的注册请求次数,包含失败尝试;多人共用网络时建议至少 30 次。", unit: "次/小时", max: 999_999 },
{ group: "request", name: "emailCodePerHour", label: "邮箱验证码", extra: "每 IP 每小时的验证码请求次数,建议至少 60 次;单邮箱另限每小时 10 次,发送后需间隔 60 秒。", unit: "次/小时", max: 999_999 },
{ group: "request", name: "loginIPPerTenMinutes", label: "登录 IP", extra: "每 IP 每 10 分钟允许登录的次数。", unit: "次/10分钟", max: 999_999 },
{ group: "request", name: "loginAccountPerTenMinutes", label: "登录账号组合", extra: "同一 IP 与账号组合每 10 分钟的登录次数。", unit: "次/10分钟", max: 999_999 },
{ group: "request", name: "systemRelayPerMinute", label: "系统渠道中转", extra: "每账号每分钟使用系统渠道的请求数。", unit: "次/分钟", max: 999_999 },

View File

@@ -1,10 +1,11 @@
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Divider, Input } from "antd";
import { ArrowRight, Info, LockKeyhole, Mail, ShieldCheck, TriangleAlert, UserRound } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register, sendRegistrationEmailCode } from "@/services/api/auth";
import { LinuxDOIcon } from "./auth-scene";
import { ApiError } from "@/services/api/request";
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
@@ -22,12 +23,19 @@ export default function RegisterPage() {
const [submitting, setSubmitting] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [registerCountdown, setRegisterCountdown] = useState(0);
const sending = useRef(false),
registering = useRef(false);
const next = safeNext(params.get("next"));
useEffect(() => {
let cancelled = false;
void getAuthSettings().then((value) => !cancelled && setSettings(value)).catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
return () => { cancelled = true; };
void getAuthSettings()
.then((value) => !cancelled && setSettings(value))
.catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
return () => {
cancelled = true;
};
}, [message]);
useEffect(() => {
@@ -37,28 +45,34 @@ export default function RegisterPage() {
}, [countdown]);
const sendCode = async () => {
if (sending.current || countdown > 0) return;
if (!email.trim()) {
message.warning("请先输入邮箱");
return;
}
sending.current = true;
setSendingCode(true);
try {
await sendRegistrationEmailCode(email.trim());
setCountdown(60);
message.success("验证码已发送,请检查邮箱");
} catch (error) {
if (error instanceof ApiError && error.status === 429) setCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000)));
message.error(error instanceof Error ? error.message : "发送验证码失败");
} finally {
sending.current = false;
setSendingCode(false);
}
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (registering.current || registerCountdown > 0) return;
if (password !== confirmPassword) {
message.error("两次输入的密码不一致");
return;
}
registering.current = true;
setSubmitting(true);
try {
await register({ username, email, emailCode, displayName, password });
@@ -68,12 +82,20 @@ export default function RegisterPage() {
message.success(settings?.firstUser ? "管理员账号已创建" : "注册成功");
navigate(next, { replace: true });
} catch (error) {
if (error instanceof ApiError && error.status === 429) setRegisterCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000)));
message.error(error instanceof Error ? error.message : "注册失败");
} finally {
registering.current = false;
setSubmitting(false);
}
};
useEffect(() => {
if (registerCountdown <= 0) return;
const timer = window.setInterval(() => setRegisterCountdown((value) => Math.max(0, value - 1)), 1000);
return () => window.clearInterval(timer);
}, [registerCountdown]);
const registrationClosed = settings?.registrationEnabled === false;
const mailUnavailable = Boolean(settings && !settings.firstUser && settings.emailCodeRequired && !settings.emailEnabled);
const disabled = registrationClosed || mailUnavailable;
@@ -81,43 +103,125 @@ export default function RegisterPage() {
return (
<form onSubmit={submit} className="space-y-4">
{settings?.firstUser ? <Notice icon={<Info className="size-3.5" />} tone="blue">首个账号自动成为管理员,邮箱验证码暂不要求。</Notice> : null}
{registrationClosed ? <Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">当前已关闭普通注册,请联系管理员创建账号。</Notice> : null}
{mailUnavailable ? <Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">管理员尚未配置注册邮件,普通邮箱注册暂不可用。</Notice> : null}
{settings?.firstUser ? (
<Notice icon={<Info className="size-3.5" />} tone="blue">
首个账号自动成为管理员,邮箱验证码暂不要求。
</Notice>
) : null}
{registrationClosed ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
当前已关闭普通注册,请联系管理员创建账号。
</Notice>
) : null}
{mailUnavailable ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
管理员尚未配置注册邮件,普通邮箱注册暂不可用。
</Notice>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="用户名"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} /></AuthField>
<AuthField label="显示名称"><Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} /></AuthField>
<AuthField label="用户名">
<Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} />
</AuthField>
<AuthField label="显示名称">
<Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} />
</AuthField>
</div>
<AuthField label="邮箱"><Input size="large" prefix={<Mail className="size-4 text-white/35" />} value={email} onChange={(event) => setEmail(event.target.value)} placeholder="用于登录与安全验证" autoComplete="email" required={!settings?.firstUser} disabled={disabled} /></AuthField>
<AuthField label="邮箱">
<Input
size="large"
prefix={<Mail className="size-4 text-white/35" />}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="用于登录与安全验证"
autoComplete="email"
required={!settings?.firstUser}
disabled={disabled}
/>
</AuthField>
{requireCode ? (
<AuthField label="邮箱验证码">
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
<Input size="large" prefix={<ShieldCheck className="size-4 text-white/35" />} value={emailCode} onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" required disabled={disabled} />
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>{countdown > 0 ? `${countdown}s` : "获取验证码"}</Button>
<Input
size="large"
prefix={<ShieldCheck className="size-4 text-white/35" />}
value={emailCode}
onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="6 位验证码"
inputMode="numeric"
autoComplete="one-time-code"
required
disabled={disabled}
/>
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</AuthField>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位" autoComplete="new-password" required disabled={disabled} /></AuthField>
<AuthField label="确认密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} placeholder="再次输入密码" autoComplete="new-password" required disabled={disabled} /></AuthField>
<AuthField label="密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位"
autoComplete="new-password"
required
disabled={disabled}
/>
</AuthField>
<AuthField label="确认密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入密码"
autoComplete="new-password"
required
disabled={disabled}
/>
</AuthField>
</div>
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled} icon={<ArrowRight className="size-4" />} iconPlacement="end">创建账号</Button>
{settings?.linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 注册 / 登录</Button></> : null}
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0} icon={<ArrowRight className="size-4" />} iconPlacement="end">
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
</Button>
{settings?.linuxdoEnabled ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
或
</Divider>
<Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>
使用 Linux.do 注册 / 登录
</Button>
</>
) : null}
</form>
);
}
function AuthField({ label, children }: { label: string; children: ReactNode }) {
return <label className="block space-y-2"><span className="text-xs font-medium text-white/62">{label}</span>{children}</label>;
return (
<label className="block space-y-2">
<span className="text-xs font-medium text-white/62">{label}</span>
{children}
</label>
);
}
function Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amber"; children: ReactNode }) {
return <div className={`flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5 ${tone === "blue" ? "border-blue-300/15 bg-blue-300/[0.06] text-blue-100/78" : "border-amber-300/15 bg-amber-300/[0.06] text-amber-100/78"}`}><span className="mt-0.5 shrink-0">{icon}</span>{children}</div>;
return (
<div className={`flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5 ${tone === "blue" ? "border-blue-300/15 bg-blue-300/[0.06] text-blue-100/78" : "border-amber-300/15 bg-amber-300/[0.06] text-amber-100/78"}`}>
<span className="mt-0.5 shrink-0">{icon}</span>
{children}
</div>
);
}
function safeNext(value: string | null) {

View File

@@ -0,0 +1,5 @@
import { apiClient, request } from "./request";
export type ChannelOrderItem = { id: string; name: string; enabled: boolean };
const pathFor = (channelId?: string) => (channelId ? `/admin/channels/${encodeURIComponent(channelId)}/models/order` : "/admin/channels/order");
export const getChannelOrder = (channelId?: string) => request<{ items: ChannelOrderItem[] }>(apiClient.get(pathFor(channelId)));
export const saveChannelOrder = (channelId: string | undefined, ids: string[], expectedIds: string[]) => request<{ saved: boolean }>(apiClient.put(pathFor(channelId), { ids, expectedIds }));

View File

@@ -31,7 +31,7 @@ export type PublicLogicalModel = {
inputPriceMicrocredits: number;
outputPriceMicrocredits: number;
cachedPriceMicrocredits: number;
priceTiers: PublicLogicalModelPriceTier[];
priceTiers: PublicLogicalModelPriceTier[];
legacyModelIds: string[];
capabilitySpec: CapabilitySpec;
capabilityProfiles: CapabilitySpec[];
@@ -40,14 +40,14 @@ export type PublicLogicalModel = {
};
export type PublicLogicalModelPriceTier = {
selector: Record<string, string>;
resolution: string;
videoSeconds: number;
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
selector: Record<string, string>;
resolution: string;
videoSeconds: number;
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
};
export type AdminLogicalRoute = {
@@ -127,6 +127,7 @@ export type PublicChannelCatalog = {
id: string;
name: string;
displayName: string;
sortOrder?: number;
models: PublicChannelModel[];
};
@@ -134,6 +135,7 @@ export type PublicChannelModel = {
id: string;
modelKey: string;
displayName: string;
sortOrder?: number;
icon: string;
capability: string;
protocol?: string;

View File

@@ -53,6 +53,7 @@ export type ChannelModel = {
modelKey: string;
providerModelKey: string;
displayName: string;
sortOrder?: number;
icon: string;
capability: "text" | "image" | "video" | "audio" | "";
protocol?: import("@/lib/model-protocols").ModelProtocol;
@@ -272,11 +273,11 @@ export function listAdminChannelModels(channelId: string) {
// 管理员从上游读取模型目录;确认导入后才会写入渠道模型,价格和启用仍需人工确认。
export function fetchAdminChannelModels(channelId: string) {
return request<{ models: string[] }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
return request<{ models: string[] }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
}
export function importAdminChannelModels(channelId: string, models: string[]) {
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models }));
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models }));
}
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "providerModelKey" | "capability" | "protocol"> & { capabilityConfig?: ChannelModel["capabilityConfig"] }) {
@@ -291,6 +292,10 @@ export function updateAdminChannelModel(channelId: string, id: string, input: Ch
return request<{ model: ChannelModel }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`, input));
}
export function updateAdminChannelModelSort(channelId: string, id: string, sortOrder: number) {
return request<{ updated: boolean }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}/sort`, { sortOrder }));
}
export function deleteAdminChannelModel(channelId: string, id: string) {
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`));
}

View File

@@ -60,16 +60,47 @@ function normalizeWorkflowFieldSourceName(value: unknown, capability?: RunningHu
const source = String(value || "").trim();
const normalized = source.toLowerCase().replace(/[\s_-]/g, "");
const aliases: Record<string, string> = {
text: "prompt", positive: "prompt", positiveprompt: "prompt",
image: "referenceImage", referenceimage: "referenceImage", referenceimages: "referenceImage",
video: "referenceVideo", referencevideo: "referenceVideo", referencevideos: "referenceVideo",
audio: "referenceAudio", referenceaudio: "referenceAudio", referenceaudios: "referenceAudio",
sizewidth: "width", imagewidth: "width", videowidth: "width", sizeheight: "height", imageheight: "height", videoheight: "height",
ratio: "aspectRatio", aspectratio: "aspectRatio", imageaspectratio: "aspectRatio", imageratio: "aspectRatio", videoaspectratio: "aspectRatio", videoratio: "aspectRatio",
videoresolution: "vquality", batch: "count", batchsize: "count", duration: "videoSeconds", videoseconds: "videoSeconds", videoquality: "vquality",
generateaudio: "videoGenerateAudio", videogenerateaudio: "videoGenerateAudio", watermark: "videoWatermark", videowatermark: "videoWatermark", voice: "audioVoice",
systemprompt: "systemPrompt", transparentbackground: "transparentBackground", audiovoice: "audioVoice",
audioformat: "audioFormat", audiospeed: "audioSpeed", audioinstructions: "audioInstructions",
text: "prompt",
positive: "prompt",
positiveprompt: "prompt",
image: "referenceImage",
referenceimage: "referenceImage",
referenceimages: "referenceImage",
video: "referenceVideo",
referencevideo: "referenceVideo",
referencevideos: "referenceVideo",
audio: "referenceAudio",
referenceaudio: "referenceAudio",
referenceaudios: "referenceAudio",
sizewidth: "width",
imagewidth: "width",
videowidth: "width",
sizeheight: "height",
imageheight: "height",
videoheight: "height",
ratio: "aspectRatio",
aspectratio: "aspectRatio",
imageaspectratio: "aspectRatio",
imageratio: "aspectRatio",
videoaspectratio: "aspectRatio",
videoratio: "aspectRatio",
videoresolution: "vquality",
batch: "count",
batchsize: "count",
duration: "videoSeconds",
videoseconds: "videoSeconds",
videoquality: "vquality",
generateaudio: "videoGenerateAudio",
videogenerateaudio: "videoGenerateAudio",
watermark: "videoWatermark",
videowatermark: "videoWatermark",
voice: "audioVoice",
systemprompt: "systemPrompt",
transparentbackground: "transparentBackground",
audiovoice: "audioVoice",
audioformat: "audioFormat",
audiospeed: "audioSpeed",
audioinstructions: "audioInstructions",
};
if (normalized === "resolution") return capability === "video" ? "vquality" : "size";
// 工作流的 quality 可能是连续数值(例如 0.1-3),不能按视频分辨率处理。
@@ -135,7 +166,7 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn
// source 明确存在但为空,表示用户要求保留工作流默认值;只有旧数据完全缺少来源字段时才自动补绑定。
const sourceKey = ["source", "bind", "from"].find((key) => Object.prototype.hasOwnProperty.call(raw, key));
const sourceConfigured = Boolean(sourceKey);
const configuredSource = sourceKey ? raw[sourceKey] : (raw.bindPrompt === true || raw.bind_prompt === true ? "prompt" : "");
const configuredSource = sourceKey ? raw[sourceKey] : raw.bindPrompt === true || raw.bind_prompt === true ? "prompt" : "";
let source = normalizeWorkflowFieldSourceName(configuredSource, capability);
const rawSourceAutomatic = raw.sourceAutomatic ?? raw.source_automatic;
let sourceAutomatic = typeof rawSourceAutomatic === "boolean" ? rawSourceAutomatic : !sourceConfigured && Boolean(source);
@@ -164,8 +195,8 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn
const options = Array.isArray(rawOptions)
? rawOptions
: rawOptions && typeof rawOptions === "object"
? ((rawOptions as Record<string, unknown>).choices ?? (rawOptions as Record<string, unknown>).options ?? (rawOptions as Record<string, unknown>).values)
: undefined;
? ((rawOptions as Record<string, unknown>).choices ?? (rawOptions as Record<string, unknown>).options ?? (rawOptions as Record<string, unknown>).values)
: undefined;
const range = workflowFieldRange(rawOptions);
const min = raw.min ?? raw.minValue ?? raw.min_value ?? range.min;
const max = raw.max ?? raw.maxValue ?? raw.max_value ?? range.max;
@@ -196,8 +227,10 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn
const role = workflowFieldRole({ ...candidate, role: String(raw.role || "") });
const configuredEnabled = typeof raw.enabled === "boolean" ? raw.enabled : role !== "internal";
const optionsSource = ["workflow", "manual", "preset"].includes(String(raw.optionsSource || raw.options_source || ""))
? String(raw.optionsSource || raw.options_source) as WorkflowFieldMapping["optionsSource"]
: Array.isArray(options) ? "workflow" : undefined;
? (String(raw.optionsSource || raw.options_source) as WorkflowFieldMapping["optionsSource"])
: Array.isArray(options)
? "workflow"
: undefined;
return [{ ...candidate, role, safeToOverride, enabled: safeToOverride && configuredEnabled, ...(optionsSource ? { optionsSource } : {}) }];
});
let imageOrder = 0;
@@ -231,11 +264,7 @@ export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown,
const previousFields = normalizeWorkflowFieldMappings(current, capability);
const nextFields = normalizeWorkflowFieldMappings(incoming, capability);
const previousByKey = new Map(previousFields.map((field) => [`${field.nodeId}::${field.fieldName}`, field]));
const policyKeys: Array<keyof WorkflowFieldMapping> = [
"label", "enabled",
"randomEnabled", "source", "sourceAutomatic", "sourceFromUpstream", "sourceIndex",
"imageOrder", "required", "bindPrompt",
];
const policyKeys: Array<keyof WorkflowFieldMapping> = ["label", "enabled", "randomEnabled", "source", "sourceAutomatic", "sourceFromUpstream", "sourceIndex", "imageOrder", "required", "bindPrompt"];
return nextFields.map((field) => {
const previous = previousByKey.get(`${field.nodeId}::${field.fieldName}`);
if (!previous) return field;
@@ -263,9 +292,7 @@ export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown,
function normalizeSavedWorkflowFields(workflow: { fields?: unknown; workflowJson?: Record<string, unknown> }, capability: RunningHubCapability) {
const schemaFields = workflowVideoFieldsFromJson(workflow.workflowJson);
return schemaFields.length
? mergeWorkflowFieldMappings(workflow.fields, schemaFields, capability)
: normalizeWorkflowFieldMappings(workflow.fields, capability);
return schemaFields.length ? mergeWorkflowFieldMappings(workflow.fields, schemaFields, capability) : normalizeWorkflowFieldMappings(workflow.fields, capability);
}
function workflowFieldRange(value: unknown) {
@@ -339,6 +366,8 @@ export const PUBLIC_MODEL_CATALOG_ID = "managed";
export type ModelChannel = {
id: string;
name: string;
publicAlias?: string;
sortOrder?: number;
baseUrl: string;
allowLocalChannel?: boolean;
apiKey: string;
@@ -371,7 +400,7 @@ export type ModelChannel = {
logicalModelId?: string;
logicalCapabilitySpec?: CapabilitySpec;
logicalCapabilityProfiles?: CapabilitySpec[];
logicalPriceTiers?: PublicLogicalModelPriceTier[];
logicalPriceTiers?: PublicLogicalModelPriceTier[];
defaultOptions?: Record<string, unknown>;
}>;
transport?: "backend-channel" | "local-runtime";
@@ -642,36 +671,34 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
const runningHubCapability = normalizeRunningHubCapability(persistedRunningHub?.capability, defaultConfig.runningHub.capability);
const runningHubWorkflows = Array.isArray(persistedRunningHub?.workflows)
? persistedRunningHub.workflows
.filter((item): item is RunningHubWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
.map((item) => {
const capability = normalizeRunningHubCapability(item.capability, runningHubCapability);
return {
...item,
kind: normalizeRunningHubWorkflowKind(item.kind),
workflowId: String(item.workflowId || "").trim(),
capability,
fields: normalizeSavedWorkflowFields(item, capability),
};
})
.filter((item): item is RunningHubWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
.map((item) => {
const capability = normalizeRunningHubCapability(item.capability, runningHubCapability);
return {
...item,
kind: normalizeRunningHubWorkflowKind(item.kind),
workflowId: String(item.workflowId || "").trim(),
capability,
fields: normalizeSavedWorkflowFields(item, capability),
};
})
: [];
const runningHubWorkflowID = String(persistedRunningHub?.workflowId || "").trim();
const runningHubSelectedKind = persistedRunningHub?.selectedKind
? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind)
: normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind);
const runningHubSelectedKind = persistedRunningHub?.selectedKind ? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind) : normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind);
const persistedComfyBridge = persistedConfig.comfyBridge;
const comfyBridgeCapability = normalizeRunningHubCapability(persistedComfyBridge?.capability, defaultConfig.comfyBridge.capability);
const comfyBridgeWorkflows = Array.isArray(persistedComfyBridge?.workflows)
? persistedComfyBridge.workflows
.filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
.map((item) => {
const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability);
return {
...item,
workflowId: String(item.workflowId || "").trim(),
capability,
fields: normalizeSavedWorkflowFields(item, capability),
};
})
.filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
.map((item) => {
const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability);
return {
...item,
workflowId: String(item.workflowId || "").trim(),
capability,
fields: normalizeSavedWorkflowFields(item, capability),
};
})
: [];
const config = {
...defaultConfig,
@@ -789,6 +816,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
return {
id: channel?.id?.trim() || nanoid(),
name: channel?.name?.trim() || "新渠道",
sortOrder: channel?.sortOrder ?? 0,
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
allowLocalChannel: channel?.allowLocalChannel === true,
apiKey: channel?.apiKey || "",
@@ -864,19 +892,19 @@ export function hasSystemModelPrice(channel: ModelChannel, model: string) {
if (channel.scope !== "system") return true;
// 价格字段已由后端按“非负数”校验;0 表示免费模型,不能在目录重建时被过滤。
const configured = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value >= 0;
return channel.modelCosts?.some((item) => {
if (item.model !== model) return false;
const tiers = item.logicalPriceTiers || [];
if (tiers.length) {
return tiers.some((tier) => tier.billingMode === "token"
? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].every(configured)
: configured(tier.unitPriceMicrocredits));
}
if (item.billingMode === "token") {
return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].every(configured);
}
return configured(item.unitPriceMicrocredits);
}) === true;
return (
channel.modelCosts?.some((item) => {
if (item.model !== model) return false;
const tiers = item.logicalPriceTiers || [];
if (tiers.length) {
return tiers.some((tier) => (tier.billingMode === "token" ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].every(configured) : configured(tier.unitPriceMicrocredits)));
}
if (item.billingMode === "token") {
return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].every(configured);
}
return configured(item.unitPriceMicrocredits);
}) === true
);
}
export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) {
@@ -1020,14 +1048,16 @@ export function buildApiUrl(baseUrl: string, path: string) {
if (isSystemProxyBaseUrl(normalizedBaseUrl)) return `${normalizedBaseUrl}${requestPath}`;
const knownPrefixes = ["/api/plan/v3", "/api/v3", "/v1beta", "/v1", "/v2", "/v3"];
const requestPrefixFor = (value: string) => knownPrefixes.find((prefix) => {
const lower = value.toLowerCase();
return lower === prefix || lower.startsWith(`${prefix}/`) || lower.startsWith(`${prefix}?`) || lower.startsWith(`${prefix}#`);
}) || "";
const basePrefixFor = (value: string) => knownPrefixes.find((prefix) => {
const lower = value.toLowerCase();
return lower.endsWith(prefix) || lower.includes(`${prefix}/`) || lower.includes(`${prefix}?`) || lower.includes(`${prefix}#`);
}) || "";
const requestPrefixFor = (value: string) =>
knownPrefixes.find((prefix) => {
const lower = value.toLowerCase();
return lower === prefix || lower.startsWith(`${prefix}/`) || lower.startsWith(`${prefix}?`) || lower.startsWith(`${prefix}#`);
}) || "";
const basePrefixFor = (value: string) =>
knownPrefixes.find((prefix) => {
const lower = value.toLowerCase();
return lower.endsWith(prefix) || lower.includes(`${prefix}/`) || lower.includes(`${prefix}?`) || lower.includes(`${prefix}#`);
}) || "";
const basePrefix = basePrefixFor(normalizedBaseUrl);
const requestPrefix = requestPrefixFor(requestPath);
if (requestPrefix) {