feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置

This commit is contained in:
ddcat666
2026-10-13 08:54:53 +08:00
parent 4c1d2b4012
commit 1f3f2c6020
69 changed files with 7108 additions and 87 deletions

View File

@@ -7,7 +7,7 @@ import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/co
import { refreshFeatureAvailability } from "@/lib/user-session";
import { useUserStore } from "@/stores/use-user-store";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled";
const featureNames: Record<FeatureKey, string> = {
shortDramaEnabled: "短剧创作",
@@ -15,6 +15,8 @@ const featureNames: Record<FeatureKey, string> = {
creditsEnabled: "积分中心",
frontendModelsEnabled: "前台模型",
pluginCenterEnabled: "插件中心",
imageWorkbenchEnabled: "生图",
videoWorkbenchEnabled: "视频",
};
export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) {
@@ -39,8 +41,18 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
};
}, [feature, user?.id]);
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} /></WorkspacePage>;
if (checking)
return (
<WorkspacePage>
<WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} />
</WorkspacePage>
);
if (error)
return (
<WorkspacePage>
<WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} />
</WorkspacePage>
);
if (!features[feature]) {
// 管理员页面返回到管理后台首页,用户页面返回到创作台
const isAdminFeature = feature === "frontendModelsEnabled";
@@ -49,7 +61,16 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
return (
<WorkspacePage>
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate(backPath, { replace: true })}>{backLabel}</Button>} />
<WorkspaceState
icon="empty"
title={`${featureNames[feature]}暂未开放`}
description="当前功能已由平台管理员关闭。"
action={
<Button type="primary" onClick={() => navigate(backPath, { replace: true })}>
{backLabel}
</Button>
}
/>
</WorkspacePage>
);
}

View File

