feat(features): 功能开放 - 增加自定义渠道开关

This commit is contained in:
MasterChiefCN
2026-08-15 13:25:50 +08:00
parent b2a7eef35d
commit f83ff03df3
16 changed files with 154 additions and 30 deletions

View File

@@ -37,5 +37,5 @@ export function EmailSettingsPage() {
}
export function FeatureAvailabilityPage() {
return <AdminPageFrame title="功能开放" description="控制用户工作台入口与计费模式"><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式"><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
}

View File

@@ -40,7 +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/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" /> },

View File

@@ -1,17 +1,18 @@
import { useEffect, useState, type ReactNode } from "react";
import { App, Switch } from "antd";
import { Clapperboard, Coins, ListChecks, ToggleLeft } from "lucide-react";
import { Clapperboard, Coins, ListChecks, RadioTower, 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";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled";
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" /> },
{ key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: <RadioTower className="size-4" /> },
];
export default function FeatureAvailabilityPanel() {
@@ -41,6 +42,7 @@ export default function FeatureAvailabilityPanel() {
shortDramaEnabled: next.shortDramaEnabled,
taskCenterEnabled: next.taskCenterEnabled,
creditsEnabled: next.creditsEnabled,
customChannelsEnabled: next.customChannelsEnabled,
});
setFeatures(result.features);
setGlobalFeatures(result.features);
@@ -73,7 +75,7 @@ export default function FeatureAvailabilityPanel() {
<SettingsSectionCard
icon={<ToggleLeft className="size-4" />}
title="用户功能开放"
description="统一控制用户工作台入口、页面访问和积分消费策略。"
description="统一控制用户工作台入口、页面访问、渠道能力和积分消费策略。"
status={{ label: features ? `${enabledCount}/${featureRows.length} 已开放` : "读取中", color: enabledCount === featureRows.length ? "success" : "default" }}
>
<div className="divide-y divide-border/75">

View File

@@ -34,17 +34,24 @@ export default function SettingsPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section");
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(isConfigSection(requestedSection) ? requestedSection : "channels");
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
const initialSection = isConfigSection(requestedSection) ? requestedSection : customChannelsEnabled ? "channels" : "models";
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(initialSection === "channels" && !customChannelsEnabled ? "models" : initialSection);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveConfig();
const updateConfig = useConfigStore((state) => state.updateConfig);
const shouldPromptContinue = searchParams.get("continue") === "1";
const userId = useUserStore((state) => state.user?.id);
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
const visibleConfigSections = customChannelsEnabled ? configSections : configSections.filter((section) => section.key !== "channels");
useEffect(() => {
if (isConfigSection(requestedSection)) setActiveTab(requestedSection);
}, [requestedSection]);
if (isConfigSection(requestedSection) && (requestedSection !== "channels" || customChannelsEnabled)) {
setActiveTab(requestedSection);
return;
}
if (!customChannelsEnabled) setActiveTab((current) => (current === "channels" ? "models" : current));
}, [customChannelsEnabled, requestedSection]);
useEffect(() => {
if (!userId) return;
@@ -65,7 +72,7 @@ export default function SettingsPage() {
};
const finishConfig = () => {
const invalidChannel = userChannels.find((channel) => channelValidationError(channel));
const invalidChannel = customChannelsEnabled ? userChannels.find((channel) => channelValidationError(channel)) : undefined;
if (invalidChannel) {
selectSection("channels");
message.warning(`${invalidChannel.name || "未命名渠道"}:${channelValidationError(invalidChannel)}`);
@@ -73,9 +80,9 @@ export default function SettingsPage() {
return;
}
const hasReadyLocalRuntime = effectiveConfig.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false && Boolean(channel.localModels?.length));
if (!config.channels.some(isChannelReady) && !hasReadyLocalRuntime) {
selectSection("channels");
message.error(shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置");
if (!effectiveConfig.channels.some(isChannelReady) && !hasReadyLocalRuntime) {
selectSection(customChannelsEnabled ? "channels" : "models");
message.error(customChannelsEnabled ? (shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置") : "当前没有可用的系统模型,请联系管理员配置系统渠道");
return;
}
message.success("配置已保存,正在返回创作页面");
@@ -190,7 +197,7 @@ export default function SettingsPage() {
<div className="settings-library-frame flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="settings-nav-panel w-full shrink-0 md:w-[200px]">
<nav className="thin-scrollbar flex gap-1 overflow-x-auto p-2 md:block md:space-y-1 md:p-2.5" aria-label="配置分类">
{configSections.map((item) => {
{visibleConfigSections.map((item) => {
const selected = item.key === activeTab;
return (
<button

View File

@@ -351,7 +351,7 @@ export function getAdminFeatureAvailability() {
return request<{ features: FeatureAvailability }>(api.get("/admin/settings/features"));
}
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled">) {
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled">) {
return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features));
}

View File

@@ -9,6 +9,7 @@ import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } f
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
import { useUserStore } from "@/stores/use-user-store";
import type { DreaminaLocalModel } from "@/services/local-dreamina-model-catalog";
export type ApiCallFormat = "openai" | "gemini";
@@ -345,9 +346,19 @@ function normalizeSelectedModel(value: string, channels: ModelChannel[], options
export function useEffectiveConfig() {
const config = useConfigStore((state) => state.config);
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
const catalogState = useLocalDreaminaModelStore((state) => state.state);
const dreaminaModels = useLocalDreaminaModelStore((state) => state.models);
return useMemo(() => effectiveConfigWithDreamina(config, catalogState, dreaminaModels), [catalogState, config, dreaminaModels]);
return useMemo(
() => effectiveConfigWithDreamina(effectiveConfigForCustomChannels(config, customChannelsEnabled), catalogState, dreaminaModels),
[catalogState, config, customChannelsEnabled, dreaminaModels],
);
}
export function effectiveConfigForCustomChannels(config: AiConfig, customChannelsEnabled: boolean): AiConfig {
if (customChannelsEnabled) return config;
const channels = config.channels.filter((channel) => channel.scope === "system" || channel.transport === "local-runtime");
return normalizeConfigSnapshot({ config: { ...config, channels } }).config;
}
export function effectiveConfigWithDreamina(config: AiConfig, catalogState: "idle" | "loading" | "ready" | "error", dreaminaModels: DreaminaLocalModel[]): AiConfig {

View File

@@ -28,6 +28,7 @@ export type FeatureAvailability = {
shortDramaEnabled: boolean;
taskCenterEnabled: boolean;
creditsEnabled: boolean;
customChannelsEnabled: boolean;
desktopLocalChannelsEnabled: boolean;
configured?: boolean;
updatedBy?: string;
@@ -38,6 +39,7 @@ export const defaultFeatureAvailability: FeatureAvailability = {
shortDramaEnabled: true,
taskCenterEnabled: true,
creditsEnabled: true,
customChannelsEnabled: true,
desktopLocalChannelsEnabled: false,
};
@@ -64,7 +66,7 @@ export const useUserStore = create<UserStore>()((set) => ({
setUser: (user) => set({ user }),
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
setDrawingEngine: (drawingEngine) => set({ drawingEngine: drawingEngine || { defaultEngine: DEFAULT_DRAWING_ENGINE } }),
setFeatures: (features) => set({ features: features || defaultFeatureAvailability }),
setFeatures: (features) => set({ features: features ? { ...defaultFeatureAvailability, ...features } : defaultFeatureAvailability }),
setHydrated: (hydrated) => set({ hydrated }),
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE }, features: defaultFeatureAvailability }),
}));