feat(web): 用户工作台 - 产品化壳层、内容库布局与积分入口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCatandCursor committed 2026-09-16 22:59:45 +08:00
1 parent f52702b70f
commit 969e2831b2
46 files changed
+5355 -944

No files matched your search

@@ -6,7 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { defaultToolbarPrefs, readToolbarPrefs, resolveToolbarEntries, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import type { CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { useThemeStore } from "@/stores/use-theme-store";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
type CanvasProjectSelectionToolbarProps = {
anchorRef: RefObject<HTMLDivElement | null>;
@@ -23,7 +23,7 @@ type CanvasProjectSelectionToolbarProps = {
};
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos }: CanvasProjectSelectionToolbarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
const handlers = {
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos,
@@ -8,7 +8,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasMediaPerformanceMode } from "@/types/canvas";
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
@@ -66,7 +66,7 @@ export function CanvasTopBar({
onEnterFocusMode,
shortDramaGuide,
}: CanvasTopBarProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
const dockStyle = canvasDockStyle(theme, theme.node.text);
const user = useUserStore((state) => state.user);
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
@@ -226,12 +226,12 @@ export function CanvasTopBar({
</Dropdown>
</CanvasTopBarTooltip>
{user && creditsEnabled ? (
<CanvasTopBarTooltip label="查看积分明细">
<CanvasTopBarTooltip label="进入积分超市">
<Link
to="/wallet"
className="canvas-topbar-action inline-flex h-9 min-w-[5.5rem] items-center justify-center gap-1.5 rounded-lg px-2.5 text-xs font-medium tabular-nums"
style={{ color: theme.node.text }}
aria-label="查看积分明细"
aria-label="进入积分超市"
>
{refreshing && availableMicrocredits === null ? <LoaderCircle className="size-3.5 animate-spin opacity-60" style={{ color: theme.accent.primary }} /> : <Coins className="size-3.5" style={{ color: theme.accent.primary }} />}
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
+29 -96
View File
@@ -1,17 +1,17 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Modal } from "antd";
import { App, Button, Dropdown, Input, Modal } from "antd";
import { Select } from "@/components/ui/base/select";
import { ArrowDownAZ, Clock3, Download, FileUp, History, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
import { ArrowDownAZ, Clock3, Download, FileUp, History, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2 } from "lucide-react";
import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasCreateCard } from "@/components/canvas/canvas-project-card";
import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
import { CanvasHistoryDrawer } from "@/components/canvas/canvas-history-drawer";
import type { CanvasExportFile } from "@/types/canvas-export";
@@ -80,7 +80,7 @@ export default function CanvasPage() {
const mode = searchParams.get("mode");
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
const handoffMode = mode === "handoff";
const forwardedQuery = agentMode || handoffMode ? `?${searchParams.toString()}` : "";
const forwardedQuery = agentMode || handoffMode || searchParams.get("agent") === "1" ? `?${searchParams.toString()}` : "";
const preloadProject = useCallback(() => {
void loadCanvasProjectPage();
}, []);
@@ -111,7 +111,6 @@ export default function CanvasPage() {
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
const visibleProjects = userId ? filteredProjects : filteredProjects.slice(0, loadedProjectCount);
const hasMore = userId ? libraryQuery.hasNextPage : visibleProjects.length < filteredProjects.length;
const showCreateCard = !keyword.trim() && projectFilter === "all";
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布";
const sortLabel = sort === "name" ? "按名称" : sort === "nodes" ? "按节点" : "最近更新";
@@ -402,15 +401,15 @@ export default function CanvasPage() {
if (hydrated && !libraryQuery.isError && (mode === "new" || mode === "recent" || mode === "handoff")) return <main className="flex h-full items-center justify-center bg-background text-sm text-stone-500">正在打开画布...</main>;
return (
<WorkspacePage grid className="canvas-library-page">
<WorkspacePage className="studio-collection-page">
<div className="studio-band">
<PageHeader
title="画布"
title="我的画布"
description="把镜头、素材和想法留在同一张画布里。"
meta={<span className="app-projects-header-meta">{totalProjects} 个</span>}
actions={
<div className="canvas-library-header-actions">
<Button className="canvas-library-header-action is-primary library-primary-action" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>
<div className="collection-header-actions">
<Button type="primary" disabled={!hydrated} icon={<Plus />} onClick={createAndEnter}>
新建画布
</Button>
{projects.length ? (
@@ -423,100 +422,35 @@ export default function CanvasPage() {
placement="bottomRight"
trigger={["click"]}
>
<Button className="canvas-library-header-action is-icon" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal className="size-4" />} />
<Button type="text" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal />} />
</Dropdown>
) : null}
<Button className="canvas-library-header-action" disabled={!hydrated} icon={<FileUp className="size-3.5" />} onClick={() => inputRef.current?.click()}>
<Button disabled={!hydrated} icon={<FileUp />} onClick={() => inputRef.current?.click()}>
导入
</Button>
</div>
}
/>
<section className="canvas-library-discovery" aria-label="画布浏览工具">
<div className="canvas-library-search">
<Search aria-hidden="true" />
<input
value={keyword}
placeholder="搜索画布"
aria-label="搜索画布"
onChange={(event) => {
setKeyword(event.target.value);
}}
/>
{keyword ? (
<button
type="button"
aria-label="清除搜索"
onClick={() => {
setKeyword("");
}}
>
<X />
</button>
) : null}
</div>
<div className="canvas-library-filters">
<Dropdown
trigger={["click"]}
placement="bottomLeft"
menu={{
items: projectFilterItems,
selectedKeys: [projectFilter],
onClick: ({ key }) => {
setProjectFilter(String(key));
},
}}
>
<button type="button" className={`canvas-library-filter${projectFilter !== "all" ? " is-active" : ""}`} aria-label="按所属项目筛选">
<SlidersHorizontal />
<span>{projectFilterLabel}</span>
</button>
</Dropdown>
<Dropdown
trigger={["click"]}
placement="bottomLeft"
menu={{
items: sortItems,
selectedKeys: [sort],
onClick: ({ key }) => {
setSort(key as typeof sort);
},
}}
>
<button type="button" className={`canvas-library-filter${sort !== "updated" ? " is-active" : ""}`} aria-label="画布排序">
{sort === "updated" ? <Clock3 /> : sort === "name" ? <ArrowDownAZ /> : <ListFilter />}
<span>{sortLabel}</span>
</button>
</Dropdown>
<button type="button" className="canvas-library-filter flex items-center gap-1.5" onClick={() => setHistoryOpen(true)} aria-label="查看画布创建与变更历史时间线" title="查看画布创建与变更历史时间线">
<History className="size-3.5 text-[var(--workspace-accent)]" />
<span>历史</span>
</button>
{keyword || projectFilter !== "all" || sort !== "updated" ? (
<button
type="button"
className="canvas-library-reset"
onClick={() => {
setKeyword("");
setProjectFilter("all");
setSort("updated");
}}
>
重置
</button>
) : null}
</div>
<span className="canvas-library-count">
<strong>{String(filteredProjects.length).padStart(2, "0")}</strong>
<span>/ {String(totalProjects).padStart(2, "0")} 画布</span>
</span>
</section>
<CollectionToolbar
label="画布浏览工具"
active={Boolean(keyword || projectFilter !== "all" || sort !== "updated")}
onReset={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); }}
trailing={<Button type="text" icon={<History />} onClick={() => setHistoryOpen(true)}>创作历史</Button>}
>
<Input prefix={<Search />} value={keyword} allowClear placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => setKeyword(event.target.value)} />
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: projectFilterItems, selectedKeys: [projectFilter], onClick: ({ key }) => setProjectFilter(String(key)) }}>
<Button icon={<SlidersHorizontal />} aria-label="按所属项目筛选">{projectFilterLabel}</Button>
</Dropdown>
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: sortItems, selectedKeys: [sort], onClick: ({ key }) => setSort(key as typeof sort) }}>
<Button icon={sort === "updated" ? <Clock3 /> : sort === "name" ? <ArrowDownAZ /> : <ListFilter />} aria-label="画布排序">{sortLabel}</Button>
</Dropdown>
</CollectionToolbar>
</div>
<div className="canvas-library-frame">
<div className="collection-content">
{selectedIds.length ? (
<div className="app-canvas-selection-toolbar mt-2 flex min-h-10 flex-wrap items-center gap-2 rounded-md border px-3 py-1.5 text-xs">
<div className="collection-selection-bar">
<strong className="mr-auto font-medium">已选 {selectedIds.length} 个画布</strong>
<Button
size="small"
@@ -553,9 +487,8 @@ export default function CanvasPage() {
<div role="alert">画布列表读取失败<Button onClick={() => void libraryQuery.refetch()}>重试</Button></div>
) : !hydrated || (userId && libraryQuery.isPending) ? (
<WorkspaceLoadingState label="正在恢复画布" detail="读取本地缓存与账号同步状态" />
) : showCreateCard || visibleProjects.length ? (
<CollectionGrid className="canvas-library-grid">
{showCreateCard ? <CanvasCreateCard disabled={!hydrated} onClick={createAndEnter} /> : null}
) : visibleProjects.length ? (
<CollectionGrid className="canvas-collection-grid">
{visibleProjects.map((project) => (
<CanvasFolderCard
key={project.id}
@@ -568,7 +501,7 @@ export default function CanvasPage() {
))}
</CollectionGrid>
) : (
<WorkspaceState icon="canvas" title="没有匹配的画布" description="换一个画布名称或重置筛选条件。" />
<WorkspaceState icon="canvas" title={keyword || projectFilter !== "all" ? "没有匹配的画布" : "让第一个想法落在画布上"} description={keyword || projectFilter !== "all" ? "换一个画布名称或重置筛选条件。" : "图片、分镜和灵感,都可以在这里自由组织。"} action={!keyword && projectFilter === "all" ? <Button type="primary" icon={<Plus />} disabled={!hydrated} onClick={createAndEnter}>新建画布</Button> : undefined} />
)}
{hydrated && visibleProjects.length ? (
<div ref={loadMoreRef} className="library-load-more" aria-live="polite">
+12 -3
View File
@@ -24,7 +24,7 @@ import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-charac
import { useAssetStore } from "@/stores/use-asset-store";
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { useThemeStore } from "@/stores/use-theme-store";
import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { App, Button } from "antd";
import { ArrowLeftRight } from "lucide-react";
@@ -227,6 +227,7 @@ function visibleGenerationBatch(node: CanvasNodeData) {
}
export default function CanvasPage() {
useCanvasThemeScope();
const [mounted, setMounted] = useState(false);
useEffect(() => {
@@ -257,8 +258,8 @@ function InfiniteCanvasPage() {
const assets = useAssetStore((state) => state.assets);
const assetsHydrated = useAssetStore((state) => state.hydrated);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const colorTheme = useCanvasThemeStore((state) => state.theme);
const setTheme = useCanvasThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
@@ -570,6 +571,14 @@ function InfiniteCanvasPage() {
setSearchParams(next, { replace: true });
}, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]);
useEffect(() => {
if (!projectLoaded || searchParams.get("agent") !== "1") return;
openAgent();
const next = new URLSearchParams(searchParams);
next.delete("agent");
setSearchParams(next, { replace: true });
}, [projectLoaded, searchParams, setSearchParams, openAgent]);
// 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。
const prevFocusModeRef = useRef(focusMode);
useEffect(() => {
+11 -10
View File
@@ -20,16 +20,17 @@ import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, is
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
import { getPublicCanvasShare } from "@/services/api/canvas-share";
import { useThemeStore } from "@/stores/use-theme-store";
import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas";
type ContextMenu = { x: number; y: number; world: Position; nodeId?: string };
type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: number; origins: Map<string, Position>; moved: boolean };
export default function SharedCanvasPage() {
useCanvasThemeScope();
const { token = "" } = useParams();
const { message } = App.useApp();
const colorTheme = useThemeStore((state) => state.theme);
const colorTheme = useCanvasThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
@@ -79,10 +80,10 @@ export default function SharedCanvasPage() {
useEffect(() => {
let active = true;
const themeBeforeShare = useThemeStore.getState().theme;
const themeBeforeShare = useCanvasThemeStore.getState().theme;
let appliedShareTheme: typeof themeBeforeShare | null = null;
let themeChangedAfterApply = false;
const unsubscribeTheme = useThemeStore.subscribe((state, previous) => {
const unsubscribeTheme = useCanvasThemeStore.subscribe((state, previous) => {
if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true;
});
setLoading(true);
@@ -94,7 +95,7 @@ export default function SharedCanvasPage() {
const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare);
setAppearance(nextAppearance);
appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare);
useThemeStore.getState().setTheme(appliedShareTheme);
useCanvasThemeStore.getState().setTheme(appliedShareTheme);
setBackgroundMode(project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE);
const initial = project.viewport || { x: 0, y: 0, k: 1 };
viewportRef.current = initial;
@@ -107,8 +108,8 @@ export default function SharedCanvasPage() {
return () => {
active = false;
unsubscribeTheme();
if (appliedShareTheme && !themeChangedAfterApply && useThemeStore.getState().theme === appliedShareTheme) {
useThemeStore.getState().setTheme(themeBeforeShare);
if (appliedShareTheme && !themeChangedAfterApply && useCanvasThemeStore.getState().theme === appliedShareTheme) {
useCanvasThemeStore.getState().setTheme(themeBeforeShare);
}
};
}, [token]);
@@ -293,7 +294,7 @@ export default function SharedCanvasPage() {
}
function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
return <div data-canvas-no-zoom className="absolute z-[var(--z-modal)] min-w-48 rounded-lg border p-1.5 shadow-xl" style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
{menu.nodeId ? <><MenuButton icon={<Eye />} label="查看节点信息" onClick={onInfo} /><MenuButton icon={<LockKeyhole />} label="编辑或生成" onClick={onUnauthorized} /></> : <>
<div className="px-2 py-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>添加临时节点</div>
@@ -310,7 +311,7 @@ function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string;
}
function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><ImageIcon className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span></div>
<div className="min-h-0 flex-1 whitespace-pre-wrap break-words p-4 text-sm leading-6" style={{ color: theme.node.muted }}>{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}</div>
@@ -319,7 +320,7 @@ function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUn
}
function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
const rows = node.metadata?.storyboard?.rows || [];
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><Clapperboard className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span><span className="text-xs" style={{ color: theme.node.muted }}>{rows.length} 镜</span><button type="button" className="grid size-7 place-items-center rounded hover:bg-black/5 dark:hover:bg-white/10" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }} aria-label="一键创建视频节点"><Video className="size-3.5" /></button></div>
@@ -11,7 +11,7 @@ import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, loadCanvasProjectForEditing, localSavedRemotePendingMessage, saveRemoteUserDataNow, subscribeAgentCanvasRefresh } from "@/services/user-data-sync";
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { CanvasHistorySnapshot } from "./use-canvas-history";
@@ -94,7 +94,7 @@ export function useCanvasProjectLifecycle({
setLoadError("");
const applyRestoredProject = (targetProject: CanvasProject) => {
if (cancelled) return;
const fallbackTheme = useThemeStore.getState().theme;
const fallbackTheme = useCanvasThemeStore.getState().theme;
const restoredAppearance = targetProject.appearance
? normalizeCanvasAppearance(targetProject.appearance, fallbackTheme)
: canvasAppearanceForTheme(fallbackTheme);
@@ -119,7 +119,7 @@ export function useCanvasProjectLifecycle({
setChatSessions(snapshot.chatSessions);
setActiveChatId(snapshot.activeChatId);
setCanvasAppearance(snapshot.canvasAppearance);
useThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme));
useCanvasThemeStore.getState().setTheme(canvasAppearanceBaseTheme(snapshot.canvasAppearance, fallbackTheme));
setBackgroundMode(snapshot.backgroundMode);
setShowImageInfo(snapshot.showImageInfo);
setViewport(targetProject.viewport);