From d8637f50320e56f80d57d5f45bef20e7d590c32c Mon Sep 17 00:00:00 2001 From: echoD886 Date: Thu, 20 Aug 2026 15:27:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(dev):=20=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E9=A2=84=E8=A7=88=20-=20=E6=96=B0=E5=A2=9E=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=B1=95=E5=8E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/dev/folder-preview-lab.css | 232 +++++++++++++++++++++++ web/src/pages/dev/folder-preview-lab.tsx | 186 ++++++++++++++++++ web/src/router.tsx | 4 + 3 files changed, 422 insertions(+) create mode 100644 web/src/pages/dev/folder-preview-lab.css create mode 100644 web/src/pages/dev/folder-preview-lab.tsx diff --git a/web/src/pages/dev/folder-preview-lab.css b/web/src/pages/dev/folder-preview-lab.css new file mode 100644 index 000000000..ce78dad46 --- /dev/null +++ b/web/src/pages/dev/folder-preview-lab.css @@ -0,0 +1,232 @@ +.folder-preview-lab { + min-height: 100dvh; + overflow: auto; + background: var(--workspace-canvas); + color: var(--fg); + padding: var(--space-10) var(--space-12) var(--space-20); +} + +.folder-preview-lab-header { + display: flex; + max-width: 1280px; + margin: 0 auto var(--space-10); + align-items: flex-end; + justify-content: space-between; + gap: var(--space-8); +} + +.folder-preview-lab-kicker { + color: var(--muted-foreground); + font-size: var(--fs-label); + font-weight: 650; + letter-spacing: .12em; +} + +.folder-preview-lab-header h1 { + margin: var(--space-2) 0 0; + font-size: var(--fs-display); + font-weight: 620; + letter-spacing: -.04em; +} + +.folder-preview-lab-header p { + max-width: 620px; + margin: var(--space-3) 0 0; + color: var(--muted-foreground); + font-size: var(--fs-body-lg); + line-height: 1.7; +} + +.folder-preview-lab-guide { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-2); + border-radius: var(--r-full); + background: var(--library-surface); + padding: var(--space-3) var(--space-4); + color: var(--muted-foreground); + font-size: var(--fs-caption); +} + +.folder-preview-lab-guide svg, +.folder-preview-lab-open svg, +.folder-preview-lab-drawer-header button svg { + width: var(--space-4); + height: var(--space-4); +} + +.folder-preview-lab-grid { + display: grid; + max-width: 1280px; + margin: 0 auto; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: var(--space-10) var(--space-8); +} + +.folder-preview-lab-item { + min-width: 0; +} + +.folder-preview-lab-item-copy { + display: flex; + margin-bottom: var(--space-4); + align-items: baseline; + justify-content: space-between; + gap: var(--space-4); +} + +.folder-preview-lab-item-copy strong { + font-size: var(--fs-heading); + font-weight: 620; +} + +.folder-preview-lab-item-copy span { + color: var(--muted-foreground); + font-size: var(--fs-caption); +} + +.folder-preview-lab-folder { + width: 360px; + height: 280px; + max-width: 100%; + margin: 0 auto; + cursor: pointer; +} + +.folder-preview-lab-folder:focus-visible { + border-radius: var(--r-5xl); + outline: 2px solid var(--cn-active-stroke); + outline-offset: var(--space-2); +} + +.folder-preview-lab-open, +.folder-preview-lab-drawer-header button { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + gap: var(--space-2); + border: 0; + border-radius: var(--r-xl); + background: var(--library-surface); + padding: var(--space-2) var(--space-4); + color: var(--fg); + font-size: var(--fs-body); + transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) var(--motion-ease-out); +} + +.folder-preview-lab-open { + margin: var(--space-4) auto 0; +} + +.folder-preview-lab-open:hover, +.folder-preview-lab-drawer-header button:hover { + background: var(--library-surface-hover); + transform: translateY(-2px); +} + +.folder-preview-lab-drawer { + position: fixed; + z-index: var(--z-modal); + inset: var(--space-6); + overflow: auto; + border-radius: var(--r-5xl); + background: var(--bg); + padding: var(--space-8); + box-shadow: var(--elevation-overlay); +} + +.folder-preview-lab-drawer-header { + display: grid; + max-width: 1180px; + margin: 0 auto var(--space-8); + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--space-4); +} + +.folder-preview-lab-drawer-header > div { + text-align: center; +} + +.folder-preview-lab-drawer-header > div span { + color: var(--muted-foreground); + font-size: var(--fs-caption); +} + +.folder-preview-lab-drawer-header h2 { + margin: var(--space-1) 0 0; + font-size: var(--fs-title); +} + +.folder-preview-lab-drawer-header button:first-child { + justify-self: start; +} + +.folder-preview-lab-drawer-header button:last-child { + justify-self: end; +} + +.folder-preview-lab-assets { + display: grid; + max-width: 1180px; + margin: 0 auto; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-5); +} + +.folder-preview-lab-asset { + overflow: hidden; + border-radius: var(--r-3xl); + background: var(--library-surface); + transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) var(--motion-ease-out); +} + +.folder-preview-lab-asset:hover { + background: var(--library-surface-hover); + transform: translateY(-2px); +} + +.folder-preview-lab-asset img { + display: block; + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; +} + +.folder-preview-lab-asset div { + display: flex; + padding: var(--space-4); + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.folder-preview-lab-asset strong { + font-size: var(--fs-body); +} + +.folder-preview-lab-asset span { + color: var(--muted-foreground); + font-size: var(--fs-caption); +} + +@media (max-width: 760px) { + .folder-preview-lab { padding: var(--space-6) var(--space-4) var(--space-12); } + .folder-preview-lab-header { align-items: flex-start; flex-direction: column; } + .folder-preview-lab-grid { grid-template-columns: minmax(0, 1fr); } + .folder-preview-lab-folder { transform: scale(.86); transform-origin: top center; margin-bottom: -40px; } + .folder-preview-lab-drawer { inset: var(--space-2); padding: var(--space-4); } + .folder-preview-lab-drawer-header { grid-template-columns: 1fr 1fr; } + .folder-preview-lab-drawer-header > div { grid-column: 1 / -1; grid-row: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .folder-preview-lab-folder *, + .folder-preview-lab-open, + .folder-preview-lab-asset, + .folder-preview-lab-drawer-header button { + transition-duration: .01ms !important; + } +} diff --git a/web/src/pages/dev/folder-preview-lab.tsx b/web/src/pages/dev/folder-preview-lab.tsx new file mode 100644 index 000000000..08c388e59 --- /dev/null +++ b/web/src/pages/dev/folder-preview-lab.tsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from "react"; +import { ChevronLeft, FolderOpen, ImagePlus, MousePointer2 } from "lucide-react"; + +import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview"; +import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas"; +import { CanvasNodeType } from "@/types/canvas"; + +import "./folder-preview-lab.css"; + +const STYLE_PRESETS: Array<{ style: CanvasFolderStyle; name: string; hint: string }> = [ + { style: "glass", name: "流光玻璃", hint: "复刻参考 · 加号入口" }, + { style: "stacked", name: "内容陈列", hint: "复刻参考 · 文件预览" }, + { style: "midnight", name: "午夜封面", hint: "复刻参考 · 深色标签" }, + { style: "paper", name: "纸感收藏", hint: "衍生 · 轻量资料" }, + { style: "cinema", name: "电影胶片", hint: "衍生 · 影像项目" }, + { style: "compact", name: "紧凑资料", hint: "衍生 · 高频调用" }, +]; + +const SAMPLE_MEDIA = [ + "/short-drama-styles/fantasy-3d.jpg", + "/short-drama-styles/suspense-noir.jpg", + "/short-drama-styles/cyberpunk-neon.jpg", + "/short-drama-styles/ink-narrative.jpg", + "/short-drama-styles/space-opera.jpg", +]; + +function makeMediaNode(id: string, title: string, content: string, index: number): CanvasNodeData { + return { + id, + type: CanvasNodeType.Image, + title, + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata: { content, mimeType: "image/jpeg", count: index + 1 }, + }; +} + +function makeFolderNode(style: CanvasFolderStyle, index: number): CanvasNodeData { + return { + id: `folder-${style}`, + type: CanvasNodeType.Frame, + title: ["我的素材", "项目资料", "视觉档案", "灵感收藏", "影像资产", "快速调用"][index], + position: { x: 0, y: 0 }, + width: 360, + height: 280, + metadata: { + folder: { style, createdAt: new Date(2026, 7, 19 + index).toISOString() }, + frame: { collapsed: true, expandedWidth: 760, expandedHeight: 520 }, + }, + }; +} + +const INITIAL_FOLDERS = STYLE_PRESETS.map((preset, index) => makeFolderNode(preset.style, index)); + +const INITIAL_CHILDREN = Object.fromEntries( + INITIAL_FOLDERS.map((folder, folderIndex) => [ + folder.id, + SAMPLE_MEDIA.slice(0, 3 + (folderIndex % 3)).map((source, childIndex) => + makeMediaNode(`${folder.id}-asset-${childIndex}`, ["主视觉", "氛围参考", "场景草图", "色彩方案", "镜头灵感"][childIndex], source, childIndex), + ), + ]), +) as Record; + +export default function FolderPreviewLab() { + const [folders, setFolders] = useState(INITIAL_FOLDERS); + const [childrenByFolder, setChildrenByFolder] = useState(INITIAL_CHILDREN); + const [selectedId, setSelectedId] = useState(INITIAL_FOLDERS[0].id); + const [openedId, setOpenedId] = useState(null); + const openedFolder = useMemo(() => folders.find((folder) => folder.id === openedId), [folders, openedId]); + const openedChildren = openedId ? childrenByFolder[openedId] || [] : []; + + const updateFolder = (nodeId: string, patch: (folder: CanvasNodeData) => CanvasNodeData) => { + setFolders((current) => current.map((folder) => (folder.id === nodeId ? patch(folder) : folder))); + }; + + const addSampleContent = () => { + if (!openedId) return; + setChildrenByFolder((current) => { + const previous = current[openedId] || []; + const index = previous.length; + const next = makeMediaNode( + `${openedId}-asset-${Date.now()}`, + `新素材 ${index + 1}`, + SAMPLE_MEDIA[index % SAMPLE_MEDIA.length], + index, + ); + return { ...current, [openedId]: [...previous, next] }; + }); + }; + + return ( +
+
+
+ DEV · INTERACTIVE FOLDER LAB +

通用素材文件夹

+

悬停查看动效,单击选中,双击打开;标题和右上角菜单都可以直接操作。

+
+
+ + 试试 hover、双击与样式切换 +
+
+ +
+ {folders.map((folder, index) => { + const preset = STYLE_PRESETS[index]; + const childNodes = childrenByFolder[folder.id] || []; + return ( +
+
+ {preset.name} + {preset.hint} +
+
setSelectedId(folder.id)} + onDoubleClick={() => setOpenedId(folder.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setOpenedId(folder.id); + } + }} + > + updateFolder(nodeId, (current) => ({ ...current, title }))} + onStyleChange={(nodeId, style) => + updateFolder(nodeId, (current) => ({ + ...current, + metadata: { + ...current.metadata, + folder: { ...current.metadata!.folder!, style }, + }, + })) + } + /> +
+ +
+ ); + })} +
+ + {openedFolder ? ( +
+
+ +
+ 已打开 +

{openedFolder.title}

+
+ +
+
+ {openedChildren.map((node) => ( +
+ +
+ {node.title} + 图片素材 +
+
+ ))} +
+
+ ) : null} +
+ ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index b672be6c0..8fe708875 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -40,6 +40,7 @@ const ProjectsPage = lazy(loadProjectsPage); const ProjectDetailPage = lazy(() => import("@/pages/projects/detail")); const SettingsPage = lazy(() => import("@/pages/settings")); const TestVoiceRecording = lazy(() => import("@/pages/test-voice-recording")); +const FolderPreviewLab = lazy(() => import("@/pages/dev/folder-preview-lab")); function deferred(element: ReactNode) { return }>{element}; @@ -59,6 +60,9 @@ export const router = createBrowserRouter([ ], }, { path: "/share/canvas/:token", element: fullScreenDeferred(), errorElement: }, + ...(import.meta.env.DEV + ? [{ path: "/dev/folders", element: fullScreenDeferred(), errorElement: }] + : []), { element: (