Files
ttstd-infinite-canvas/web/src/pages/admin/components/feature-availability-panel.tsx

384 lines
19 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Skeleton } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { AlertTriangle, Clapperboard, Coins, ListChecks, MonitorCog, PlugZap, RadioTower, RefreshCw, ShieldCheck, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
import { AdminStatusBadge } from "./admin-ui";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers";
type FeatureRow = {
key: FeatureKey;
title: string;
description: string;
icon: ReactNode;
dependsOn?: FeatureKey;
};
const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"];
const workspaceFeatureRows: FeatureRow[] = [
{
key: "shortDramaEnabled",
title: "短剧创作",
description: "开放短剧入口、项目列表与项目详情。关闭不删除已有项目。",
icon: <Clapperboard className="size-4" aria-hidden="true" />,
},
{
key: "taskCenterEnabled",
title: "任务中心",
description: "开放任务记录页面。关闭不会停止生成任务。",
icon: <ListChecks className="size-4" aria-hidden="true" />,
},
{
key: "creditsEnabled",
title: "积分计费",
description: "控制钱包入口及新任务的积分预授权与结算。",
icon: <Coins className="size-4" aria-hidden="true" />,
},
{
key: "customChannelsEnabled",
title: "自定义渠道",
description: "允许用户配置并使用自己的模型渠道。",
icon: <RadioTower className="size-4" aria-hidden="true" />,
},
];
const pluginFeatureRows: FeatureRow[] = [
{
key: "pluginCenterEnabled",
title: "插件中心",
description: "开放插件中心及插件调用能力。",
icon: <PlugZap className="size-4" aria-hidden="true" />,
},
{
key: "systemPluginsVisibleToUsers",
title: "系统插件可见性",
description: "向普通用户展示系统协议插件和管理员上传插件。",
icon: <ShieldCheck className="size-4" aria-hidden="true" />,
dependsOn: "pluginCenterEnabled",
},
];
const modelFeatureRows: FeatureRow[] = [
{
key: "frontendModelsEnabled",
title: "前台模型目录",
description: "选择前台模型目录,或直接使用系统渠道中的模型。",
icon: <Sparkles className="size-4" aria-hidden="true" />,
},
];
const allFeatureRows = [...workspaceFeatureRows, ...pluginFeatureRows, ...modelFeatureRows];
const featureByKey = new Map(allFeatureRows.map((item) => [item.key, item]));
export default function FeatureAvailabilityPanel() {
const { message, modal } = App.useApp();
const setGlobalFeatures = useUserStore((state) => state.setFeatures);
const [savedFeatures, setSavedFeatures] = useState<FeatureAvailability | null>(null);
const [draftFeatures, setDraftFeatures] = useState<FeatureAvailability | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const requestVersionRef = useRef(0);
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const result = await getAdminFeatureAvailability();
const value = parseFeatureAvailability(result.features);
if (requestVersion !== requestVersionRef.current) return;
setSavedFeatures(value);
setDraftFeatures(value);
setGlobalFeatures(value);
setSaveError("");
if (announce) message.success("已重新读取当前功能状态");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取功能开放配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[message, setGlobalFeatures],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
const setFeature = async (key: FeatureKey, enabled: boolean) => {
if (!savedFeatures || saving || savedFeatures[key] === enabled) return;
const previous = savedFeatures;
const expected = { ...savedFeatures, [key]: enabled };
setDraftFeatures(expected);
setSaving(true);
setSaveError("");
try {
const result = await updateAdminFeatureAvailability(toEditablePayload(expected));
const value = parseFeatureAvailability(result.features);
if (!sameEditableFeatures(value, expected)) throw new Error("服务端返回的功能状态与本次保存内容不一致,请重新读取后核对");
setSavedFeatures(value);
setDraftFeatures(value);
setGlobalFeatures(value);
message.success(`${featureByKey.get(key)?.title || "功能"}已${enabled ? "开启" : "关闭"}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存功能开放配置失败";
setDraftFeatures(previous);
setSaveError(`${errorMessage}。已恢复修改前状态。`);
message.error(errorMessage);
} finally {
setSaving(false);
}
};
const requestFeatureChange = (key: FeatureKey, enabled: boolean) => {
if (!savedFeatures || saving || savedFeatures[key] === enabled) return;
if (key === "creditsEnabled" && !enabled) {
modal.confirm({
title: "关闭用户积分功能?",
content: "保存后新创建的任务和系统渠道请求将不再扣减积分;已经冻结的计费订单仍按原规则结算,已有余额和流水继续保留。",
okText: "确认关闭",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => setFeature(key, false),
});
return;
}
if (key === "frontendModelsEnabled" && !enabled) {
modal.confirm({
title: "关闭前台模型功能?",
content: "关闭后用户将直接使用系统渠道中配置的模型;管理后台仍保留前台模型配置入口,重新开启后即可继续使用。",
okText: "确认关闭",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => setFeature(key, false),
});
return;
}
void setFeature(key, enabled);
};
if (loading && !draftFeatures) {
return (
<div className="admin-settings-stack admin-feature-availability" aria-label="正在读取功能开放配置" role="status">
<div className="admin-feature-command-bar">
<Skeleton active title={{ width: 180 }} paragraph={false} />
</div>
<div className="admin-feature-board is-loading">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="admin-feature-domain">
<Skeleton active title={{ width: 120 }} paragraph={{ rows: 3 }} />
</div>
))}
</div>
<div className="admin-feature-loading-card">
<Skeleton active paragraph={{ rows: 2 }} />
</div>
</div>
);
}
if (!draftFeatures || !savedFeatures) {
return (
<div className="admin-settings-stack admin-feature-availability">
<div className="admin-feature-load-error" role="alert">
<span className="admin-feature-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取功能开放配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void load(true)}>
重新读取
</Button>
</div>
</div>
);
}
const enabledWorkspaceFeatures = workspaceFeatureRows.filter((row) => effectiveFeatureValue(draftFeatures, row.key)).length;
const enabledPluginFeatures = pluginFeatureRows.filter((row) => effectiveFeatureValue(draftFeatures, row.key)).length;
return (
<div className="admin-settings-stack admin-feature-availability">
<div className="admin-feature-command-bar">
<div className="admin-feature-command-copy" aria-live="polite">
<div className="flex flex-wrap items-center gap-2">
<strong>{saving ? "正在保存更改" : "开关切换后立即生效"}</strong>
<AdminStatusBadge label={saving ? "提交中" : savedFeatures.configured ? "服务端配置" : "系统默认"} tone={saving ? "warning" : "neutral"} />
</div>
</div>
<div className="admin-feature-command-actions">
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={() => void load(false, true)}>
刷新状态
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-feature-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的状态。`}</span>
</div>
) : null}
<div className="admin-feature-board" aria-label="功能开放控制台">
<FeatureDomainPanel
title="1. 用户工作台入口"
description="先决定普通用户能进入哪些核心工作区"
icon={<MonitorCog className="size-4" aria-hidden="true" />}
status={<AdminStatusBadge label={`${enabledWorkspaceFeatures}/4 开放`} tone={enabledWorkspaceFeatures === 4 ? "success" : "neutral"} />}
>
{workspaceFeatureRows.map((row) => (
<FeatureSettingRow key={row.key} row={row} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
))}
</FeatureDomainPanel>
<FeatureDomainPanel
title="2. 插件开放范围"
description="先开放插件中心,再决定系统插件是否可见"
icon={<PlugZap className="size-4" aria-hidden="true" />}
status={<AdminStatusBadge label={`${enabledPluginFeatures}/2 生效`} tone={enabledPluginFeatures === 2 ? "success" : "neutral"} />}
>
<FeatureSettingRow row={pluginFeatureRows[0]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} step={1} />
{draftFeatures.pluginCenterEnabled ? <FeatureSettingRow row={pluginFeatureRows[1]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} step={2} /> : null}
</FeatureDomainPanel>
<FeatureDomainPanel
title="3. 用户模型来源"
description="决定用户选模型时读取前台目录还是系统渠道"
icon={<Sparkles className="size-4" aria-hidden="true" />}
status={<AdminStatusBadge label={draftFeatures.frontendModelsEnabled ? "前台模型目录" : "系统渠道"} tone="info" />}
>
<FeatureSourceRow row={modelFeatureRows[0]} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
</FeatureDomainPanel>
</div>
</div>
);
}
function FeatureDomainPanel({ title, description, icon, status, children }: { title: string; description: string; icon: ReactNode; status: ReactNode; children: ReactNode }) {
return (
<section className="admin-feature-domain">
<header className="admin-feature-domain-header">
<span className="admin-feature-domain-icon">{icon}</span>
<div className="admin-feature-domain-copy min-w-0 flex-1">
<h2>{title}</h2>
<p>{description}</p>
</div>
<div className="admin-feature-domain-status">{status}</div>
</header>
<div className="admin-feature-domain-list">{children}</div>
</section>
);
}
function FeatureSettingRow({ row, saved, draft, saving, onChange, step }: { row: FeatureRow; saved: FeatureAvailability; draft: FeatureAvailability; saving: boolean; onChange: (key: FeatureKey, enabled: boolean) => void; step?: number }) {
const changed = saved[row.key] !== draft[row.key];
const dependencyDisabled = Boolean(row.dependsOn && !draft[row.dependsOn]);
const enabled = effectiveFeatureValue(draft, row.key);
return (
<article className={cn("admin-feature-board-row", changed && "is-dirty", !enabled && "is-off", row.dependsOn && "is-dependent")}>
<div className="admin-feature-board-row-copy">
<span className="admin-feature-board-row-icon">{row.icon}</span>
<span className="admin-feature-board-row-name">
{step ? <small>第 {step} 步</small> : null}
<strong>{row.title}</strong>
<span>{row.description}</span>
</span>
{changed ? <span className="admin-feature-board-row-dirty">待保存</span> : null}
</div>
<div className="admin-feature-board-row-control">
<span>{dependencyDisabled ? "依赖未开启" : enabled ? "已开放" : "已关闭"}</span>
<Switch checked={draft[row.key]} disabled={saving || dependencyDisabled} onChange={(checked) => onChange(row.key, checked)} aria-label={`设置${row.title},切换后立即保存`} />
</div>
</article>
);
}
function FeatureSourceRow({ row, saved, draft, saving, onChange }: { row: FeatureRow; saved: FeatureAvailability; draft: FeatureAvailability; saving: boolean; onChange: (key: FeatureKey, enabled: boolean) => void }) {
const changed = saved.frontendModelsEnabled !== draft.frontendModelsEnabled;
return (
<article className={cn("admin-feature-board-row is-source", changed && "is-dirty")}>
<div className="admin-feature-board-row-copy">
<span className="admin-feature-board-row-icon">{row.icon}</span>
<span className="admin-feature-board-row-name">
<small>模型来源</small>
<strong>用户模型目录</strong>
<span>{row.description}</span>
</span>
{changed ? <span className="admin-feature-board-row-dirty">待保存</span> : null}
</div>
<div className="admin-feature-board-source-selector" role="radiogroup" aria-label="选择用户模型目录来源">
<button type="button" role="radio" aria-checked={!draft.frontendModelsEnabled} disabled={saving} className={cn(!draft.frontendModelsEnabled && "is-selected")} onClick={() => onChange("frontendModelsEnabled", false)}>
系统渠道
</button>
<button type="button" role="radio" aria-checked={draft.frontendModelsEnabled} disabled={saving} className={cn(draft.frontendModelsEnabled && "is-selected")} onClick={() => onChange("frontendModelsEnabled", true)}>
前台目录
</button>
</div>
</article>
);
}
function effectiveFeatureValue(features: FeatureAvailability, key: FeatureKey) {
if (key === "systemPluginsVisibleToUsers") return features.pluginCenterEnabled && features.systemPluginsVisibleToUsers;
return features[key];
}
function toEditablePayload(features: FeatureAvailability) {
return {
shortDramaEnabled: features.shortDramaEnabled,
taskCenterEnabled: features.taskCenterEnabled,
creditsEnabled: features.creditsEnabled,
customChannelsEnabled: features.customChannelsEnabled,
frontendModelsEnabled: features.frontendModelsEnabled,
pluginCenterEnabled: features.pluginCenterEnabled,
systemPluginsVisibleToUsers: features.systemPluginsVisibleToUsers,
};
}
function sameEditableFeatures(left: FeatureAvailability, right: FeatureAvailability) {
return editableFeatureKeys.every((key) => left[key] === right[key]);
}
function parseFeatureAvailability(value: unknown): FeatureAvailability {
if (!value || typeof value !== "object") throw new Error("功能开放配置响应格式无效");
const record = value as Record<string, unknown>;
for (const key of editableFeatureKeys) {
if (typeof record[key] !== "boolean") throw new Error("功能开放配置响应缺少有效开关状态");
}
return {
welcomeEnabled: record.welcomeEnabled as boolean,
shortDramaEnabled: record.shortDramaEnabled as boolean,
taskCenterEnabled: record.taskCenterEnabled as boolean,
creditsEnabled: record.creditsEnabled as boolean,
customChannelsEnabled: record.customChannelsEnabled as boolean,
frontendModelsEnabled: record.frontendModelsEnabled as boolean,
pluginCenterEnabled: record.pluginCenterEnabled as boolean,
systemPluginsVisibleToUsers: record.systemPluginsVisibleToUsers as boolean,
configured: typeof record.configured === "boolean" ? record.configured : undefined,
updatedBy: typeof record.updatedBy === "string" ? record.updatedBy : undefined,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}