feat(canvas): 前端重构与渠道兼容 - 重构工作区、任务中心等多页面,修复智能体对话与节点生成

Co-authored-by: sikongyue <3155157671@qq.com>
Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/217
This commit is contained in:
爱笑的毛毛虫
2026-08-14 12:01:21 +08:00
committed by GitHub
parent fb2a54b345
commit cfdf08d05f
37 changed files with 4599 additions and 912 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "antd";
import { useLocation, useNavigate } from "react-router";
import { useNavigate } from "react-router";
import { WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
@@ -15,16 +15,18 @@ const featureNames: Record<FeatureKey, string> = {
creditsEnabled: "积分中心",
};
let featureAvailabilityCheckedOnce = false;
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 [checking, setChecking] = useState(() => !useUserStore.getState().features[feature]);
const [error, setError] = useState("");
useEffect(() => {
if (featureAvailabilityCheckedOnce) return;
featureAvailabilityCheckedOnce = true;
let cancelled = false;
setChecking(true);
setError("");
refreshFeatureAvailability()
.catch((reason) => {
@@ -36,7 +38,7 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
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>;

View File

@@ -750,7 +750,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
{agentBusy ? <AgentWorkingMessage theme={theme} /> : null}
</>
) : (
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={setPrompt} />
<AgentChatEmptyState theme={theme} nodeCount={contextSummary.nodeCount} onSelect={(text) => { setPrompt(text); void sendMessage(text, messages); }} />
)}
</div>
)}

View File

@@ -172,8 +172,8 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
return () => clearTimeout(timer);
}, [connected, endpoint, snapshot, token]);
const sendPrompt = async () => {
const text = prompt.trim();
const sendPrompt = async (overrideText?: string) => {
const text = (overrideText ?? prompt).trim();
const files = attachments;
const requestPrompt = promptWithAttachments(text, files);
if (!connected || !requestPrompt || sending || waiting) return;
@@ -545,7 +545,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
) : (
<>
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(prompt) => setAgentState({ prompt })} /> : null}
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(text) => { setAgentState({ prompt: text }); void sendPrompt(text); }} /> : null}
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
))}

View File

