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

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

View File

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

View File

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

View File

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

View File

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