feat(*): 功能开放与页面布局 - 新增运维开关并优化后台及创作界面空间利用
This commit is contained in:
51
web/src/components/auth/require-feature.tsx
Normal file
51
web/src/components/auth/require-feature.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button } from "antd";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { refreshFeatureAvailability } from "@/lib/user-session";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled";
|
||||
|
||||
const featureNames: Record<FeatureKey, string> = {
|
||||
shortDramaEnabled: "短剧创作",
|
||||
taskCenterEnabled: "任务中心",
|
||||
creditsEnabled: "积分中心",
|
||||
};
|
||||
|
||||
export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const features = useUserStore((state) => state.features);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setChecking(true);
|
||||
setError("");
|
||||
refreshFeatureAvailability()
|
||||
.catch((reason) => {
|
||||
if (!cancelled) setError(reason instanceof Error ? reason.message : "读取功能开放状态失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setChecking(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
|
||||
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/create", { replace: true })} /></WorkspacePage>;
|
||||
if (!features[feature]) {
|
||||
return (
|
||||
<WorkspacePage>
|
||||
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate("/create", { replace: true })}>返回创作台</Button>} />
|
||||
</WorkspacePage>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -91,6 +93,7 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
|
||||
expanded={expandedTaskId === task.id}
|
||||
onToggle={() => setExpandedTaskId((current) => current === task.id ? null : task.id)}
|
||||
reducedMotion={Boolean(reducedMotion)}
|
||||
creditsEnabled={creditsEnabled}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
@@ -103,7 +106,7 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }: { task: GenerationTask; now: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; expanded: boolean; onToggle: () => void; reducedMotion: boolean }) {
|
||||
function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion, creditsEnabled }: { task: GenerationTask; now: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; expanded: boolean; onToggle: () => void; reducedMotion: boolean; creditsEnabled: boolean }) {
|
||||
const progress = typeof task.progress === "number" ? Math.max(0, Math.min(100, Math.round(task.progress))) : task.status === "queued" ? 0 : undefined;
|
||||
const startedAt = task.startedAt || task.createdAt;
|
||||
const elapsedMs = Math.max(0, now - parseTime(startedAt));
|
||||
@@ -170,9 +173,9 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }:
|
||||
<span className="font-medium tabular-nums" style={{ color: statusTone }}>{progress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
<div className={`mt-3 grid gap-2 text-[var(--fs-tiny)] ${creditsEnabled ? "grid-cols-2" : "grid-cols-1"}`} style={{ color: theme.node.muted }}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1 truncate" title={durationLabel}><Clock3 className="size-3 shrink-0" />{durationLabel}</span>
|
||||
<span className="inline-flex min-w-0 items-center justify-end gap-1 truncate" title={billingLabel}><Coins className="size-3 shrink-0" />{billingLabel}</span>
|
||||
{creditsEnabled ? <span className="inline-flex min-w-0 items-center justify-end gap-1 truncate" title={billingLabel}><Coins className="size-3 shrink-0" />{billingLabel}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BookOpenText, Bot, Clapperboard, Focus, Globe2, LayoutTemplate, Laptop,
|
||||
|
||||
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasAgentMode } from "./canvas-agent-chat-ui";
|
||||
|
||||
export function AgentPanelChrome({
|
||||
@@ -90,6 +91,8 @@ const starterActions = [
|
||||
];
|
||||
|
||||
export function AgentChatEmptyState({ theme, nodeCount, onSelect }: { theme: CanvasTheme; nodeCount: number; onSelect: (value: string) => void }) {
|
||||
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
|
||||
const visibleStarterActions = shortDramaEnabled ? starterActions : starterActions.filter((item) => item.label !== "搭建短剧工作流");
|
||||
return (
|
||||
<div className="flex h-full items-center px-5 py-8">
|
||||
<div className="mx-auto w-full max-w-[380px]">
|
||||
@@ -99,7 +102,7 @@ export function AgentChatEmptyState({ theme, nodeCount, onSelect }: { theme: Can
|
||||
</div>
|
||||
<h2 className="mt-3 text-[19px] font-semibold leading-6" style={{ color: theme.node.text }}>从当前画布开始</h2>
|
||||
<div className="mt-4 grid grid-cols-1 gap-1">
|
||||
{starterActions.map(({ label, icon: Icon }) => (
|
||||
{visibleStarterActions.map(({ label, icon: Icon }) => (
|
||||
<button key={label} type="button" className="group flex min-h-11 min-w-0 items-center gap-2.5 rounded-md px-2.5 text-left text-xs font-medium transition-colors" style={{ color: theme.node.text }} onMouseEnter={(event) => { event.currentTarget.style.background = theme.spatial.surface; }} onMouseLeave={(event) => { event.currentTarget.style.background = "transparent"; }} onFocus={(event) => { event.currentTarget.style.background = theme.spatial.surface; }} onBlur={(event) => { event.currentTarget.style.background = "transparent"; }} onClick={() => onSelect(label)}>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.spatial.surface, color: theme.node.muted }}><Icon className="size-3.5" /></span>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
@@ -40,6 +41,7 @@ const videoOperationOptions: Array<{ label: string; value: CanvasVideoEditOperat
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
@@ -47,7 +49,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
|
||||
const hasPrice = credits !== null;
|
||||
const hasPrice = creditsEnabled && credits !== null;
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
@@ -140,7 +142,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
<div className="mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
|
||||
) : (
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth />
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
|
||||
{mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : mode === "image" ? (
|
||||
@@ -168,7 +170,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasPrice ? (
|
||||
{!creditsEnabled ? (
|
||||
<span>生成</span>
|
||||
) : hasPrice ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CreditSymbol />
|
||||
{credits.toLocaleString()}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
@@ -39,6 +40,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const themeName = useThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[themeName];
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const mode = defaultMode(node.type);
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
@@ -219,7 +221,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
) : mode === "audio" ? (
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
) : null}
|
||||
<GenerationCostBadge credits={credits} theme={theme} />
|
||||
{creditsEnabled ? <GenerationCostBadge credits={credits} theme={theme} /> : null}
|
||||
<Button
|
||||
type="text"
|
||||
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
|
||||
|
||||
@@ -82,6 +82,21 @@ export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUp
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasFreeformEmptyState({ onUpload, onAddText }: { onUpload: () => void; onAddText: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-20 pt-24">
|
||||
<div className="pointer-events-auto w-full max-w-[440px] rounded-lg border p-4 shadow-sm backdrop-blur" data-canvas-no-zoom style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<div className="text-center"><h2 className="text-base font-semibold">从空白画布开始</h2><p className="mt-1 text-xs" style={{ color: theme.node.muted }}>添加文本或导入已有素材。</p></div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={onAddText} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-4" />新建文本</button>
|
||||
<button type="button" onClick={onUpload} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Upload className="size-4" />导入素材</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PathCard({ icon, title, description, action, accent, theme, focusStyle, onClick }: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
|
||||
@@ -6,11 +6,15 @@ import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
|
||||
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { refreshFeatureAvailability } from "@/lib/user-session";
|
||||
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const features = useUserStore((state) => state.features);
|
||||
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
||||
const scrollRef = useRef<HTMLElement>(null);
|
||||
const [scrollState, setScrollState] = useState({
|
||||
@@ -22,7 +26,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const creationWorkspace = pathname === "/create";
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
const visibleNavigationTools = spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间");
|
||||
const visibleNavigationTools = (spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间"))
|
||||
.filter((tool) => {
|
||||
if (tool.slug === "projects") return features.shortDramaEnabled;
|
||||
if (tool.slug === "tasks") return features.taskCenterEnabled;
|
||||
if (tool.slug === "wallet") return features.creditsEnabled;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleScroll = () => {
|
||||
const element = scrollRef.current;
|
||||
@@ -49,6 +59,17 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
handleScroll();
|
||||
}, [visibleNavigationTools.length, mobileSidebarExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const refresh = () => void refreshFeatureAvailability().catch((error) => console.warn("功能开放状态刷新失败", error));
|
||||
const timer = window.setInterval(refresh, 30_000);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
|
||||
|
||||
@@ -24,10 +24,11 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, true);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const balance = availableMicrocredits === null
|
||||
? "--"
|
||||
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
@@ -69,7 +70,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
<UserAvatar user={user} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
|
||||
<div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div>
|
||||
{creditsEnabled ? <div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,11 +93,11 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button type="button" className={cn("flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-foreground/[.045]", accountClassName)} title={`${user.displayName || user.username} · ${balance} 积分`}>
|
||||
<button type="button" className={cn("flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-foreground/[.045]", accountClassName)} title={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
|
||||
<UserAvatar user={user} className="size-7" />
|
||||
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
|
||||
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-micro)] tabular-nums text-foreground/42">{balance} 积分</span>
|
||||
{creditsEnabled ? <span className="mt-0.5 block truncate text-[var(--fs-micro)] tabular-nums text-foreground/42">{balance} 积分</span> : null}
|
||||
</span>
|
||||
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/30", expandedClassName)} />
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
@@ -21,6 +22,7 @@ type ModelPickerProps = {
|
||||
};
|
||||
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) {
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const pickerId = useId();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -129,7 +131,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} />
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
@@ -172,7 +174,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
|
||||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}><ModelIcon model={current} /></span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current) : placeholder}</span>
|
||||
{showSelectedPrice ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
</span>
|
||||
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -187,7 +189,7 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
}
|
||||
|
||||
function ModelLabel({ config, model, capability, theme, creationVariant }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean }) {
|
||||
function ModelLabel({ config, model, capability, theme, creationVariant, showPrice }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showPrice: boolean }) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
@@ -198,7 +200,7 @@ function ModelLabel({ config, model, capability, theme, creationVariant }: { con
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
|
||||
</span>
|
||||
<ModelPrice price={modelMenuPrice(config, model)} />
|
||||
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSystemChannels, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { getFeatureAvailability, getSystemChannels, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
@@ -26,6 +26,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
useUserStore.getState().setUser(payload.user);
|
||||
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
|
||||
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
|
||||
useUserStore.getState().setFeatures(payload.features);
|
||||
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate()]);
|
||||
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
|
||||
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
|
||||
@@ -57,3 +58,9 @@ export async function refreshSystemChannels() {
|
||||
const payload = await getSystemChannels();
|
||||
useConfigStore.getState().mergeSystemChannels(payload.channels || []);
|
||||
}
|
||||
|
||||
export async function refreshFeatureAvailability() {
|
||||
const payload = await getFeatureAvailability();
|
||||
useUserStore.getState().setFeatures(payload.features);
|
||||
return payload.features;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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="用户注册"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>; }
|
||||
|
||||
@@ -69,7 +69,8 @@ export function CanvasTopBar({
|
||||
}: CanvasTopBarProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const user = useUserStore((state) => state.user);
|
||||
const { availableMicrocredits, refreshing } = useWalletBalance(user?.id);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits, refreshing } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const titleRef = useRef<HTMLDivElement>(null);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
|
||||
@@ -188,7 +189,7 @@ export function CanvasTopBar({
|
||||
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" title="媒体性能模式" />
|
||||
</Dropdown>
|
||||
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
|
||||
{user ? (
|
||||
{user && creditsEnabled ? (
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums transition hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
||||
@@ -49,7 +49,7 @@ import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
|
||||
import { CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
|
||||
import {
|
||||
createCanvasNode,
|
||||
getInputSummary,
|
||||
@@ -194,6 +194,7 @@ function InfiniteCanvasPage() {
|
||||
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
|
||||
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
|
||||
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
|
||||
const [connections, setConnections] = useState<CanvasConnection[]>([]);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
|
||||
@@ -331,7 +332,7 @@ function InfiniteCanvasPage() {
|
||||
cleanupAssetImages,
|
||||
cleanupCanvasFiles,
|
||||
});
|
||||
const linkedProjectId = currentProject?.projectId || "";
|
||||
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
|
||||
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || !linkedProjectQuery.data) return;
|
||||
@@ -1348,13 +1349,35 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
focusCanvasNode(styleNode.id);
|
||||
}, [focusCanvasNode, message, nodesRef]);
|
||||
const emptyCanvasState = nodes.length ? null : !shortDramaEnabled ? (
|
||||
<CanvasFreeformEmptyState onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} />
|
||||
) : currentProject?.projectId ? (
|
||||
<CanvasLinkedProjectEmptyState
|
||||
projectName={linkedProjectQuery.data?.project.name || currentProject.title}
|
||||
hasChapter={Boolean(linkedProjectQuery.data?.units.length)}
|
||||
onAddFirstChapter={() => {
|
||||
const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0];
|
||||
if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position });
|
||||
}}
|
||||
onOpenAssets={() => openProjectAssets()}
|
||||
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||
/>
|
||||
) : (
|
||||
<CanvasShortDramaEmptyState
|
||||
onCreatePipeline={createShortDramaPipeline}
|
||||
onOpenAgent={() => { setCinematicAgentEntry(true); setAgentMode("online"); openAgent("online"); }}
|
||||
onUpload={() => handleUploadRequest()}
|
||||
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||
onAddScript={() => createNode(CanvasNodeType.Script)}
|
||||
/>
|
||||
);
|
||||
if (!projectLoaded) return <CanvasRefreshShell />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#canvas-main" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[var(--z-toast)] focus:rounded-md focus:border focus:bg-background focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg">跳转到画布主内容</a>
|
||||
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: theme.canvas.background, color: theme.node.text }}>
|
||||
{currentProject?.projectId ? <CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} /> : null}
|
||||
{shortDramaEnabled && currentProject?.projectId ? <CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} /> : null}
|
||||
<section className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<CanvasTopBar
|
||||
title={currentProject?.title || "未命名画布"}
|
||||
@@ -1381,7 +1404,7 @@ function InfiniteCanvasPage() {
|
||||
mediaPerformanceMode={mediaPerformanceMode}
|
||||
onMediaPerformanceModeChange={setMediaPerformanceMode}
|
||||
onOpenSearch={() => setNodeSearchOpen(true)}
|
||||
projectContext={currentProject?.projectId ? {
|
||||
projectContext={shortDramaEnabled && currentProject?.projectId ? {
|
||||
...canvasContext,
|
||||
projectId: currentProject.projectId,
|
||||
projectName: linkedProjectQuery.data?.project.name || currentProject.title,
|
||||
@@ -1408,7 +1431,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
<CanvasActiveTaskPanel tasks={activeTasks} />
|
||||
|
||||
{!currentProject?.projectId ? <CanvasShortDramaGuide progress={shortDramaProgress} collapsed={shortDramaGuideCollapsed} onToggle={() => setShortDramaGuideCollapsed((value) => !value)} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} /> : null}
|
||||
{shortDramaEnabled && !currentProject?.projectId ? <CanvasShortDramaGuide progress={shortDramaProgress} collapsed={shortDramaGuideCollapsed} onToggle={() => setShortDramaGuideCollapsed((value) => !value)} onSkip={skipShortDramaGuide} onStepClick={activateShortDramaStep} /> : null}
|
||||
|
||||
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
|
||||
|
||||
@@ -1531,7 +1554,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
<CanvasFileDropOverlay active={fileDropActive} theme={theme} />
|
||||
|
||||
{!nodes.length ? currentProject?.projectId ? <CanvasLinkedProjectEmptyState projectName={linkedProjectQuery.data?.project.name || currentProject.title} hasChapter={Boolean(linkedProjectQuery.data?.units.length)} onAddFirstChapter={() => { const first = linkedProjectQuery.data?.units.slice().sort((left, right) => left.position - right.position)[0]; if (first) void handleProjectChapterInsert({ id: first.id, projectId: currentProject.projectId!, title: first.title, position: first.position }); }} onOpenAssets={() => openProjectAssets()} onAddText={() => createNode(CanvasNodeType.Text)} /> : <CanvasShortDramaEmptyState onCreatePipeline={createShortDramaPipeline} onOpenAgent={() => { setCinematicAgentEntry(true); setAgentMode("online"); openAgent("online"); }} onUpload={() => handleUploadRequest()} onAddText={() => createNode(CanvasNodeType.Text)} onAddScript={() => createNode(CanvasNodeType.Script)} /> : null}
|
||||
{emptyCanvasState}
|
||||
|
||||
{pendingConnectionCreate ? <CanvasConnectionCreateMenu pending={pendingConnectionCreate} viewport={viewport} viewportSize={size} containerRef={containerRef} canCreateDrawing={canCreateDrawingFromConnection} onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
|
||||
|
||||
@@ -1581,7 +1604,7 @@ function InfiniteCanvasPage() {
|
||||
workspaceMode={workspaceMode}
|
||||
canvasTool={canvasTool}
|
||||
onToolChange={setCanvasTool}
|
||||
isProjectLinked={Boolean(currentProject?.projectId)}
|
||||
isProjectLinked={Boolean(shortDramaEnabled && currentProject?.projectId)}
|
||||
canUndo={historyState.canUndo}
|
||||
canRedo={historyState.canRedo}
|
||||
backgroundMode={backgroundMode}
|
||||
|
||||
@@ -27,7 +27,8 @@ export default function IndexPage() {
|
||||
const canvasProjects = useCanvasStore((state) => state.projects);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const userHydrated = useUserStore((state) => state.hydrated);
|
||||
const domainProjectsQuery = useQuery({ queryKey: ["projects"], queryFn: listProjects, enabled: Boolean(user) });
|
||||
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
|
||||
const domainProjectsQuery = useQuery({ queryKey: ["projects"], queryFn: listProjects, enabled: Boolean(user && shortDramaEnabled) });
|
||||
const domainProjects = useMemo(
|
||||
() => [...(domainProjectsQuery.data?.projects || [])].sort((left, right) => right.project.updatedAt.localeCompare(left.project.updatedAt)),
|
||||
[domainProjectsQuery.data],
|
||||
@@ -36,7 +37,7 @@ export default function IndexPage() {
|
||||
const activeProjectQuery = useQuery({
|
||||
queryKey: ["project", activeProject?.project.id],
|
||||
queryFn: () => getProject(activeProject!.project.id),
|
||||
enabled: Boolean(user && activeProject?.project.id),
|
||||
enabled: Boolean(user && shortDramaEnabled && activeProject?.project.id),
|
||||
});
|
||||
const recentIndependentCanvases = useMemo(
|
||||
() => canvasProjects.filter((project) => !project.projectId).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)).slice(0, 3),
|
||||
@@ -55,15 +56,15 @@ export default function IndexPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const loadingUserWorkspace = !userHydrated || (Boolean(user) && domainProjectsQuery.isLoading);
|
||||
const loadingUserWorkspace = !userHydrated || (Boolean(user && shortDramaEnabled) && domainProjectsQuery.isLoading);
|
||||
return (
|
||||
<main className="app-user-content app-workspace-canvas h-full overflow-y-auto text-foreground">
|
||||
<div className="app-home-workbench mx-auto w-full max-w-[1440px] px-4 pb-12 pt-5 sm:px-6 lg:px-8">
|
||||
{loadingUserWorkspace ? (
|
||||
<WorkspaceLoadingState className="mt-3 max-w-[980px]" label="正在恢复工作台" detail="读取项目、章节和最近画布" rows={5} />
|
||||
) : user && domainProjectsQuery.isError ? (
|
||||
) : user && shortDramaEnabled && domainProjectsQuery.isError ? (
|
||||
<WorkspaceErrorState title="项目工作台加载失败" description={domainProjectsQuery.error instanceof Error ? domainProjectsQuery.error.message : "暂时无法读取项目列表。"} onRetry={() => void domainProjectsQuery.refetch()} />
|
||||
) : activeProject ? (
|
||||
) : shortDramaEnabled && activeProject ? (
|
||||
<ReturningWorkspace
|
||||
summary={activeProject}
|
||||
detail={activeProjectQuery.data}
|
||||
@@ -79,6 +80,7 @@ export default function IndexPage() {
|
||||
canvasHydrated={canvasHydrated}
|
||||
recentIndependentCanvases={recentIndependentCanvases}
|
||||
onCreateIndependentCanvas={createIndependentCanvas}
|
||||
shortDramaEnabled={shortDramaEnabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,9 +97,10 @@ function ReturningWorkspace({ summary, detail, detailLoading, detailError, recen
|
||||
recentIndependentCanvases: ReturnType<typeof useCanvasStore.getState>["projects"];
|
||||
onCreateIndependentCanvas: () => void;
|
||||
}) {
|
||||
const taskCenterEnabled = useUserStore((state) => state.features.taskCenterEnabled);
|
||||
const stage = detail ? projectDetailStage(detail) : { label: "进行中", detail: "读取项目进度" };
|
||||
const continueTarget = detail ? projectContinueTarget(detail) : { href: `/projects/${summary.project.id}/overview`, title: summary.project.name, context: "打开项目概览", updatedAt: summary.project.updatedAt };
|
||||
const nextActions = detail ? projectNextActions(detail, 3) : [];
|
||||
const nextActions = detail ? projectNextActions(detail, 4).filter((action) => taskCenterEnabled || !action.href.startsWith("/tasks")).slice(0, 3) : [];
|
||||
const completion = projectSummaryCompletion(summary);
|
||||
const attentionCount = detail ? projectAttentionCount(detail) : 0;
|
||||
return (
|
||||
@@ -194,11 +197,12 @@ function ReturningWorkspace({ summary, detail, detailLoading, detailError, recen
|
||||
);
|
||||
}
|
||||
|
||||
function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependentCanvases, onCreateIndependentCanvas }: {
|
||||
function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependentCanvases, onCreateIndependentCanvas, shortDramaEnabled }: {
|
||||
authenticated: boolean;
|
||||
canvasHydrated: boolean;
|
||||
recentIndependentCanvases: ReturnType<typeof useCanvasStore.getState>["projects"];
|
||||
onCreateIndependentCanvas: () => void;
|
||||
shortDramaEnabled: boolean;
|
||||
}) {
|
||||
const projectHref = authenticated ? "/projects?create=1" : `/login?next=${encodeURIComponent("/projects?create=1")}`;
|
||||
return (
|
||||
@@ -208,7 +212,7 @@ function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependen
|
||||
<h1 className="mt-5 max-w-[780px] text-3xl font-semibold leading-[1.08] sm:text-4xl lg:text-5xl">把一个故事推进到可交付的镜头</h1>
|
||||
<p className="mt-5 max-w-[680px] text-sm leading-7 text-foreground/58 sm:text-base">从章节、角色和参考图开始,逐步生成分镜、视频和可复用资产。需要自由探索时,也可以先打开一张自由画布。</p>
|
||||
<div className="mt-7 flex flex-wrap items-center gap-3">
|
||||
<Link className="inline-flex h-10 items-center gap-2 rounded-md bg-foreground px-4 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25" to={projectHref}><FolderKanban className="size-4" />创建项目</Link>
|
||||
{shortDramaEnabled ? <Link className="inline-flex h-10 items-center gap-2 rounded-md bg-foreground px-4 text-sm font-medium text-background transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/25" to={projectHref}><FolderKanban className="size-4" />创建项目</Link> : null}
|
||||
<Button size="large" disabled={!canvasHydrated} icon={<LayoutGrid className="size-4" />} onClick={onCreateIndependentCanvas}>打开画布</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -230,7 +234,7 @@ function FirstProjectWorkspace({ authenticated, canvasHydrated, recentIndependen
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">两种开始方式</h2>
|
||||
<div className="mt-3 divide-y divide-border/75 border-y border-border/75">
|
||||
<StartMode icon={<Clapperboard className="size-4" />} title="项目" description="适合短剧、故事板和多章节制作。集中管理章节、资产、画布与进度。" action="创建项目" href={projectHref} />
|
||||
{shortDramaEnabled ? <StartMode icon={<Clapperboard className="size-4" />} title="项目" description="适合短剧、故事板和多章节制作。集中管理章节、资产、画布与进度。" action="创建项目" href={projectHref} /> : null}
|
||||
<StartMode icon={<Sparkles className="size-4" />} title="自由画布" description="适合快速试图、提示词实验和不需要章节流程的自由创作。" action="打开画布" onClick={onCreateIndependentCanvas} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AiConfig,
|
||||
type ModelCapability,
|
||||
} from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type DefaultModelKey = "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||
|
||||
@@ -28,6 +29,7 @@ const groups: Array<{
|
||||
];
|
||||
|
||||
export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onChange: (key: DefaultModelKey, model: string) => void }) {
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
return (
|
||||
<div className="divide-y divide-border/70">
|
||||
{groups.map((group) => {
|
||||
@@ -72,7 +74,7 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
|
||||
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
|
||||
<span className="rounded border border-border/70 px-1 py-px text-[var(--fs-micro)]">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
|
||||
{creditsEnabled && cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn("grid size-5 shrink-0 place-items-center rounded-full border", selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-border text-transparent group-hover:border-foreground/30")}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { cancelGenerationTask, createAgentSession, createGenerationTask, listGen
|
||||
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
|
||||
@@ -29,6 +30,8 @@ export default function TasksPage() {
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const projects = useCanvasStore((state) => state.projects);
|
||||
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const [form] = Form.useForm<CreateTaskInput & { operation: string }>();
|
||||
const [tasks, setTasks] = useState<GenerationTask[]>([]);
|
||||
const [domainProjects, setDomainProjects] = useState<ProjectSummary[]>([]);
|
||||
@@ -74,6 +77,10 @@ export default function TasksPage() {
|
||||
}), [canvasById, domainProjectNameById, keyword, projectFilter, statusFilter, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shortDramaEnabled) {
|
||||
setDomainProjects([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void listProjects().then((result) => {
|
||||
if (!cancelled) setDomainProjects(result.projects);
|
||||
@@ -81,7 +88,7 @@ export default function TasksPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [shortDramaEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const maxPage = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
|
||||
@@ -306,7 +313,7 @@ export default function TasksPage() {
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}</div>
|
||||
<div className="truncate text-[var(--fs-tiny)] text-foreground/38">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-label)]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span><TaskBilling billing={task.billing} /></div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-label)]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span>{creditsEnabled ? <TaskBilling billing={task.billing} /> : null}</div>
|
||||
<Space size={0}>
|
||||
<Button type="text" size="small" aria-label="查看详情" icon={<Eye className="size-3.5" />} onClick={() => openTaskDetail(task)} />
|
||||
{task.status === "failed" || task.status === "cancelled" ? <Button type="text" size="small" aria-label="重新生成" icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")} /> : null}
|
||||
@@ -348,13 +355,13 @@ export default function TasksPage() {
|
||||
responsive: ["lg"],
|
||||
render: (value) => <TaskDate value={value} />,
|
||||
},
|
||||
{
|
||||
...(creditsEnabled ? [{
|
||||
title: "消耗积分",
|
||||
width: 130,
|
||||
align: "right",
|
||||
responsive: ["md"],
|
||||
render: (_, task) => <TaskBilling billing={task.billing} />,
|
||||
},
|
||||
}] as ColumnsType<GenerationTask> : []),
|
||||
{
|
||||
title: "操作",
|
||||
width: 168,
|
||||
@@ -369,7 +376,7 @@ export default function TasksPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[actingId, canvasById, domainProjectNameById, effectiveConfig, openTaskDetail],
|
||||
[actingId, canvasById, creditsEnabled, domainProjectNameById, effectiveConfig, openTaskDetail],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -575,7 +582,7 @@ function taskAttentionReason(task: GenerationTask) {
|
||||
function taskEmptyState(status: TaskStatusFilter) {
|
||||
if (status === "all") return { title: "还没有任务", description: "新提交的生成会在这里显示状态和实时进度。" };
|
||||
if (status === "active") return { title: "没有运行中的任务", description: "新提交的生成会在这里显示排队状态和实时进度。" };
|
||||
if (status === "succeeded") return { title: "还没有已完成任务", description: "生成成功后,结果预览和积分消耗会保留在这里。" };
|
||||
if (status === "succeeded") return { title: "还没有已完成任务", description: "生成成功后,结果预览和执行记录会保留在这里。" };
|
||||
return { title: "目前没有需要处理的任务", description: "失败或取消的生成会出现在这里,并提供原因和可用操作。" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createBrowserRouter, Navigate, Outlet } from "react-router";
|
||||
|
||||
import { RequireAuth } from "@/components/auth/require-auth";
|
||||
import { RequireFeature } from "@/components/auth/require-feature";
|
||||
import UserLayout from "@/layouts/user-layout";
|
||||
import AdminPage from "@/pages/admin";
|
||||
import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsPage, EmailSettingsPage } from "@/pages/admin/admin-route-pages";
|
||||
import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsPage, EmailSettingsPage, FeatureAvailabilityPage } from "@/pages/admin/admin-route-pages";
|
||||
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";
|
||||
@@ -51,15 +52,15 @@ export const router = createBrowserRouter([
|
||||
{ path: "/", element: <Navigate to="/create" replace /> },
|
||||
{ path: "/create", element: <RequireAuth><CreatePage /></RequireAuth> },
|
||||
{ path: "/home", element: <HomePage /> },
|
||||
{ path: "/tasks", element: <RequireAuth><TasksPage /></RequireAuth> },
|
||||
{ path: "/tasks", element: <RequireAuth><RequireFeature feature="taskCenterEnabled"><TasksPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/assets", element: <RequireAuth><AssetsPage /></RequireAuth> },
|
||||
{ path: "/skills", element: <RequireAuth><SkillsPage /></RequireAuth> },
|
||||
{ path: "/wallet", element: <RequireAuth><WalletPage /></RequireAuth> },
|
||||
{ path: "/wallet", element: <RequireAuth><RequireFeature feature="creditsEnabled"><WalletPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/settings", element: <RequireAuth><SettingsPage /></RequireAuth> },
|
||||
{ path: "/projects", element: <RequireAuth><ProjectsPage /></RequireAuth> },
|
||||
{ path: "/projects/:projectId", element: <RequireAuth><ProjectDetailPage /></RequireAuth> },
|
||||
{ path: "/projects/:projectId/:view", element: <RequireAuth><ProjectDetailPage /></RequireAuth> },
|
||||
{ path: "/projects/:projectId/chapters/:chapterId", element: <RequireAuth><ProjectDetailPage /></RequireAuth> },
|
||||
{ path: "/projects", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectsPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/projects/:projectId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/projects/:projectId/:view", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/projects/:projectId/chapters/:chapterId", element: <RequireAuth><RequireFeature feature="shortDramaEnabled"><ProjectDetailPage /></RequireFeature></RequireAuth> },
|
||||
{ path: "/canvas", element: <RequireAuth><CanvasPage /></RequireAuth> },
|
||||
{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> },
|
||||
{
|
||||
@@ -79,6 +80,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "settings/drawing-engine", element: <DrawingEngineSettingsPage /> },
|
||||
{ path: "settings/concurrency", element: <Navigate to="/admin/settings/runtime-policy" replace /> },
|
||||
{ path: "settings/runtime-policy", element: <RuntimePolicySettingsPage /> },
|
||||
{ path: "settings/features", element: <FeatureAvailabilityPage /> },
|
||||
{ path: "settings/access", element: <AccessSettingsPage /> },
|
||||
{ path: "settings/email", element: <EmailSettingsPage /> },
|
||||
{ path: "settings/storage", element: <StorageSettingsPage /> },
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import type { CreditLedgerEntry } from "@/services/api/wallet";
|
||||
import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
|
||||
import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import type { FeatureAvailability } from "@/stores/use-user-store";
|
||||
|
||||
const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true });
|
||||
|
||||
@@ -33,6 +34,7 @@ export type AuthSessionPayload = {
|
||||
systemChannels?: ModelChannel[];
|
||||
runtimeLimits?: RuntimeLimits;
|
||||
drawingEngine?: CanvasDrawingEngineSetting;
|
||||
features?: FeatureAvailability;
|
||||
};
|
||||
|
||||
export type RuntimeLimits = {
|
||||
@@ -353,6 +355,18 @@ export function getSystemChannels() {
|
||||
return request<{ channels: ModelChannel[] }>(api.get("/channels/system"));
|
||||
}
|
||||
|
||||
export function getFeatureAvailability() {
|
||||
return request<{ features: FeatureAvailability }>(api.get("/features"));
|
||||
}
|
||||
|
||||
export function getAdminFeatureAvailability() {
|
||||
return request<{ features: FeatureAvailability }>(api.get("/admin/settings/features"));
|
||||
}
|
||||
|
||||
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled">) {
|
||||
return request<{ features: FeatureAvailability }>(api.patch("/admin/settings/features", features));
|
||||
}
|
||||
|
||||
export function login(input: { username: string; password: string }) {
|
||||
return request<{ user: LocalUser }>(api.post("/auth/login", input));
|
||||
}
|
||||
|
||||
@@ -24,14 +24,31 @@ export type RuntimeLimits = {
|
||||
sessionUploadMB: number;
|
||||
};
|
||||
|
||||
export type FeatureAvailability = {
|
||||
shortDramaEnabled: boolean;
|
||||
taskCenterEnabled: boolean;
|
||||
creditsEnabled: boolean;
|
||||
configured?: boolean;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export const defaultFeatureAvailability: FeatureAvailability = {
|
||||
shortDramaEnabled: true,
|
||||
taskCenterEnabled: true,
|
||||
creditsEnabled: true,
|
||||
};
|
||||
|
||||
type UserStore = {
|
||||
hydrated: boolean;
|
||||
user: LocalUser | null;
|
||||
runtimeLimits: RuntimeLimits;
|
||||
drawingEngine: CanvasDrawingEngineSetting;
|
||||
features: FeatureAvailability;
|
||||
setUser: (user: LocalUser | null) => void;
|
||||
setRuntimeLimits: (limits?: RuntimeLimits) => void;
|
||||
setDrawingEngine: (setting?: CanvasDrawingEngineSetting) => void;
|
||||
setFeatures: (features?: FeatureAvailability) => void;
|
||||
setHydrated: (hydrated: boolean) => void;
|
||||
clearSession: () => void;
|
||||
};
|
||||
@@ -41,9 +58,11 @@ export const useUserStore = create<UserStore>()((set) => ({
|
||||
user: null,
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 },
|
||||
drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE },
|
||||
features: defaultFeatureAvailability,
|
||||
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 }),
|
||||
setHydrated: (hydrated) => set({ hydrated }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE } }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE }, features: defaultFeatureAvailability }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user