@@ -3,7 +3,8 @@ import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Ma
import { Button, Image as AntImage, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
@@ -475,8 +476,7 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = node.metadata?.model;
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
const model = resolveCanvasGenerationModel(globalConfig, node.metadata?.model, mode) || resolveCanvasGenerationModel(globalConfig, defaultModel, mode) || fallbackModel;
return {
...globalConfig,
model,

View File

@@ -51,7 +51,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
<div className="flex h-[min(620px,calc(100vh-80px))] min-h-[440px] flex-col overflow-hidden">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border py-0 pl-4 pr-12"><h2 className="text-sm font-semibold">引用项目角色与资产</h2><span className="text-[var(--fs-label)] text-foreground/42">已选 {selectedIds.size} 项</span></header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] sm:grid-cols-[150px_minmax(0,1fr)] sm:grid-rows-1">
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-foreground/[.08] font-medium" : "text-foreground/55 hover:bg-foreground/[.04]"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
<nav className="thin-scrollbar flex min-w-0 gap-1 overflow-x-auto border-b border-border p-2 sm:block sm:overflow-y-auto sm:border-b-0 sm:border-r" aria-label="项目资产分类">{categories.map((item) => <button key={item} type="button" onClick={() => setCategory(item)} className={`flex h-11 min-w-[104px] shrink-0 items-center justify-between rounded-md px-2 text-xs sm:w-full sm:min-w-0 ${category === item ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{categoryLabels[item] || "其他"}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item === "all" ? items.length : items.filter((asset) => asset.category === item).length}</span></button>)}</nav>
<div className="thin-scrollbar min-h-0 overflow-y-auto p-3">{visible.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">{visible.map((item) => <ProjectAssetCard key={item.id} item={item} selected={selectedIds.has(item.id)} onToggle={() => toggle(item.id)} />)}</div> : <WorkspaceState icon="assets" compact className="h-full" title="此分类没有可引用资产" description={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,下方展示本地素材库;也可先上传素材后再引用。"} />}</div>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-border px-3"><span className="text-[var(--fs-tiny)] text-foreground/42">角色引用会在生成时解析当前角色版本</span><div className="flex gap-2"><Button size="small" onClick={onClose}>取消</Button><Button size="small" type="primary" disabled={!selectedIds.size} loading={inserting} onClick={() => void insert()}>引入 {selectedIds.size || ""} 项</Button></div></footer>

View File

@@ -103,10 +103,10 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
if (collapsed) {
return (
<aside className="relative z-[var(--z-panel)] hidden w-11 shrink-0 flex-col items-center border-r border-border bg-background/94 py-2 backdrop-blur-xl lg:flex">
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
<ChevronRight className="size-4" />
</button>
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="返回项目画布列表">
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="返回项目画布列表">
<FolderKanban className="size-4" />
</Link>
</aside>
@@ -120,7 +120,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
<FolderKanban className="size-3.5 shrink-0" />
<span className="truncate">{projectDetail?.project.name || "项目空间"}</span>
</Link>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-foreground/[.06]" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-surface-hover" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}>
<ChevronLeft className="size-4" />
</button>
</header>
@@ -131,7 +131,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
<Palette className="size-3.5" />
项目画风
</span>
<Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风">
<Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-surface-hover hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风">
<Settings2 className="size-3.5" />
</Link>
</div>
@@ -139,7 +139,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
type="button"
disabled={!style}
onClick={onLocateStyle}
className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-foreground/[.045] disabled:cursor-default disabled:hover:bg-transparent"
className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"}
>
{style ? (
@@ -164,7 +164,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span>
</span>
{selectedId ? (
<button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="回到当前章节" aria-label="回到当前章节">
<button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-surface-hover hover:text-foreground" title="回到当前章节" aria-label="回到当前章节">
<Crosshair className="size-3.5" />
</button>
) : null}
@@ -183,7 +183,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
aria-label="搜索项目章节"
/>
{query ? (
<button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06] hover:text-foreground" aria-label="清空章节搜索">
<button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-surface-hover hover:text-foreground" aria-label="清空章节搜索">
<X className="size-3" />
</button>
) : null}
@@ -208,7 +208,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload));
event.dataTransfer.effectAllowed = "copy";
}}
className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-foreground/[.08]" : "hover:bg-foreground/[.045]"}`}
className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-surface-active" : "hover:bg-surface-hover"}`}
>
<GripVertical className="size-3.5 shrink-0 text-foreground/22" />
<button
@@ -295,7 +295,7 @@ function ChapterPreview({
<div className="text-[var(--fs-tiny)] tabular-nums text-foreground/38">第 {chapterNumber.toLocaleString("zh-CN")} 章</div>
<h2 className="mt-0.5 truncate text-sm font-semibold">{unit.title}</h2>
</div>
<button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-foreground/[.06] hover:text-foreground" aria-label="关闭章节预览">
<button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-surface-hover hover:text-foreground" aria-label="关闭章节预览">
<X className="size-3.5" />
</button>
</header>
@@ -312,7 +312,7 @@ function ChapterPreview({
>
{adding ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}添加到画布
</button>
<Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-foreground/[.06] hover:text-foreground">
<Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground">
打开编辑器
<ArrowUpRight className="size-3.5" />
</Link>

View File

@@ -115,6 +115,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
: node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : "";
const [batchDetailsOpen, setBatchDetailsOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const pipelineDisabled = !rows.length || node.metadata?.status === "loading" || hasActiveBatchItems;
const missingImages = Math.max(0, pipeline.images.total - pipeline.images.created);
const missingVideos = Math.max(0, pipeline.videos.total - pipeline.videos.created);
@@ -164,9 +165,9 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
<span className="min-w-0 flex-1 truncate text-sm font-semibold" title={node.title || "分镜脚本"}>{node.title || "分镜脚本"}</span>
{batchSummary ? <span className="min-w-0 max-w-[42%] truncate text-[var(--fs-label)] font-medium" title={batchSummary} style={{ color: batch?.status === "partial_failed" ? theme.accent.danger : theme.node.muted }}>{batchSummary}</span> : taskFeedback ? <span className="min-w-0 max-w-[38%] truncate text-[var(--fs-label)] font-medium" title={taskFeedback} style={{ color: node.metadata?.status === "error" ? theme.accent.danger : theme.node.muted }}>{taskFeedback}</span> : null}
<span className="text-[var(--fs-caption)] font-semibold tabular-nums" style={{ color: theme.node.muted }}>{rows.length} 镜 · {totalDuration}s</span>
<Tooltip title="全屏编辑"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} aria-label="全屏编辑"><Expand className="size-3.5" /></button></Tooltip>
<Dropdown menu={{ items: moreMenuItems }} trigger={["click"]} placement="bottomRight">
<button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} aria-label="更多操作"><MoreHorizontal className="size-3.5" /></button>
<Tooltip title="全屏编辑"><button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onOpen(); }} aria-label="全屏编辑"><Expand className="size-3.5" /></button></Tooltip>
<Dropdown open={moreMenuOpen} onOpenChange={setMoreMenuOpen} menu={{ items: moreMenuItems, onClick: () => setMoreMenuOpen(false) }} trigger={["click"]} placement="bottomRight">
<button type="button" className="grid size-7 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ "--tw-ring-color": theme.node.muted } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); setMoreMenuOpen(true); }} aria-label="更多操作"><MoreHorizontal className="size-3.5" /></button>
</Dropdown>
</div>
{batch ? <Modal title="批次详情" open={batchDetailsOpen} onCancel={() => setBatchDetailsOpen(false)} footer={null} width={560} centered destroyOnHidden>

View File

@@ -1,9 +1,10 @@
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { Home, Infinity as InfinityIcon } from "lucide-react";
import { Fragment, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
import { refreshFeatureAvailability } from "@/lib/user-session";
@@ -16,12 +17,14 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
const user = useUserStore((state) => state.user);
const features = useUserStore((state) => state.features);
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
const scrollRef = useRef<HTMLElement>(null);
const [scrollState, setScrollState] = useState({
hasTopFade: false,
hasBottomFade: false,
});
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const showGlobalTopBar = !hideChrome;
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
const creationWorkspace = pathname === "/create";
const slug = pathname.split("/").filter(Boolean)[0];
@@ -33,6 +36,39 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
if (tool.slug === "wallet") return features.creditsEnabled;
return true;
});
const topRailNavTools = visibleNavigationTools.filter((tool) => tool.section === "创作空间" || tool.slug === "skills");
const bottomRailNavTools = visibleNavigationTools.filter((tool) => tool.slug === "wallet");
const toggleSidebar = () => {
if (window.innerWidth < 1024) {
setMobileSidebarExpanded((current) => !current);
return;
}
setDesktopSidebarCollapsed((current) => !current);
};
const handleNavClick = () => {
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
};
const renderSidebarLink = (tool: (typeof navigationTools)[number]) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
className={cn(
"app-workspace-nav-link app-workspace-rail-tile grid size-10 place-items-center rounded-md text-[var(--fs-tiny)] transition-colors",
active ? "is-active font-medium" : "",
)}
>
<Icon className="size-5 shrink-0" />
</Link>
);
};
const handleScroll = () => {
const element = scrollRef.current;
@@ -72,84 +108,89 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
return (
<>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")} data-sidebar-collapsed={desktopSidebarCollapsed || undefined}>
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
{showGlobalTopBar ? <WorkspaceTopBar sidebarOpen={window.innerWidth < 1024 ? mobileSidebarExpanded : !desktopSidebarCollapsed} onToggleSidebar={toggleSidebar} /> : null}
<div className="app-workspace-main-row flex min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden transition-all duration-200", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-0 lg:w-[88px] lg:shrink-0")}>
<div
className={cn(
"flex h-14 shrink-0 items-center border-b border-border/55 text-foreground",
mobileSidebarExpanded ? "gap-2 px-3" : "justify-center",
"lg:justify-center lg:px-0",
)}
>
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded || spatialWorkbench ? "flex" : "hidden", "lg:flex")} title="影策">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
{spatialWorkbench ? <span className="min-w-0"><span className="block truncate text-[var(--fs-body)] font-semibold">影策</span><span className="block truncate text-[var(--fs-micro)] text-foreground/36">AI 叙事工作台</span></span> : <span className="truncate text-[var(--fs-body)] font-semibold">影策</span>}
</Link>
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden transition-all duration-200", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-0 lg:w-[var(--workspace-sidebar-width)] lg:shrink-0")} style={{ "--workspace-sidebar-width": desktopSidebarCollapsed ? "0px" : "64px" } as CSSProperties}>
{/* 桌面:64px 电影胶片条式轨道 */}
<div className="app-workspace-rail hidden min-h-0 flex-col overflow-y-auto lg:flex">
<div className="app-workspace-rail-header flex h-16 shrink-0 items-center justify-center">
<Link to="/home" className="grid size-10 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
<Home className="size-4" />
</Link>
</div>
<div className="app-workspace-rail-middle flex min-h-0 w-full flex-1 flex-col items-center justify-start overflow-y-auto px-3 pt-3">
<nav className="flex w-full flex-col items-center gap-2">
{topRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
<nav className="mt-4 flex w-full flex-col items-center gap-2 pb-3">
{bottomRailNavTools.map((tool) => renderSidebarLink(tool))}
</nav>
</div>
<div className="app-workspace-rail-footer shrink-0 px-2 pb-4 pt-3">
<WorkspaceSidebarFooter
showAnnouncement={false}
expandedClassName="hidden"
collapsedClassName="flex h-10 w-10 items-center justify-center rounded-md"
accountClassName="justify-center rounded-md"
/>
</div>
</div>
<nav
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{visibleNavigationTools.map((tool, index) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
return (
<Fragment key={tool.slug}>
{showSection ? <div className={cn("mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", "lg:hidden")}>{tool.section}</div> : null}
<Link
to={`/${tool.slug}`}
title={tool.label}
onClick={() => {
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
}}
className={cn(
"app-workspace-nav-link relative mb-1 flex shrink-0 items-center rounded-md text-[var(--fs-body)] transition-colors",
spatialWorkbench ? "h-11" : "h-9",
mobileSidebarExpanded ? "gap-3 px-2.5" : "justify-center px-0",
"lg:justify-center lg:px-0",
active ? "is-active font-medium" : "text-foreground/55 hover:bg-foreground/[0.045] hover:text-foreground/85",
)}
>
<Icon className="size-4 shrink-0" />
<span className={cn("truncate", mobileSidebarExpanded ? "inline" : "hidden", "lg:inline")}>{tool.label}</span>
</Link>
</Fragment>
);
})}
</nav>
<div className="shrink-0 border-t border-border/55 p-2">
<WorkspaceSidebarFooter
collapsedClassName={cn(
mobileSidebarExpanded ? "justify-start gap-2 px-2" : "justify-center gap-0 px-0",
"lg:justify-center lg:gap-0 lg:px-0",
{/* 移动端:抽屉完整导航 */}
<div className="app-workspace-mobile-nav flex min-h-0 flex-1 flex-col overflow-hidden lg:hidden">
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-border/55 px-3">
<Link to="/" className="flex min-w-0 items-center gap-2" title="影策">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
<span className="truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
</div>
<nav
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col px-2 py-3",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
expandedClassName={cn(mobileSidebarExpanded ? "flex" : "hidden", "lg:hidden")}
accountClassName={cn(
mobileSidebarExpanded ? "flex-row gap-2 px-2" : "flex-col gap-0.5 px-0 py-1",
"lg:flex-col lg:gap-0.5 lg:px-0 lg:py-1",
)}
/>
>
{visibleNavigationTools.map((tool, index) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
return (
<Fragment key={tool.slug}>
{showSection ? <div className="mb-2 px-2 text-[var(--fs-tiny)] font-medium text-foreground/34">{tool.section}</div> : null}
<Link
to={`/${tool.slug}`}
title={tool.label}
onClick={handleNavClick}
className={cn("app-workspace-nav-link relative mb-1 flex h-11 shrink-0 items-center gap-3 rounded-md px-2.5 text-[var(--fs-body)] transition-colors", active ? "is-active font-medium" : "text-foreground/55 hover:bg-surface-hover hover:text-foreground/85")}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{tool.label}</span>
</Link>
</Fragment>
);
})}
</nav>
<div className="shrink-0 border-t border-border/55 p-2">
<WorkspaceSidebarFooter
expandedClassName="flex"
collapsedClassName="hidden"
accountClassName="flex-row gap-2 px-2"
/>
</div>
</div>
</aside>
) : null}
<div className="app-workspace-stage relative min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<button type="button" className="app-workspace-sidebar-toggle absolute left-2 top-2 z-30 grid size-8 place-items-center rounded-md border border-border/60 bg-background/75 text-foreground/80 backdrop-blur transition hover:bg-foreground/[0.06] lg:hidden" aria-label={mobileSidebarExpanded ? "收起侧栏" : "展开侧栏"} onClick={() => setMobileSidebarExpanded((current) => !current)}>
{mobileSidebarExpanded ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
) : null}
{children}
</div>
</div>
</div>
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
</>

