feat(admin): 资源与策略 - 后台统一维护账号配额与运行时请求限制 (#37)

Fixes #35

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-23 21:19:07 +08:00
committed by GitHub
parent 78e27507c4
commit d3dfede48f
40 changed files with 1202 additions and 488 deletions

View File

@@ -1,9 +1,9 @@
import type { CanvasGenerationBatch, CanvasGenerationBatchStatus } from "@/types/canvas";
const TASK_CAPACITY_MESSAGE = "同时排队或运行的任务最多 5 个";
const TASK_CAPACITY_MESSAGE = /同时排队或运行的任务最多 \d+ 个/;
export function isGenerationTaskCapacityError(error: unknown) {
return error instanceof Error && error.message.includes(TASK_CAPACITY_MESSAGE);
return error instanceof Error && TASK_CAPACITY_MESSAGE.test(error.message);
}
export function isGenerationCostUncertainError(error: unknown) {

View File

@@ -19,6 +19,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
]);
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
useUserStore.getState().setUser(payload.user);
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate()]);
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });

View File

@@ -164,7 +164,7 @@ export default function ChannelsPage() {
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
<Form.Item name="apiKey" label={editingChannel ? `API Key(${configuredSecretText})` : "API Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key" }]}><Input.Password placeholder={editingChannel ? "留空保留原密钥" : "系统渠道密钥"} /></Form.Item>
<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: 100, message: "请输入 1-100 的整数" }]}><InputNumber className="w-full" min={1} max={100} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-100"} /></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>
</Drawer>

View File

