diff --git a/AGENTS.md b/AGENTS.md index 09eb7be..5a83aa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ - 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。 - 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。 - 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。 +- 画布内的操作按钮(如面板里的「添加」「导出」「选择」等)默认用扁平无底色样式:透明背景、仅 `hover:bg-black/5 dark:hover:bg-white/10` 轻微反馈,靠图标+文字表达,不要用 `theme.toolbar.activeBg`(`#e7e5df`/`#3a3631`)或 `theme.node.fill` 之类的灰色作为按钮填充底色。灰色 `activeBg` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。 - 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。 - 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 050638d..02094c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。 ++ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。 + [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。 + [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。 + [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 8b479f2..cbd103b 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 左侧画布面板「资产」Tab:搜索框旁「添加」按钮可上传图片/视频文件(支持多选)加入资产库;每张资产卡片悬停显示移除按钮,二次确认后删除;点击卡片仍插入到画布;需验证添加图片/视频、非图片视频文件的提示、移除确认与插入行为。 - 左侧画布面板:支持拖拽右边缘调整宽度(记忆到本地)、通过顶栏菜单左侧的开关按钮展开/收起,展开收起有动画;需验证宽度拖拽、动画、刷新后保持展开/收起状态与宽度。 - 左侧画布面板:「画布/资产」切换改为带滑动下划线的动画;点击「画布元素」列表项跳转到对应节点时带缓动动画(视图平滑移动缩放而非瞬移);文本/配置/视频/音频等非图片元素的图标去掉灰色底色;需验证视觉与交互。 - 左侧画布面板:画布元素列表点击「选择」进入多选模式,可勾选/全选节点后「导出选中」为压缩包;压缩包为扁平结构——图片/视频/音频直接是对应文件(按节点名称命名、重名自动加序号),文本节点为 `.txt`,其余无法识别的节点各自导出为同名 `.json`;需验证多选、全选、导出内容与命名正确。 diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 97d0ec1..54504e3 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -311,39 +311,41 @@ export const CanvasNode = React.memo(function CanvasNode({ onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)} onContextMenu={(event) => onContextMenu(event, data.id)} > -
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> - {isEditingTitle ? ( - setTitleDraft(event.target.value)} - onBlur={finishTitleEditing} - onKeyDown={(event) => { - if (event.key === "Enter") finishTitleEditing(); - if (event.key === "Escape") { - setTitleDraft(data.title || ""); - setIsEditingTitle(false); - } - }} - /> - ) : ( - - )} -
+ {(isSelected || hovered || isEditingTitle) && ( +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + {isEditingTitle ? ( + setTitleDraft(event.target.value)} + onBlur={finishTitleEditing} + onKeyDown={(event) => { + if (event.key === "Enter") finishTitleEditing(); + if (event.key === "Escape") { + setTitleDraft(data.title || ""); + setIsEditingTitle(false); + } + }} + /> + ) : ( + + )} +
+ )}
void handleExport()} disabled={!checked.size || exporting} - className="ml-auto flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40" - style={{ background: theme.toolbar.activeBg, color: theme.toolbar.activeText }} + className="ml-auto flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10" + style={{ color: theme.node.text }} > 导出选中 @@ -277,10 +279,15 @@ function buildInsertPayload(asset: Asset): InsertAssetPayload { } function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) { + const { message } = App.useApp(); const assets = useAssetStore((state) => state.assets); + const addAsset = useAssetStore((state) => state.addAsset); + const removeAsset = useAssetStore((state) => state.removeAsset); const [keyword, setKeyword] = useState(""); const [tagFilter, setTagFilter] = useState("all"); const [collapsed, setCollapsed] = useState>({}); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]); @@ -291,10 +298,51 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]); + const handleFiles = async (fileList: FileList | null) => { + const files = Array.from(fileList || []); + if (!files.length) return; + setUploading(true); + const hide = message.loading("正在添加资产…", 0); + let added = 0; + try { + for (const file of files) { + if (file.type.startsWith("image/")) { + const image = await uploadImage(file); + addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } }); + added += 1; + } else if (file.type.startsWith("video/")) { + const media = await uploadMediaFile(file, "video"); + addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } }); + added += 1; + } + } + if (added) message.success(`已添加 ${added} 个资产`); + else message.warning("仅支持图片或视频文件"); + } catch (error) { + console.error(error); + message.error("添加失败,请重试"); + } finally { + hide(); + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + return (
-
+
} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} /> + + void handleFiles(e.target.files)} />
{allTags.length ? (
@@ -324,7 +372,7 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP {isCollapsed ? null : (
{group.items.map((asset) => ( - onInsert(buildInsertPayload(asset))} /> + onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} /> ))}
)} @@ -340,13 +388,38 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP ); } -function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) { - const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : ""); +function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) { return ( - +
+ +
+ + + + +
+
); } + +function AssetCover({ asset }: { asset: Asset }) { + if (asset.kind === "text") return
{asset.data.content}
; + if (asset.kind === "video") { + if (asset.coverUrl) return ; + return