feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)
This commit is contained in:
1 parent
381edf37e3
commit
8360b2c933
41 files changed
+1485
-148
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+121
-17
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user