@@ -1,5 +1,5 @@
import { Tooltip } from "antd";
import { BarChart3, BellRing, Coins, FileClock, Gauge, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { useState, type ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
@@ -40,7 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{
label: "系统配置",
items: [
{ path: "/admin/settings/concurrency", label: "任务并发", description: "Worker 与渠道请求调度", icon: <Gauge className="size-4" />, secondary: true },
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" />, secondary: true },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" />, secondary: true },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" />, secondary: true },
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" />, secondary: true },

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { App, Descriptions, Drawer, Empty, Skeleton, Table, Tabs, Tag } from "antd";
import { App, Descriptions, Drawer, Empty, Progress, Skeleton, Table, Tabs, Tag } from "antd";
import { formatCredits } from "@/constant/credits";
import { getAdminUserDetail, listAdminUserAuditEvents, listAdminUserLedger, listAdminUserTasks, type AdminAuditEvent, type AdminUserDetail, type AdminUserTask } from "@/services/api/auth";
@@ -119,6 +119,20 @@ export function AdminUserDetailDrawer({ userId, onClose }: { userId: string | nu
</div>
))}
</div>
<div>
<div className="mb-3 text-sm font-medium">资源与配额占用</div>
<div className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
{quotaUsageItems(detail).map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="text-foreground/60">{item.label}</span>
<span className="shrink-0 tabular-nums text-foreground/75">{item.display}</span>
</div>
<Progress percent={Math.min(100, item.limit > 0 ? Math.round(item.value / item.limit * 100) : 0)} size="small" showInfo={false} status={item.value >= item.limit ? "exception" : "normal"} />
</div>
))}
</div>
</div>
</div>
),
},
@@ -195,3 +209,20 @@ export function AdminUserDetailDrawer({ userId, onClose }: { userId: string | nu
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}
function quotaUsageItems(detail: AdminUserDetail) {
const structuredBytes = detail.storageUsage.assetBytes + detail.storageUsage.canvasBytes + detail.storageUsage.sessionBytes;
const bytes = (value: number) => value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(2)} GB` : `${(value / 1024 ** 2).toFixed(1)} MB`;
const number = (value: number) => new Intl.NumberFormat("zh-CN").format(value);
return [
{ label: "资源与附件", value: detail.storedFileBytes, limit: detail.quota.storedFileGB * 1024 ** 3, display: `${bytes(detail.storedFileBytes)} / ${detail.quota.storedFileGB} GB` },
{ label: "今日上传(UTC)", value: detail.dailyUploadBytes, limit: detail.quota.dailyUploadMB * 1024 ** 2, display: `${bytes(detail.dailyUploadBytes)} / ${detail.quota.dailyUploadMB} MB` },
{ label: "画布、素材与会话数据", value: structuredBytes, limit: detail.quota.structuredDataMB * 1024 ** 2, display: `${bytes(structuredBytes)} / ${detail.quota.structuredDataMB} MB` },
{ label: "任务与请求日志数据", value: detail.storageUsage.taskBytes, limit: detail.quota.taskDataGB * 1024 ** 3, display: `${bytes(detail.storageUsage.taskBytes)} / ${detail.quota.taskDataGB} GB` },
{ label: "素材数量", value: detail.storageUsage.assetCount, limit: detail.quota.assetCount, display: `${number(detail.storageUsage.assetCount)} / ${number(detail.quota.assetCount)}` },
{ label: "画布数量", value: detail.storageUsage.canvasCount, limit: detail.quota.canvasCount, display: `${number(detail.storageUsage.canvasCount)} / ${number(detail.quota.canvasCount)}` },
{ label: "Agent 会话数量", value: detail.storageUsage.sessionCount, limit: detail.quota.sessionCount, display: `${number(detail.storageUsage.sessionCount)} / ${number(detail.quota.sessionCount)}` },
{ label: "任务历史数量", value: detail.storageUsage.taskCount, limit: detail.quota.taskCount, display: `${number(detail.storageUsage.taskCount)} / ${number(detail.quota.taskCount)}` },
{ label: "上游请求日志数量", value: detail.storageUsage.apiCallCount, limit: detail.quota.apiCallLogCount, display: `${number(detail.storageUsage.apiCallCount)} / ${number(detail.quota.apiCallLogCount)}` },
];
}

View File

@@ -1,73 +0,0 @@
import { App, Button, Form, InputNumber, Tag } from "antd";
import { Gauge, RadioTower, Workflow } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAdminRuntimeConcurrencySetting, updateAdminRuntimeConcurrencySetting, type RuntimeConcurrencySetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { SettingsSectionCard } from "../components/admin-ui";
type ConcurrencyFormValues = Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">;
export default function ConcurrencySettingsPage() {
const { message } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<RuntimeConcurrencySetting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<ConcurrencyFormValues>();
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
useEffect(() => {
void getAdminRuntimeConcurrencySetting()
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue(value);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取任务并发配置失败"))
.finally(() => setLoading(false));
}, [form, message]);
const save = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const result = await updateAdminRuntimeConcurrencySetting(values);
setSetting(result.setting);
form.setFieldsValue(result.setting);
message.success("任务并发配置已生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存任务并发配置失败");
} finally {
setSaving(false);
}
};
return (
<AdminPageFrame title="任务并发" description="Worker 与渠道请求调度">
<div className="mx-auto max-w-5xl space-y-5">
<SettingsSectionCard
icon={<Gauge className="size-4" />}
title="运行时并发"
description="控制任务消费速度和未单独配置渠道的默认请求并发。"
status={<Tag bordered={false} color="blue">热更新</Tag>}
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "当前使用环境变量默认值"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存并发配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2">
<Form.Item name="workerConcurrency" label="Worker 并发数" extra="控制集群同时执行的后台任务数。" rules={concurrencyRules("Worker 并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<Workflow className="size-3.5 text-foreground/40" />} /></Form.Item>
<Form.Item name="channelConcurrency" label="全局渠道并发数" extra="渠道选择“跟随系统”时使用;自定义渠道也使用该值。" rules={concurrencyRules("全局渠道并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<RadioTower className="size-3.5 text-foreground/40" />} /></Form.Item>
</div>
</Form>
</SettingsSectionCard>
<div className="rounded-lg border border-border bg-muted/25 px-4 py-3 text-xs leading-6 text-foreground/55">任务可以继续进入队列;达到渠道上限时会等待空闲槽位,不会因暂时满载直接失败。单独配置的渠道上限优先于这里的全局值。</div>
</div>
</AdminPageFrame>
);
}
function concurrencyRules(label: string) {
return [{ required: true, message: `请填写${label}` }, { type: "number" as const, min: 1, max: 100, message: `${label}必须是 1-100 的整数` }];
}
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }

View File

@@ -0,0 +1,200 @@
import { App, Button, Form, InputNumber, Tag } from "antd";
import { Database, Gauge, Infinity as InfinityIcon, Network, RotateCcw, Save, ShieldCheck, TimerReset } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
getAdminRuntimePolicySetting,
getAdminSelfUseRuntimePolicy,
resetAdminRuntimePolicySetting,
updateAdminRuntimePolicySetting,
type RuntimePolicySetting,
} from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { SettingsSectionCard } from "../components/admin-ui";
type PolicyGroup = "resource" | "task" | "request";
type PolicyField = {
group: PolicyGroup;
name: string;
label: string;
extra: string;
unit: string;
max: number;
};
const resourceFields: PolicyField[] = [
{ group: "resource", name: "resourceUploadMB", label: "普通资源单文件", extra: "素材上传和远程导入的单文件业务上限。", unit: "MB", max: 999 },
{ group: "resource", name: "sessionUploadMB", label: "Agent 会话附件", extra: "单个会话附件的大小上限。", unit: "MB", max: 999 },
{ group: "resource", name: "generatedFileMB", label: "单个生成资源", extra: "上游生成响应和落库资源的单文件上限。", unit: "MB", max: 999 },
{ group: "resource", name: "dailyUploadMB", label: "每日上传总量", extra: "按 UTC 自然日累计资源与附件上传。", unit: "MB", max: 999_999 },
{ group: "resource", name: "storedFileGB", label: "账号文件总量", extra: "资源文件与 Agent 会话附件合计。", unit: "GB", max: 999 },
{ group: "resource", name: "structuredDataMB", label: "结构化数据总量", extra: "画布、素材和 Agent 会话结构化数据合计。", unit: "MB", max: 999_999 },
{ group: "resource", name: "taskDataGB", label: "任务数据总量", extra: "任务历史、结果和上游请求日志合计。", unit: "GB", max: 999 },
{ group: "resource", name: "assetCount", label: "素材数量", extra: "单账号可保存的素材记录数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "canvasCount", label: "画布数量", extra: "单账号可保存的画布数量。", unit: "个", max: 999_999_999 },
{ group: "resource", name: "sessionCount", label: "Agent 会话数量", extra: "单账号可保存的 Agent 会话数量。", unit: "个", max: 999_999_999 },
{ group: "resource", name: "taskCount", label: "任务历史数量", extra: "单账号保留的任务历史记录数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "apiCallLogCount", label: "请求日志数量", extra: "单账号保留的上游请求日志数。", unit: "条", max: 999_999_999 },
];
const concurrencyFields: PolicyField[] = [
{ group: "task", name: "workerConcurrency", label: "Worker 并发", extra: "集群同时执行的后台任务数。", unit: "个", max: 999 },
{ group: "task", name: "channelConcurrency", label: "全局渠道并发", extra: "渠道选择跟随系统时采用的并发上限。", unit: "个", max: 999 },
{ group: "task", name: "activeTaskLimit", label: "账号活动任务", extra: "单账号同时排队或运行的任务数。", unit: "个", max: 999 },
];
const timeoutFields: PolicyField[] = [
{ group: "task", name: "imageTimeoutMinutes", label: "图片任务超时", extra: "图片任务进入失败状态前的最长执行时间。", unit: "分钟", max: 9_999 },
{ group: "task", name: "textTimeoutMinutes", label: "文本任务超时", extra: "文本任务的最长执行时间。", unit: "分钟", max: 9_999 },
{ group: "task", name: "audioTimeoutMinutes", label: "音频任务超时", extra: "音频任务的最长执行时间。", unit: "分钟", max: 9_999 },
{ group: "task", name: "videoTimeoutMinutes", label: "视频任务超时", extra: "视频任务的最长执行时间。", unit: "分钟", max: 9_999 },
{ group: "task", name: "storyboardTimeoutMinutes", label: "分镜任务超时", extra: "Agent 分镜任务的最长执行时间。", unit: "分钟", max: 9_999 },
{ group: "task", name: "defaultTimeoutMinutes", label: "默认任务超时", extra: "未匹配专用类型时使用的最长执行时间。", unit: "分钟", max: 9_999 },
];
const rateFields: PolicyField[] = [
{ group: "request", name: "taskCreatePerMinute", label: "任务创建", extra: "每账号每分钟允许创建的任务数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "sessionCreatePerMinute", label: "会话创建", extra: "每账号每分钟允许创建的会话数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "resourceUploadPerMinute", label: "资源上传", extra: "每账号每分钟上传资源的次数。", unit: "次/分钟", max: 999_999 },
{ group: "request", name: "resourceImportPerMinute", label: "资源导入", extra: "每账号每分钟导入远程资源的次数。", unit: "次/分钟", max: 999_999 },
{ 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: "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 },
{ group: "request", name: "customRelayPerMinute", label: "自定义渠道中转", extra: "每账号每分钟使用自定义渠道的请求数。", unit: "次/分钟", max: 999_999 },
];
const relayFields: PolicyField[] = [
{ group: "request", name: "customRelayConcurrency", label: "自定义渠道并发", extra: "单账号同时进行的自定义渠道请求数。", unit: "个", max: 999 },
{ group: "request", name: "customRelayRequestMB", label: "自定义渠道请求体", extra: "中转到自定义上游的请求体上限。", unit: "MB", max: 999 },
{ group: "request", name: "customRelayResponseMB", label: "自定义渠道响应体", extra: "自定义上游 JSON 与流式响应的读取上限。", unit: "MB", max: 999 },
{ group: "request", name: "customRelayTimeoutMinutes", label: "自定义渠道超时", extra: "自定义渠道连接与响应的最长等待时间。", unit: "分钟", max: 9_999 },
{ group: "request", name: "systemRelayRequestMB", label: "系统渠道请求体", extra: "中转到系统渠道的请求体上限。", unit: "MB", max: 999 },
{ group: "request", name: "systemRelayResponseMB", label: "系统渠道响应体", extra: "系统渠道上游响应的读取上限。", unit: "MB", max: 999 },
{ group: "request", name: "channelCircuitFailureCount", label: "熔断失败次数", extra: "一分钟内连续失败达到该值后打开熔断。", unit: "次", max: 999 },
{ group: "request", name: "channelCircuitOpenSeconds", label: "熔断持续时间", extra: "渠道熔断打开后拒绝请求的时间。", unit: "秒", max: 86_400 },
];
export default function RuntimePolicySettingsPage() {
const { message, modal } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<RuntimePolicySetting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [form] = Form.useForm<RuntimePolicySetting>();
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
useEffect(() => {
void getAdminRuntimePolicySetting()
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue(value);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取资源与请求策略失败"))
.finally(() => setLoading(false));
}, [form, message]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty]);
const useSelfMode = async () => {
try {
const result = await getAdminSelfUseRuntimePolicy();
form.setFieldsValue(result.setting);
setDirty(true);
message.info("已填入自用模式上限,保存后生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取自用模式失败");
}
};
const reset = () => {
modal.confirm({
title: "重置全部资源与请求策略?",
content: "将删除已保存的自定义策略并立即恢复系统默认值。",
okText: "重置默认值",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
try {
const result = await resetAdminRuntimePolicySetting();
setSetting(result.setting);
form.setFieldsValue(result.setting);
setDirty(false);
message.success("已恢复系统默认策略");
} catch (error) {
message.error(error instanceof Error ? error.message : "重置资源与请求策略失败");
throw error;
}
},
});
};
const save = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const result = await updateAdminRuntimePolicySetting({ resource: values.resource, task: values.task, request: values.request });
setSetting(result.setting);
form.setFieldsValue(result.setting);
setDirty(false);
message.success("资源与请求策略已即时生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存资源与请求策略失败");
} finally {
setSaving(false);
}
};
return (
<AdminPageFrame
title="资源与策略"
description="账号配额、任务调度与请求安全策略"
actions={<div className="flex items-center gap-2"><Button icon={<RotateCcw className="size-4" />} disabled={loading || saving} onClick={reset}>重置</Button><Button icon={<InfinityIcon className="size-4" />} disabled={loading || saving} onClick={() => void useSelfMode()}>自用模式</Button></div>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>
<div className="mx-auto max-w-6xl space-y-5">
<PolicySection icon={<Database className="size-4" />} title="资源与账号配额" description="上传、文件容量、结构化数据和历史记录上限。" fields={resourceFields} />
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<Tag bordered={false} color="blue">热更新</Tag>} />
<PolicySection icon={<TimerReset className="size-4" />} title="任务超时" description="不同生成类型的最长执行时间。" fields={timeoutFields} />
<PolicySection icon={<ShieldCheck className="size-4" />} title="业务频控" description="账号与 IP 维度的固定窗口请求限制。" fields={rateFields} />
<PolicySection icon={<Network className="size-4" />} title="渠道中转与熔断" description="请求体、响应体、并发、超时和上游故障保护。" fields={relayFields} />
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-4">
<div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "当前使用系统默认策略"}</div>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
</div>
</div>
</Form>
</AdminPageFrame>
);
}
function PolicySection({ icon, title, description, fields, status }: { icon: ReactNode; title: string; description: string; fields: PolicyField[]; status?: ReactNode }) {
return (
<SettingsSectionCard icon={icon} title={title} description={description} status={status}>
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2 xl:grid-cols-3">
{fields.map((field) => (
<Form.Item key={`${field.group}.${field.name}`} name={[field.group, field.name]} label={field.label} extra={field.extra} rules={[{ required: true, message: `请填写${field.label}` }, { type: "number", min: 1, max: field.max, message: `${field.label}必须是 1-${field.max} 的整数` }]}>
<InputNumber className="w-full" min={1} max={field.max} precision={0} addonAfter={field.unit} />
</Form.Item>
))}
</div>
</SettingsSectionCard>
);
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
}

View File

@@ -7,12 +7,12 @@ import { buildGenerationConfig, generationTaskMetadata, resetGenerationTaskMetad
import { unchangedModeratedPrompt } from "@/lib/generation-error";
import { cancelGenerationTask, listGenerationTasks } from "@/services/api/task-center";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchMode, CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
const MAX_ACTIVE_TASKS_PER_USER = 5;
const SCHEDULER_INTERVAL_MS = 2_000;
const MAX_BATCH_HISTORY = 20;
@@ -31,6 +31,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const activeTaskLimit = useUserStore((state) => state.runtimeLimits.activeTaskLimit);
const schedulingRef = useRef(false);
const controllersRef = useRef(new Map<string, AbortController>());
@@ -139,7 +140,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
});
}, [projectId, setNodes]);
// 调度只补齐用户级 5 个活跃任务的空位,后端 3 个 worker 继续负责实际执行并发。
// 调度只补齐后台返回的用户级活跃任务空位,最终并发仍由后端原子校验。
const scheduleWaitingItems = useCallback(async () => {
if (!projectLoaded || schedulingRef.current) return;
schedulingRef.current = true;
@@ -154,7 +155,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
const item = currentNodes.flatMap((node) => node.metadata?.generationBatches || []).flatMap((batch) => batch.items).find((candidate) => candidate.id === itemId);
return item ? !nodeById.get(item.nodeId)?.metadata?.taskId : false;
}).length;
let availableSlots = Math.max(0, MAX_ACTIVE_TASKS_PER_USER - activeTaskCount - pendingReservations);
let availableSlots = Math.max(0, activeTaskLimit - activeTaskCount - pendingReservations);
if (!availableSlots) return;
const candidates: Array<{ batch: CanvasGenerationBatch; item: CanvasGenerationBatchItem; node: CanvasNodeData }> = [];
@@ -198,7 +199,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
} finally {
schedulingRef.current = false;
}
}, [effectiveConfig, handleGenerateNode, isAiConfigReady, nodesRef, projectId, projectLoaded, reconcileBatches, updateBatch]);
}, [activeTaskLimit, effectiveConfig, handleGenerateNode, isAiConfigReady, nodesRef, projectId, projectLoaded, reconcileBatches, updateBatch]);
const retryFailedBatchItems = useCallback((sourceNodeId: string, batchId: string, itemId?: string) => {
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);

View File

@@ -7,7 +7,7 @@ import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsP
import ChannelsPage from "@/pages/admin/channels/channels-page";
import LogsPage from "@/pages/admin/logs/logs-page";
import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page";
import ConcurrencySettingsPage from "@/pages/admin/settings/concurrency-settings-page";
import RuntimePolicySettingsPage from "@/pages/admin/settings/runtime-policy-settings-page";
import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page";
import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page";
import UsersPage from "@/pages/admin/users/users-page";
@@ -62,8 +62,9 @@ export const router = createBrowserRouter([
{ path: "credit-operations", element: <CreditOperationsPage /> },
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
{ path: "logs", element: <LogsPage /> },
{ path: "settings", element: <Navigate to="concurrency" replace /> },
{ path: "settings/concurrency", element: <ConcurrencySettingsPage /> },
{ path: "settings", element: <Navigate to="runtime-policy" replace /> },
{ path: "settings/concurrency", element: <Navigate to="/admin/settings/runtime-policy" replace /> },
{ path: "settings/runtime-policy", element: <RuntimePolicySettingsPage /> },
{ path: "settings/access", element: <AccessSettingsPage /> },
{ path: "settings/email", element: <EmailSettingsPage /> },
{ path: "settings/storage", element: <StorageSettingsPage /> },

View File

@@ -29,6 +29,13 @@ export type AdminUser = LocalUser & {
export type AuthSessionPayload = {
user: LocalUser | null;
systemChannels?: ModelChannel[];
runtimeLimits?: RuntimeLimits;
};
export type RuntimeLimits = {
activeTaskLimit: number;
resourceUploadMB: number;
sessionUploadMB: number;
};
export type ApiCallLog = {
@@ -81,6 +88,13 @@ export type AdminUserDetail = {
user: LocalUser;
account: { userId: string; availableMicrocredits: number; reservedMicrocredits: number; version: number };
counts: { ledgerEntries: number; tasks: number; apiCalls: number; auditEvents: number };
storageUsage: {
assetCount: number; assetBytes: number; canvasCount: number; canvasBytes: number;
sessionCount: number; sessionBytes: number; taskCount: number; taskBytes: number; apiCallCount: number;
};
storedFileBytes: number;
dailyUploadBytes: number;
quota: RuntimeResourcePolicy;
};
export type AdminUserTask = {
@@ -197,9 +211,62 @@ export type AdminOSSSetting = {
updatedAt?: string;
};
export type RuntimeConcurrencySetting = {
export type RuntimeResourcePolicy = {
resourceUploadMB: number;
sessionUploadMB: number;
generatedFileMB: number;
dailyUploadMB: number;
storedFileGB: number;
structuredDataMB: number;
taskDataGB: number;
assetCount: number;
canvasCount: number;
sessionCount: number;
taskCount: number;
apiCallLogCount: number;
};
export type RuntimeTaskPolicy = {
workerConcurrency: number;
channelConcurrency: number;
activeTaskLimit: number;
imageTimeoutMinutes: number;
textTimeoutMinutes: number;
audioTimeoutMinutes: number;
videoTimeoutMinutes: number;
storyboardTimeoutMinutes: number;
defaultTimeoutMinutes: number;
};
export type RuntimeRequestPolicy = {
taskCreatePerMinute: number;
sessionCreatePerMinute: number;
resourceUploadPerMinute: number;
resourceImportPerMinute: number;
sessionFilePerMinute: number;
assetWritePerMinute: number;
canvasWritePerMinute: number;
registerPerHour: number;
emailCodePerHour: number;
loginIPPerTenMinutes: number;
loginAccountPerTenMinutes: number;
systemRelayPerMinute: number;
customRelayPerMinute: number;
customRelayConcurrency: number;
customRelayRequestMB: number;
customRelayResponseMB: number;
customRelayTimeoutMinutes: number;
systemRelayRequestMB: number;
systemRelayResponseMB: number;
channelCircuitFailureCount: number;
channelCircuitOpenSeconds: number;
};
export type RuntimePolicySetting = {
resource: RuntimeResourcePolicy;
task: RuntimeTaskPolicy;
request: RuntimeRequestPolicy;
configured?: boolean;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
@@ -329,12 +396,20 @@ export function updateAdminOSSSetting(input: Partial<AdminOSSSetting>) {
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
}
export function getAdminRuntimeConcurrencySetting() {
return request<{ setting: RuntimeConcurrencySetting }>(api.get("/admin/settings/concurrency"));
export function getAdminRuntimePolicySetting() {
return request<{ setting: RuntimePolicySetting }>(api.get("/admin/settings/runtime-policy"));
}
export function updateAdminRuntimeConcurrencySetting(input: Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">) {
return request<{ setting: RuntimeConcurrencySetting }>(api.patch("/admin/settings/concurrency", input));
export function getAdminSelfUseRuntimePolicy() {
return request<{ setting: RuntimePolicySetting }>(api.get("/admin/settings/runtime-policy/self-use"));
}
export function updateAdminRuntimePolicySetting(input: Pick<RuntimePolicySetting, "resource" | "task" | "request">) {
return request<{ setting: RuntimePolicySetting }>(api.put("/admin/settings/runtime-policy", input));
}
export function resetAdminRuntimePolicySetting() {
return request<{ setting: RuntimePolicySetting }>(api.delete("/admin/settings/runtime-policy"));
}
export function listAdminApiLogs(params: AdminListParams = {}) {

View File

@@ -16,10 +16,18 @@ export type LocalUser = {
updatedAt?: string;
};
export type RuntimeLimits = {
activeTaskLimit: number;
resourceUploadMB: number;
sessionUploadMB: number;
};
type UserStore = {
hydrated: boolean;
user: LocalUser | null;
runtimeLimits: RuntimeLimits;
setUser: (user: LocalUser | null) => void;
setRuntimeLimits: (limits?: RuntimeLimits) => void;
setHydrated: (hydrated: boolean) => void;
clearSession: () => void;
};
@@ -27,7 +35,9 @@ type UserStore = {
export const useUserStore = create<UserStore>()((set) => ({
hydrated: false,
user: null,
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 },
setUser: (user) => set({ user }),
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
setHydrated: (hydrated) => set({ hydrated }),
clearSession: () => set({ user: null }),
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
}));