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:
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")} />
|
||||
</>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
36
web/src/components/layout/workspace-top-bar.tsx
Normal file
36
web/src/components/layout/workspace-top-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user