157 lines
7.9 KiB
TypeScript
157 lines
7.9 KiB
TypeScript
import { App, Dropdown, Input } from "antd";
|
|
import { Download, LoaderCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
|
import type { KeyboardEvent } from "react";
|
|
|
|
import { ProjectPreview } from "@/components/canvas/canvas-project-card";
|
|
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
|
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
|
import type { CanvasLibrarySummary } from "@/services/api/user-data";
|
|
import { loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
|
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type CanvasFolderCardProps = {
|
|
project: CanvasLibrarySummary;
|
|
projectName?: string;
|
|
onClick: () => void;
|
|
onPrefetch?: () => void;
|
|
opening?: boolean;
|
|
};
|
|
|
|
/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */
|
|
export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, opening = false }: CanvasFolderCardProps) {
|
|
const { message } = App.useApp();
|
|
const renameProject = useCanvasStore((state) => state.renameProject);
|
|
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
|
const editingId = useCanvasUiStore((state) => state.editingProjectId);
|
|
const editingTitle = useCanvasUiStore((state) => state.editingProjectTitle);
|
|
const startEditing = useCanvasUiStore((state) => state.startEditingProject);
|
|
const setEditingTitle = useCanvasUiStore((state) => state.setEditingProjectTitle);
|
|
const stopEditing = useCanvasUiStore((state) => state.stopEditingProject);
|
|
const toggleSelected = useCanvasUiStore((state) => state.toggleSelectedProjectId);
|
|
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
|
|
const editing = editingId === project.id;
|
|
const selected = selectedIds.includes(project.id);
|
|
|
|
const saveTitle = async () => {
|
|
if (!editing) return;
|
|
stopEditing();
|
|
try {
|
|
await loadCanvasProjectForEditing(project.id);
|
|
renameProject(project.id, editingTitle);
|
|
await saveRemoteUserDataNow();
|
|
} catch (error) { message.error(error instanceof Error ? error.message : "重命名失败"); }
|
|
};
|
|
const exportProject = async () => {
|
|
try {
|
|
const fullProject = await loadCanvasProjectForEditing(project.id);
|
|
if (!fullProject) throw new Error("画布不存在");
|
|
await exportCanvasProjects([fullProject], project.title || "画布");
|
|
} catch (error) { message.error(error instanceof Error ? error.message : "导出失败"); }
|
|
};
|
|
|
|
const handleOpenKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
if (!editing) onClick();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing", opening && "is-opening")} onPointerEnter={onPrefetch} onPointerDown={onPrefetch} onFocusCapture={onPrefetch}>
|
|
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} aria-busy={opening} onClick={() => !editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
|
|
<div className="canvas-folder-preview" aria-hidden="true">
|
|
<ProjectPreview project={{ id: project.id, nodes: project.previewNodes }} preferLatestImage />
|
|
{opening ? <div className="canvas-folder-opening"><LoaderCircle className="size-5 animate-spin" /><span>正在打开</span></div> : null}
|
|
</div>
|
|
<div className="canvas-folder-body">
|
|
<div className="canvas-folder-heading-row">
|
|
{editing ? (
|
|
<Input
|
|
className="canvas-folder-title-input"
|
|
value={editingTitle}
|
|
onChange={(event) => setEditingTitle(event.target.value)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onBlur={saveTitle}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") saveTitle();
|
|
if (event.key === "Escape") stopEditing();
|
|
}}
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<span className="canvas-folder-title">{project.title}</span>
|
|
)}
|
|
</div>
|
|
<div className="canvas-folder-meta">
|
|
<span className="canvas-folder-meta-item">{projectName ? `所属项目:${projectName}` : "自由画布"}</span>
|
|
<span className="canvas-folder-meta-separator" aria-hidden="true">·</span>
|
|
<span className="canvas-folder-meta-item">{project.nodeCount} 节点</span>
|
|
</div>
|
|
<div className="canvas-folder-dates">
|
|
<span><small>创建时间</small><time dateTime={project.createdAt}>{formatCanvasDate(project.createdAt)}</time></span>
|
|
<span><small>最后更新</small><time dateTime={project.updatedAt}>{formatCanvasDate(project.updatedAt)}</time></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<span className={cn("canvas-folder-select", selected && "is-visible")} onClick={(event) => event.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected}
|
|
onChange={(event) => toggleSelected(project.id, event.target.checked)}
|
|
aria-label={`选择 ${project.title}`}
|
|
/>
|
|
</span>
|
|
|
|
<div className="canvas-folder-actions" onClick={(event) => event.stopPropagation()}>
|
|
{!editing ? (
|
|
<button
|
|
type="button"
|
|
className="canvas-folder-rename"
|
|
aria-label={`重命名 ${project.title}`}
|
|
title="重命名"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
startEditing(project.id, project.title);
|
|
}}
|
|
>
|
|
<Pencil />
|
|
</button>
|
|
) : null}
|
|
|
|
<Dropdown
|
|
trigger={["click"]}
|
|
placement="bottomRight"
|
|
menu={{
|
|
onClick: ({ domEvent }) => domEvent.stopPropagation(),
|
|
items: [
|
|
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportProject() },
|
|
{ type: "divider" },
|
|
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
|
|
],
|
|
}}
|
|
>
|
|
<button type="button" className="canvas-folder-more" aria-label={`${project.title} 画布操作`} title="更多操作" onClick={(event) => event.stopPropagation()}>
|
|
<MoreHorizontal />
|
|
</button>
|
|
</Dropdown>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function formatCanvasDate(value: string) {
|
|
const timestamp = Date.parse(value);
|
|
return Number.isFinite(timestamp)
|
|
? new Date(timestamp).toLocaleString("zh-CN", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: "时间不可用";
|
|
}
|