feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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 ? "已配置,留空不修改" : "未配置";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
135
web/src/pages/admin/components/channel-order-dialog.tsx
Normal file
135
web/src/pages/admin/components/channel-order-dialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
web/src/services/api/channel-order.ts
Normal file
5
web/src/services/api/channel-order.ts
Normal 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 }));
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user