feat(*): 功能开放与页面布局 - 新增运维开关并优化后台及创作界面空间利用

This commit is contained in:
ddCat
2026-08-04 16:24:18 +08:00
parent 8f7c492b70
commit f439d600ba
36 changed files with 723 additions and 86 deletions

View File

@@ -8,6 +8,7 @@ const AdminAnnouncementsPanel = lazy(() => import("./components/admin-announceme
const CreditOperationsPanel = lazy(() => import("./components/credit-operations-panel"));
const AccessSettingsPanel = lazy(() => import("./components/access-settings-panel"));
const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel"));
const FeatureAvailabilityPanel = lazy(() => import("./components/feature-availability-panel"));
function PageFallback({ label }: { label: string }) {
return <div className="py-16 text-center text-sm text-foreground/50">正在读取{label}...</div>;
@@ -32,5 +33,9 @@ export function AccessSettingsPage() {
}
export function EmailSettingsPage() {
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP"><div className="mx-auto max-w-5xl"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP"><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
}
export function FeatureAvailabilityPage() {
return <AdminPageFrame title="功能开放" description="控制用户工作台入口与计费模式"><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
}

View File

@@ -68,7 +68,7 @@ export default function AccessSettingsPanel() {
return (
<div className="mx-auto max-w-5xl space-y-5">
<div className="space-y-4 pt-4">
<SettingsSectionCard
icon={<UserPlus className="size-4" />}
title="用户注册"

View File

@@ -1,5 +1,5 @@
import { Tooltip } from "antd";
import { ArrowLeft, BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, Paintbrush, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { ArrowLeft, BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, Paintbrush, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, ToggleLeft, UsersRound } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Link, NavLink, Outlet } from "react-router";
@@ -40,6 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{
label: "系统配置",
items: [
{ path: "/admin/settings/features", label: "功能开放", description: "菜单、页面与积分模式", icon: <ToggleLeft className="size-4" /> },
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
@@ -99,8 +100,8 @@ export function AdminShell() {
export function AdminPageFrame({ title, description, actions, back, children }: { title: string; description: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; children: ReactNode }) {
return (
<WorkspacePage>
<div className="mx-auto w-full max-w-[1440px] pb-5">
<header className="flex min-h-[64px] flex-col gap-3 border-b border-border/75 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full pb-5">
<header className="flex min-h-14 flex-col gap-3 border-b border-border/75 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2.5">
{back ? (
<Tooltip title={back.label}>

View File

@@ -188,19 +188,21 @@ export function SettingsSectionCard({
className?: string;
}) {
return (
<section className={cn("overflow-hidden rounded-lg border border-border bg-background", className)}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-5 py-4">
<section className={cn("border-y border-border bg-background lg:grid lg:grid-cols-4", className)}>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-4 py-4 lg:col-span-1 lg:block lg:border-b-0 lg:border-r">
<div className="flex min-w-0 items-start gap-3">
{icon ? <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/40">{icon}</span> : null}
<div className="min-w-0">
<h2 className="text-base font-semibold">{title}</h2>
<h2 className="text-sm font-semibold leading-5">{title}</h2>
<p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p>
</div>
</div>
{isStatusConfig(status) ? <Tag variant="filled" color={status.color}>{status.label}</Tag> : status}
{status ? <div className="shrink-0 lg:mt-4">{isStatusConfig(status) ? <Tag variant="filled" color={status.color}>{status.label}</Tag> : status}</div> : null}
</div>
<div className="min-w-0 lg:col-span-3">
{children}
{footer ? <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">{footer}</div> : null}
</div>
{children}
{footer ? <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4">{footer}</div> : null}
</section>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState, type ReactNode } from "react";
import { App, Switch } from "antd";
import { Clapperboard, Coins, ListChecks, ToggleLeft } from "lucide-react";
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
import { SettingsSectionCard } from "./admin-ui";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled";
const featureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [
{ key: "shortDramaEnabled", title: "短剧创作", menu: "/projects", description: "关闭后隐藏短剧入口,并拦截项目列表、详情和项目 API。已有项目数据不会删除。", icon: <Clapperboard className="size-4" /> },
{ key: "taskCenterEnabled", title: "任务", menu: "/tasks", description: "关闭后仅隐藏并拦截任务中心页面;生成任务仍会创建、执行、记录和恢复。", icon: <ListChecks className="size-4" /> },
{ key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: <Coins className="size-4" /> },
];
export default function FeatureAvailabilityPanel() {
const { message, modal } = App.useApp();
const setGlobalFeatures = useUserStore((state) => state.setFeatures);
const [features, setFeatures] = useState<FeatureAvailability | null>(null);
const [saving, setSaving] = useState<FeatureKey | null>(null);
useEffect(() => {
let active = true;
getAdminFeatureAvailability()
.then(({ features: value }) => {
if (active) setFeatures(value);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取功能开放配置失败"));
return () => {
active = false;
};
}, [message]);
const save = async (key: FeatureKey, enabled: boolean) => {
if (!features) return;
setSaving(key);
try {
const next = { ...features, [key]: enabled };
const result = await updateAdminFeatureAvailability({
shortDramaEnabled: next.shortDramaEnabled,
taskCenterEnabled: next.taskCenterEnabled,
creditsEnabled: next.creditsEnabled,
});
setFeatures(result.features);
setGlobalFeatures(result.features);
message.success(`${featureRows.find((item) => item.key === key)?.title || "功能"}已${enabled ? "开放" : "关闭"}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存功能开放配置失败");
} finally {
setSaving(null);
}
};
const toggle = (key: FeatureKey, enabled: boolean) => {
if (key !== "creditsEnabled" || enabled) {
void save(key, enabled);
return;
}
modal.confirm({
title: "关闭用户积分功能?",
content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。",
okText: "确认关闭",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => save(key, false),
});
};
const enabledCount = features ? featureRows.filter((item) => features[item.key]).length : 0;
return (
<div className="pt-4">
<SettingsSectionCard
icon={<ToggleLeft className="size-4" />}
title="用户功能开放"
description="统一控制用户工作台入口、页面访问和积分消费策略。"
status={{ label: features ? `${enabledCount}/${featureRows.length} 已开放` : "读取中", color: enabledCount === featureRows.length ? "success" : "default" }}
>
<div className="divide-y divide-border/75">
{featureRows.map((item) => (
<div key={item.key} className="flex min-h-16 items-center gap-3 px-4 py-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/35 text-foreground/65">{item.icon}</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium">{item.title}</h3>
<span className="rounded border border-border/70 px-1.5 py-0.5 text-[var(--fs-micro)] text-foreground/45">{item.menu}</span>
</div>
<p className="mt-1 text-xs leading-5 text-foreground/55">{item.description}</p>
</div>
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
</div>
))}
</div>
</SettingsSectionCard>
</div>
);
}

View File

@@ -55,18 +55,32 @@ export default function DrawingEngineSettingsPage() {
const dirty = engine !== current.defaultEngine || tldrawLicenseKey !== (current.tldrawLicenseKey || "");
return (
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器">
<div className="mx-auto max-w-4xl space-y-5">
<SettingsSectionCard icon={<Brush className="size-4" />} title="默认绘图引擎" description="已有绘图保持原引擎,仅新建绘图使用这里的选择。" status={<Tag variant="filled" color="blue">{drawingEngineLabel(current.defaultEngine)}</Tag>}>
<div className="flex flex-col gap-5 px-5 py-5">
<div className="w-full sm:max-w-md">
<div className="pt-4">
<SettingsSectionCard
icon={<Brush className="size-4" />}
title="默认绘图引擎"
description="已有绘图保持原引擎,仅新建绘图使用这里的选择。"
status={<Tag variant="filled" color="blue">{drawingEngineLabel(current.defaultEngine)}</Tag>}
footer={(
<>
<span className={`text-xs ${tldrawAvailable ? "text-foreground/45" : "text-amber-600 dark:text-amber-400"}`}>
{tldrawAvailable ? "配置只影响之后新建的绘图节点。" : "未配置 tldraw License Key,暂不能将其设为默认引擎。"}
</span>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
</>
)}
>
<div className="grid gap-4 px-4 py-4 md:grid-cols-2">
<div className="min-w-0">
<label className="mb-2 block text-sm font-medium">tldraw License Key</label>
<Input.Password value={tldrawLicenseKey} onChange={(event) => setTldrawLicenseKey(event.target.value)} placeholder="输入 tldraw License Key" disabled={loading || saving} autoComplete="off" />
<p className="mt-2 text-xs text-foreground/55">填入有效的 License Key 后保存,即可解锁 tldraw 选项。</p>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<label className="mb-2 block text-sm font-medium">新建默认引擎</label>
<Segmented<CanvasDrawingEngine>
block
className="w-full sm:max-w-md"
className="w-full"
disabled={loading || saving}
value={engine}
onChange={setEngine}
@@ -75,12 +89,11 @@ export default function DrawingEngineSettingsPage() {
{ label: "tldraw", value: "tldraw", disabled: !tldrawAvailable },
]}
/>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
<p className="mt-2 text-xs text-foreground/55">切换后不会转换或覆盖已有绘图。</p>
</div>
</div>
{!tldrawAvailable ? <div className="border-t border-border px-5 py-3 text-xs text-amber-600 dark:text-amber-400">当前未配置 tldraw License Key,不能将其设为新建默认值。</div> : null}
</SettingsSectionCard>
</div>
</AdminPageFrame>
);
}
}

View File

@@ -165,7 +165,7 @@ export default function RuntimePolicySettingsPage() {
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">
<div className="space-y-3 pt-4">
<PolicySection icon={<Database className="size-4" />} title="资源与账号配额" description="上传、文件容量、结构化数据和历史记录上限。" fields={resourceFields} />
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<Tag variant="filled" color="blue">热更新</Tag>} />
<PolicySection icon={<TimerReset className="size-4" />} title="任务超时" description="不同生成类型的最长执行时间。" fields={timeoutFields} />
@@ -184,7 +184,7 @@ export default function RuntimePolicySettingsPage() {
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">
<div className="grid grid-cols-1 gap-x-4 px-4 pt-4 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} />

View File

@@ -46,8 +46,8 @@ export default function StorageSettingsPage() {
return (
<AdminPageFrame title="存储服务" description="OSS 与资源存储">
<div className="mx-auto max-w-5xl space-y-5">
<div className="rounded-lg border border-border bg-muted/25 p-4 text-foreground/75">
<div className="space-y-4 pt-4">
<div className="border-b border-border px-1 pb-4 text-foreground/75">
<div className="flex items-start gap-3"><span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-md bg-muted/60"><Info className="size-4" /></span><div><div className="text-sm font-semibold text-foreground">资源存储规则</div><p className="mt-1 text-xs leading-6 text-foreground/55">启用后,新上传和生成的媒体由后端写入 OSS;未启用时写入后端数据卷。资源统一通过登录鉴权接口读取,不直接暴露 OSS 对象地址。</p></div></div>
</div>
<SettingsSectionCard
@@ -70,7 +70,7 @@ export default function StorageSettingsPage() {
</div>
</Form>
</SettingsSectionCard>
<div className="grid gap-3 text-xs text-foreground/55 sm:grid-cols-3"><Notice icon={<Cloud className="size-3.5" />} text="新资源优先上传 OSS" /><Notice icon={<ShieldCheck className="size-3.5" />} text="AccessKey Secret 不回显" /><Notice icon={<KeyRound className="size-3.5" />} text="异常时自动本地降级" /></div>
<div className="grid border-y border-border text-xs text-foreground/55 sm:grid-cols-3 sm:divide-x sm:divide-border"><Notice icon={<Cloud className="size-3.5" />} text="新资源优先上传 OSS" /><Notice icon={<ShieldCheck className="size-3.5" />} text="AccessKey Secret 不回显" /><Notice icon={<KeyRound className="size-3.5" />} text="异常时自动本地降级" /></div>
</div>
</AdminPageFrame>
);
@@ -78,4 +78,4 @@ export default function StorageSettingsPage() {
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { enabled: setting?.enabled || false, provider: setting?.provider || "aliyun", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
function Notice({ icon, text }: { icon: ReactNode; text: string }) { return <div className="flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2"><span className="text-foreground/40">{icon}</span><span>{text}</span></div>; }
function Notice({ icon, text }: { icon: ReactNode; text: string }) { return <div className="flex items-center gap-2 px-3 py-2.5"><span className="text-foreground/40">{icon}</span><span>{text}</span></div>; }