feat(admin): 资源与策略 - 后台统一维护账号配额与运行时请求限制 (#37)
Fixes #35 Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}` },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 }) : "--"; }
|
||||
200
web/src/pages/admin/settings/runtime-policy-settings-page.tsx
Normal file
200
web/src/pages/admin/settings/runtime-policy-settings-page.tsx
Normal 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 }) : "--";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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 } }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user