feat(assets): 素材库与画布 - 新增可交互通用文件夹

This commit is contained in:
echoD886
2026-08-20 15:16:45 +08:00
parent 9ee71d4650
commit 935108edca
35 changed files with 2010 additions and 155 deletions

View File

@@ -0,0 +1,208 @@
import React, { useEffect, useMemo, useState } from "react";
import { Dropdown } from "antd";
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType } from "@/types/canvas";
const DEFAULT_FOLDER_COVER = "/images/canvas/folder-default-cover.png";
const FOLDER_STYLE_OPTIONS: Array<{ key: CanvasFolderStyle; label: string }> = [
{ key: "glass", label: "流光玻璃" },
{ key: "stacked", label: "内容陈列" },
{ key: "midnight", label: "午夜封面" },
{ key: "paper", label: "纸感收藏" },
{ key: "cinema", label: "电影胶片" },
{ key: "compact", label: "紧凑资料" },
];
export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
data,
childNodes,
active,
isDropTarget,
readOnly,
onToggleCollapsed,
onTitleChange,
onStyleChange,
}: {
data: CanvasNodeData;
childNodes: CanvasNodeData[];
active: boolean;
isDropTarget: boolean;
readOnly: boolean;
onToggleCollapsed: (nodeId: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
}) {
const style = data.metadata?.folder?.style || "glass";
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(data.title);
const previewNodes = useMemo(() => childNodes.filter(canPreviewFolderNode).slice(0, 3), [childNodes]);
const primaryNode = previewNodes[0];
const showAdd = style === "glass" || childNodes.length === 0;
useEffect(() => setTitle(data.title), [data.title]);
const commitTitle = () => {
const next = title.trim() || "未命名文件夹";
setTitle(next);
setEditing(false);
onTitleChange(data.id, next);
};
return (
<div className={`canvas-folder-preview canvas-folder-preview-${style}${active ? " is-active" : ""}${isDropTarget ? " is-drop-target" : ""}`}>
<div className="canvas-folder-back" aria-hidden>
<FolderNodeMedia node={primaryNode} fallback />
</div>
<div className="canvas-folder-preview-stack" aria-hidden>
{(previewNodes.length ? previewNodes : [undefined, undefined]).map((node, index) => (
<div key={node?.id || `folder-placeholder-${index}`} className={`canvas-folder-preview-card canvas-folder-preview-card-${index + 1}`}>
<FolderNodeMedia node={node} fallback={index === 0} />
</div>
))}
</div>
<div className="canvas-folder-front">
<div className="canvas-folder-front-media" aria-hidden>
<FolderNodeMedia node={primaryNode} fallback />
</div>
<div className="canvas-folder-front-tint" aria-hidden />
<div className="canvas-folder-copy">
{editing ? (
<input
autoFocus
className="canvas-folder-title-input"
value={title}
onChange={(event) => setTitle(event.target.value)}
onBlur={commitTitle}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") commitTitle();
if (event.key === "Escape") {
setTitle(data.title);
setEditing(false);
}
}}
/>
) : (
<button
type="button"
className="canvas-folder-title"
title={readOnly ? data.title : `${data.title} · 点击重命名`}
onClick={(event) => {
event.stopPropagation();
if (!readOnly) setEditing(true);
}}
onDoubleClick={(event) => {
event.stopPropagation();
if (!readOnly) setEditing(true);
}}
>
<span>{data.title}</span>{!readOnly ? <Pencil aria-hidden /> : null}
</button>
)}
<span className="canvas-folder-meta">
{style === "midnight" ? formatFolderDate(data.metadata?.folder?.createdAt) : `${childNodes.length} 项内容`}
</span>
</div>
{!readOnly ? (
showAdd ? (
<button
type="button"
className="canvas-folder-action canvas-folder-add"
aria-label="展开文件夹并添加内容"
title="展开文件夹并添加内容"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleCollapsed(data.id);
}}
>
<Plus />
</button>
) : (
<Dropdown
trigger={["click"]}
menu={{
selectedKeys: [style],
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
}}
>
<button
type="button"
className="canvas-folder-action canvas-folder-options"
aria-label={`文件夹选项,当前 ${childNodes.length} 项`}
title="切换文件夹样式"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal />
</button>
</Dropdown>
)
) : null}
{!readOnly && showAdd ? (
<Dropdown
trigger={["click"]}
menu={{
selectedKeys: [style],
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
}}
>
<button
type="button"
className="canvas-folder-style-trigger"
aria-label="切换文件夹样式"
title="切换文件夹样式"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal />
</button>
</Dropdown>
) : null}
{readOnly && childNodes.length > 0 ? <span className="canvas-folder-count">{childNodes.length}</span> : null}
</div>
</div>
);
});
function canPreviewFolderNode(node: CanvasNodeData) {
return node.type !== CanvasNodeType.Frame;
}
function FolderNodeMedia({ node, fallback = false }: { node?: CanvasNodeData; fallback?: boolean }) {
if (node?.type === CanvasNodeType.Image && node.metadata?.content) {
return <img src={node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video && node.metadata?.content) {
return <video src={node.metadata.content} muted playsInline preload="metadata" />;
}
if (node?.type === CanvasNodeType.Drawing && (node.metadata?.drawingPreviewUrl || node.metadata?.content)) {
return <img src={node.metadata.drawingPreviewUrl || node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video) return <Video className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Audio) return <FileAudio className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Skill) return <Sparkles className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Config) return <SlidersHorizontal className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Text || node?.type === CanvasNodeType.Script) {
return <span className="canvas-folder-text-preview">{node.metadata?.content || node.title}</span>;
}
if (node) return <FileText className="canvas-folder-file-icon" />;
return fallback ? <img src={DEFAULT_FOLDER_COVER} alt="" loading="lazy" decoding="async" draggable={false} /> : <span className="canvas-folder-file-sheet" />;
}
function formatFolderDate(value?: string) {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) return "刚刚创建";
return new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric" }).format(date);
}