@@ -1,5 +1,5 @@
import { Popover } from "antd";
import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Image as ImageIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus, Video } from "lucide-react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
@@ -46,15 +46,26 @@ function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[];
items: [
{ ...toolItem("create", "/"), id: "home", title: "创作" },
{ ...toolItem("inspirations", "/inspirations"), title: "灵感" },
{ ...toolItem("projects", "/projects"), title: "短剧 Agent" },
{ ...toolItem("projects", "/projects"), title: "短剧制作" },
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
],
},
{
heading: "资源与工具",
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
items: [
...(features.imageWorkbenchEnabled ? [{ id: "toolbox-image", title: "图片生成", icon: ImageIcon, to: "/toolbox/image" }] : []),
...(features.videoWorkbenchEnabled ? [{ id: "toolbox-video", title: "视频生成", icon: Video, to: "/toolbox/video" }] : []),
{ ...toolItem("skills", "/skills"), title: "技能" },
...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : []),
],
},
{
heading: "资产与历史",
items: [
{ ...toolItem("assets", "/assets"), title: "资产" },
...(features.taskCenterEnabled ? [{ id: "tasks", title: "创作历史", icon: ImageIcon, to: "/tasks" }] : []),
],
},
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
];
// 管理、设置和退出登录不再占据参考站式侧栏底部,而是通过用户卡片菜单进入。
@@ -67,10 +78,25 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
const profileName = user?.displayName || user?.username || "未登录";
if (!user) {
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</Link>;
return (
<Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录">
<span className="app-workspace-sidebar-profile-avatar">
<CircleUserRound aria-hidden />
</span>
{!collapsed ? <span>游客模式</span> : null}
</Link>
);
}
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />;
const content = (
<WorkspaceAccountCard
onNavigate={() => setMenuOpen(false)}
onWallet={() => {
setMenuOpen(false);
openWorkspaceWallet();
}}
/>
);
return (
<div className={cn("app-workspace-sidebar-account", collapsed && "is-collapsed")}>
@@ -80,16 +106,25 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
<UserAvatar user={user} className="app-workspace-sidebar-profile-avatar" />
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
{!collapsed ? (
<span className="app-workspace-sidebar-profile-copy">
<strong>{profileName}</strong>
<span>创作工作台</span>
</span>
) : null}
</button>
</Popover>
{!collapsed ? <button type="button" className="app-workspace-sidebar-collapse-toggle" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
<PanelLeftClose className="size-4" strokeWidth={1.7} />
</button> : null}
{!collapsed ? (
<button type="button" className="app-workspace-sidebar-collapse-toggle" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
<PanelLeftClose className="size-4" strokeWidth={1.7} />
</button>
) : null}
</div>
{collapsed ? <button type="button" className="app-workspace-sidebar-collapse-toggle is-collapsed" aria-label="展开侧栏菜单" title="展开侧栏菜单" onClick={onExpand}>
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
</button> : null}
{collapsed ? (
<button type="button" className="app-workspace-sidebar-collapse-toggle is-collapsed" aria-label="展开侧栏菜单" title="展开侧栏菜单" onClick={onExpand}>
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
</button>
) : null}
</div>
);
}
@@ -101,7 +136,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNa
return (
<div className="app-workspace-sidebar-brand-row relative shrink-0 flex items-center justify-center px-3 pt-3">
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
<BrandLogoFrame
className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm"
logoClassName="size-5 object-contain"
alt=""
fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />}
/>
</Link>
</div>
);
@@ -111,7 +151,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNa
<div className="app-workspace-sidebar-brand-row relative shrink-0 px-3 pt-3">
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
<span className="flex min-w-0 items-center gap-2">
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
<BrandLogoFrame
className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm"
logoClassName="size-5 object-contain"
alt=""
fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />}
/>
<span className="flex min-w-0 flex-col">
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-semibold">{appearance.brandName}</span>
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/60">创作工作台</span>
@@ -152,7 +197,8 @@ function NavItem({
const Icon = item.icon;
const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties);
const collapsedTitle = item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
const collapsedTitle =
item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
const rowContent = (
<>
<span className="app-workspace-nav-main flex min-w-0 items-center gap-2.5">
@@ -166,9 +212,7 @@ function NavItem({
<span className="app-workspace-nav-title truncate">{collapsed ? collapsedTitle : item.title}</span>
</span>
<span className="app-workspace-nav-meta flex shrink-0 items-center gap-2">
{item.shortcut ? (
<Kbd className="hidden group-hover:inline-flex">{item.shortcut}</Kbd>
) : null}
{item.shortcut ? <Kbd className="hidden group-hover:inline-flex">{item.shortcut}</Kbd> : null}
{item.badge !== undefined ? <span className="flex min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium tabular-nums text-primary">{item.badge}</span> : null}
{hasChildren ? <ChevronRight className={cn("size-3.5 shrink-0 text-foreground/40 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} /> : null}
</span>
@@ -180,14 +224,7 @@ function NavItem({
collapsed && "is-collapsed",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
);
const activePill = isActive ? (
<motion.span
layoutId="workspace-nav-active-pill"
className="app-workspace-nav-active-pill"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
/>
) : null;
const activePill = isActive ? <motion.span layoutId="workspace-nav-active-pill" className="app-workspace-nav-active-pill" aria-hidden transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock} /> : null;
const handleClick = () => {
if (item.action === "search") {
@@ -227,7 +264,16 @@ function NavItem({
{rowContent}
</Link>
) : (
<button type="button" className={rowClassName} data-nav-id={item.id} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
<button
type="button"
className={rowClassName}
data-nav-id={item.id}
style={rowStyle}
aria-label={collapsed ? item.title : undefined}
title={collapsed ? item.title : undefined}
onClick={handleClick}
aria-expanded={hasChildren ? isOpen : undefined}
>
{activePill}
{rowContent}
</button>
@@ -266,7 +312,11 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
// 无标题分组(核心导航入口)常驻展示,不做折叠。
if (!group.heading || collapsed) {
return <div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>{content}</div>;
return (
<div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>
{content}
</div>
);
}
return (
@@ -282,6 +332,15 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
);
}
// 菜单项 id 与实际路由不一定同名:/toolbox/image 和 /toolbox/video 共用 toolbox 前缀,
// 只取首段会让两者都匹配不到任何菜单项,侧栏因此没有高亮。
export function workspaceNavActiveId(pathname: string, section?: string | null) {
const segments = pathname.split("/").filter(Boolean);
if (segments[0] === "toolbox" && segments[1]) return `toolbox-${segments[1]}`;
const slug = segments[0] || "home";
return slug === "settings" && section ? `settings:${section}` : slug;
}
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void; onCollapse: () => void }) {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
@@ -290,9 +349,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
const { handleLogout } = useWorkspaceLogout();
const { groups, footer } = useMemo(() => buildNav(features), [features]);
const slug = pathname.split("/").filter(Boolean)[0] || "home";
const section = searchParams.get("section");
const activeId = slug === "settings" && section ? `settings:${section}` : slug;
const activeId = workspaceNavActiveId(pathname, searchParams.get("section"));
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollState, setScrollState] = useState({ hasTopFade: false, hasBottomFade: false });
@@ -314,24 +371,31 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} onCollapse={onCollapse} />
<LayoutGroup id="workspace-sidebar-nav">
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
>
{groups.map((group, index) => (
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7",
collapsed && "is-collapsed",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{groups.map((group, index) => (
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
</LayoutGroup>
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
<WorkspaceSidebarProfile collapsed={collapsed} user={user} onExpand={onExpand} onCollapse={onCollapse} />
{footer.length ? <div className="mt-2 flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div> : null}
{footer.length ? (
<div className="mt-2 flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
) : null}
</div>
</div>
);

View File

@@ -16,7 +16,7 @@ const PAGE_TITLES: Record<string, string> = {
home: "创作",
create: "创作",
inspirations: "灵感",
projects: "短剧 Agent",
projects: "短剧制作",
canvas: "自由画布",
tasks: "创作历史",
assets: "资产",

View File

@@ -14,6 +14,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { ModelTags } from "@/components/model-tags";
import { videoReferenceCapabilityTag } from "@/lib/model-tags";
import { quoteModel, type LogicalModelQuote, type PublicChannelModelAvailability } from "@/services/api/logical-models";
type ModelPickerProps = {
@@ -331,6 +332,8 @@ export function ModelLabel({
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
const logicalSpec = logicalCost?.logicalCapabilitySpec;
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
const capabilityTag = videoReferenceCapabilityTag(videoProfile?.references);
const modelTags = capabilityTag ? [capabilityTag, ...(logicalCost?.tags || [])] : logicalCost?.tags;
const capabilitySummary =
disabledReason ||
logicalCost?.description?.trim() ||
@@ -354,7 +357,7 @@ export function ModelLabel({
<span className={cn("canvas-model-picker-description mt-1 block", showDescription && "is-visible")} style={{ color: theme.node.muted }}>
{capabilitySummary}
</span>
<ModelTags tags={logicalCost?.tags} />
<ModelTags tags={modelTags} />
</span>
{!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 }}>