View File

@@ -2,7 +2,6 @@ import { Button, Pagination } from "antd";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { WorkspaceSignalIcon, type WorkspaceSignalIconVariant } from "@/components/ui/aceternity/workspace-signal-icon";
import { cn } from "@/lib/utils";
export function WorkspacePage({ children, className, grid = false, fluid = false }: { children: ReactNode; className?: string; grid?: boolean; fluid?: boolean }) {
@@ -13,14 +12,13 @@ export function WorkspacePage({ children, className, grid = false, fluid = false
);
}
export function PageHeader({ title, description, meta, actions, icon }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode; icon?: WorkspaceSignalIconVariant }) {
export function PageHeader({ title, description, meta, actions }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode }) {
return (
<header className="app-page-header flex min-h-14 flex-col gap-3 border-b border-border/80 pb-4 sm:flex-row sm:items-center sm:justify-between">
<header className="app-page-header flex min-h-14 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
{icon ? <WorkspaceSignalIcon variant={icon} /> : null}
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-2.5">
<h1 className="truncate text-[var(--fs-title)] font-semibold leading-7">{title}</h1>
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
<h1 className="app-page-header-title truncate font-semibold leading-7">{title}</h1>
{meta}
</div>
{description ? <p className="mt-1 text-xs leading-5 text-foreground/58">{description}</p> : null}
@@ -44,7 +42,7 @@ export function ListToolbar({ children, trailing, active, onReset, className }:
}
export function TableSurface({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg border border-border bg-background", className)}>{children}</div>;
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg border border-border bg-surface", className)}>{children}</div>;
}
export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) {

View File

@@ -1,5 +1,5 @@
import { App, Popover, Switch } from "antd";
import { ChevronRight, LogIn, LogOut, Moon, ShieldCheck, Sun, UserRound } from "lucide-react";
import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router";
@@ -17,9 +17,10 @@ type WorkspaceSidebarFooterProps = {
expandedClassName: string;
collapsedClassName: string;
accountClassName: string;
showAnnouncement?: boolean;
};
export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, accountClassName }: WorkspaceSidebarFooterProps) {
export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, accountClassName, showAnnouncement = true }: WorkspaceSidebarFooterProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
@@ -49,10 +50,10 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
return (
<div>
{user ? (
{user && showAnnouncement ? (
<SystemAnnouncementCenter
userId={user.id}
className={cn("relative mb-1 flex h-9 w-full items-center rounded-md text-xs text-foreground/55 transition-colors hover:bg-foreground/[.05] hover:text-foreground", collapsedClassName)}
className={cn("relative mb-1 flex h-9 w-full items-center rounded-md text-xs text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground", collapsedClassName)}
showLabel
labelClassName={expandedClassName}
staticMotion
@@ -81,7 +82,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
) : null}
<div className="border-y border-border/65 py-2">
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
</div>
<div className="flex h-10 items-center px-2">
@@ -89,11 +90,11 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
<Switch size="small" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
</div>
<button type="button" className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-foreground/[.055] hover:text-foreground" onClick={() => void handleLogout()}><LogOut className="size-3.5" />退出登录</button>
<button type="button" className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-surface-hover hover:text-foreground" onClick={() => void handleLogout()}><LogOut className="size-3.5" />退出登录</button>
</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={creditsEnabled ? `${user.displayName || user.username} · ${balance} 积分` : user.displayName || user.username}>
<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-surface-hover", 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>
@@ -103,7 +104,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
</button>
</Popover>
) : (
<Link to="/login" className={cn("flex h-10 items-center rounded-md text-xs text-foreground/65 hover:bg-foreground/[.05] hover:text-foreground", collapsedClassName)} title="登录">
<Link to="/login" className={cn("flex h-10 items-center rounded-md text-xs text-foreground/65 hover:bg-surface-hover hover:text-foreground", collapsedClassName)} title="登录">
<LogIn className="size-4 shrink-0" /><span className={expandedClassName}>登录</span>
</Link>
)}
@@ -112,7 +113,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
}
function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) {
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
}
function UserAvatar({ user, className }: { user: LocalUser; className: string }) {
@@ -123,11 +124,11 @@ function UserAvatar({ user, className }: { user: LocalUser; className: string })
return (
<span className={`relative grid shrink-0 place-items-center ${className}`}>
<span className="grid size-full place-items-center overflow-hidden rounded-full bg-muted text-foreground/55">
<span className="grid size-full place-items-center overflow-hidden rounded-md bg-transparent text-foreground/55">
{avatarUrl && !failed ? (
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<UserRound className="size-[52%]" aria-hidden />
<CircleUserRound className="app-workspace-account-icon" aria-hidden />
)}
</span>
<IdentityProviderBadge user={user} compact className="absolute -bottom-1 -right-1" />

View File

@@ -0,0 +1,36 @@
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Settings2 } from "lucide-react";
import { Link } from "react-router";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
return (
<header className="app-workspace-topbar flex shrink-0 items-center justify-between gap-3 border-b px-4 sm:px-5">
<div className="flex min-w-0 items-center gap-2">
<button type="button" className="app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
<Link to="/" className="app-workspace-brand-link inline-flex min-w-0 items-center gap-2 text-foreground" title="影策工作台">
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background">
<InfinityIcon className="size-4" />
</span>
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] font-semibold">影策</span>
</Link>
</div>
<div className="flex shrink-0 items-center gap-1">
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
<Link to="/settings" className="app-workspace-topbar-icon-button" aria-label="设置" title="设置">
<Settings2 className="size-4" />
</Link>
</div>
</header>
);
}