304d6806f0
新增 PlatformFloatingMenu 与菜单项测试 项目卡片更多菜单复用平台浮层菜单 更新 TRACKING 记录组件收口验证
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import {
|
|
Check,
|
|
CheckSquare,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Plus,
|
|
Square,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
createEditorProject,
|
|
deleteEditorProject,
|
|
listEditorProjects,
|
|
renameEditorProject,
|
|
type EditorProjectSnapshot,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
|
import {
|
|
PlatformFloatingMenu,
|
|
PlatformFloatingMenuItem,
|
|
} from '../common/PlatformFloatingMenu';
|
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
|
import { PlatformTextField } from '../common/PlatformTextField';
|
|
import { UnifiedModal } from '../common/UnifiedModal';
|
|
|
|
type ProjectGalleryViewProps = {
|
|
onOpenProject: (projectId: string) => void;
|
|
};
|
|
|
|
type RenameDraft = {
|
|
projectId: string;
|
|
title: string;
|
|
};
|
|
|
|
function resolveProjectPreview(project: EditorProjectSnapshot) {
|
|
const layerResourceIds = new Set(
|
|
project.layers
|
|
.map((layer) => layer.resourceId)
|
|
.filter((resourceId) => resourceId.trim().length > 0),
|
|
);
|
|
return (
|
|
project.resources.find((resource) => layerResourceIds.has(resource.resourceId)) ??
|
|
project.resources[0] ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function formatProjectUpdatedAt(value: string) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
|
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [activeMenuProjectId, setActiveMenuProjectId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [renameDraft, setRenameDraft] = useState<RenameDraft | null>(null);
|
|
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
|
const selectedCount = selectedProjectIds.size;
|
|
const allSelected =
|
|
projects.length > 0 && selectedProjectIds.size === projects.length;
|
|
|
|
const refreshProjects = useCallback(() => {
|
|
setIsLoading(true);
|
|
setErrorMessage(null);
|
|
listEditorProjects()
|
|
.then((items) => {
|
|
setProjects(items);
|
|
setSelectedProjectIds((currentIds) => {
|
|
const availableIds = new Set(items.map((project) => project.projectId));
|
|
return new Set(
|
|
[...currentIds].filter((projectId) => availableIds.has(projectId)),
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '读取项目列表失败',
|
|
);
|
|
})
|
|
.finally(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshProjects();
|
|
}, [refreshProjects]);
|
|
|
|
const createProject = useCallback(() => {
|
|
setErrorMessage(null);
|
|
createEditorProject()
|
|
.then((project) => onOpenProject(project.projectId))
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '创建项目失败',
|
|
);
|
|
});
|
|
}, [onOpenProject]);
|
|
|
|
const closeSelectionMode = useCallback(() => {
|
|
setIsSelectionMode(false);
|
|
setSelectedProjectIds(new Set());
|
|
}, []);
|
|
|
|
const toggleProjectSelection = useCallback((projectId: string) => {
|
|
setSelectedProjectIds((currentIds) => {
|
|
const nextIds = new Set(currentIds);
|
|
if (nextIds.has(projectId)) {
|
|
nextIds.delete(projectId);
|
|
} else {
|
|
nextIds.add(projectId);
|
|
}
|
|
return nextIds;
|
|
});
|
|
}, []);
|
|
|
|
const deleteProjects = useCallback(
|
|
(projectIds: string[]) => {
|
|
if (projectIds.length === 0) {
|
|
return;
|
|
}
|
|
setErrorMessage(null);
|
|
void Promise.all(projectIds.map((projectId) => deleteEditorProject(projectId)))
|
|
.then(() => {
|
|
setProjects((currentProjects) =>
|
|
currentProjects.filter(
|
|
(project) => !projectIds.includes(project.projectId),
|
|
),
|
|
);
|
|
setSelectedProjectIds((currentIds) => {
|
|
const nextIds = new Set(currentIds);
|
|
projectIds.forEach((projectId) => nextIds.delete(projectId));
|
|
return nextIds;
|
|
});
|
|
setActiveMenuProjectId(null);
|
|
})
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '删除项目失败',
|
|
);
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const submitRename = useCallback(() => {
|
|
if (!renameDraft) {
|
|
return;
|
|
}
|
|
const title = renameDraft.title.trim();
|
|
if (!title) {
|
|
return;
|
|
}
|
|
setErrorMessage(null);
|
|
renameEditorProject(renameDraft.projectId, title)
|
|
.then((project) => {
|
|
setProjects((currentProjects) =>
|
|
currentProjects.map((item) =>
|
|
item.projectId === project.projectId ? project : item,
|
|
),
|
|
);
|
|
setRenameDraft(null);
|
|
setActiveMenuProjectId(null);
|
|
})
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '重命名项目失败',
|
|
);
|
|
});
|
|
}, [renameDraft]);
|
|
|
|
const projectCards = useMemo(
|
|
() =>
|
|
projects.map((project) => {
|
|
const preview = resolveProjectPreview(project);
|
|
const selected = selectedProjectIds.has(project.projectId);
|
|
return (
|
|
<article
|
|
key={project.projectId}
|
|
className={[
|
|
'project-gallery__card',
|
|
selected ? 'project-gallery__card--selected' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="project-gallery__card-button"
|
|
onClick={() => {
|
|
if (isSelectionMode) {
|
|
toggleProjectSelection(project.projectId);
|
|
return;
|
|
}
|
|
onOpenProject(project.projectId);
|
|
}}
|
|
aria-label={`打开项目${project.title}`}
|
|
>
|
|
<span className="project-gallery__preview">
|
|
{preview ? (
|
|
<img src={preview.imageSrc} alt="" />
|
|
) : (
|
|
<span className="project-gallery__preview-empty" />
|
|
)}
|
|
{isSelectionMode ? (
|
|
<span className="project-gallery__checkbox">
|
|
{selected ? <Check className="h-4 w-4" /> : null}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="project-gallery__meta">
|
|
<span>{project.title}</span>
|
|
<span>{formatProjectUpdatedAt(project.updatedAt)}</span>
|
|
</span>
|
|
</button>
|
|
{!isSelectionMode ? (
|
|
<div className="project-gallery__card-menu-wrap">
|
|
<PlatformIconButton
|
|
label={`打开项目${project.title}菜单`}
|
|
icon={<MoreHorizontal className="h-4 w-4" />}
|
|
variant="surfaceFloating"
|
|
className="h-8 w-8"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setActiveMenuProjectId((currentProjectId) =>
|
|
currentProjectId === project.projectId
|
|
? null
|
|
: project.projectId,
|
|
);
|
|
}}
|
|
/>
|
|
{activeMenuProjectId === project.projectId ? (
|
|
<PlatformFloatingMenu>
|
|
<PlatformFloatingMenuItem
|
|
icon={<Pencil className="h-4 w-4" />}
|
|
onClick={() =>
|
|
setRenameDraft({
|
|
projectId: project.projectId,
|
|
title: project.title,
|
|
})
|
|
}
|
|
>
|
|
重命名
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
onClick={() => deleteProjects([project.projectId])}
|
|
>
|
|
删除
|
|
</PlatformFloatingMenuItem>
|
|
</PlatformFloatingMenu>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}),
|
|
[
|
|
activeMenuProjectId,
|
|
deleteProjects,
|
|
isSelectionMode,
|
|
onOpenProject,
|
|
projects,
|
|
selectedProjectIds,
|
|
toggleProjectSelection,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<main className="project-gallery" aria-label="项目">
|
|
<header className="project-gallery__header">
|
|
<div>
|
|
<h1>项目</h1>
|
|
<span>{projects.length} 个画布项目</span>
|
|
</div>
|
|
<div className="project-gallery__header-actions">
|
|
<PlatformActionButton
|
|
tone="secondary"
|
|
size="sm"
|
|
onClick={() => setIsSelectionMode(true)}
|
|
disabled={projects.length === 0}
|
|
>
|
|
选择
|
|
</PlatformActionButton>
|
|
<PlatformActionButton size="sm" onClick={createProject}>
|
|
<Plus className="h-4 w-4" />
|
|
新建
|
|
</PlatformActionButton>
|
|
</div>
|
|
</header>
|
|
|
|
{errorMessage ? (
|
|
<div className="project-gallery__error" role="alert">
|
|
{errorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<PlatformEmptyState surface="subpanel" size="panel">
|
|
正在读取项目
|
|
</PlatformEmptyState>
|
|
) : projects.length === 0 ? (
|
|
<button
|
|
type="button"
|
|
className="project-gallery__new-card"
|
|
onClick={createProject}
|
|
>
|
|
<Plus className="h-6 w-6" />
|
|
<span>新建项目</span>
|
|
</button>
|
|
) : (
|
|
<section className="project-gallery__grid">{projectCards}</section>
|
|
)}
|
|
|
|
<UnifiedModal
|
|
open={Boolean(renameDraft)}
|
|
title="重命名"
|
|
size="sm"
|
|
closeLabel="关闭重命名"
|
|
onClose={() => setRenameDraft(null)}
|
|
footer={
|
|
<>
|
|
<PlatformActionButton
|
|
type="button"
|
|
tone="secondary"
|
|
onClick={() => setRenameDraft(null)}
|
|
>
|
|
取消
|
|
</PlatformActionButton>
|
|
<PlatformActionButton type="submit" form="project-gallery-rename-form">
|
|
保存
|
|
</PlatformActionButton>
|
|
</>
|
|
}
|
|
>
|
|
{renameDraft ? (
|
|
<form
|
|
id="project-gallery-rename-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
submitRename();
|
|
}}
|
|
>
|
|
<PlatformTextField
|
|
aria-label="项目名称"
|
|
value={renameDraft.title}
|
|
onChange={(event) =>
|
|
setRenameDraft((currentDraft) =>
|
|
currentDraft
|
|
? { ...currentDraft, title: event.target.value }
|
|
: currentDraft,
|
|
)
|
|
}
|
|
autoFocus
|
|
/>
|
|
</form>
|
|
) : null}
|
|
</UnifiedModal>
|
|
|
|
{isSelectionMode ? (
|
|
<div className="project-gallery__batch-toolbar" role="toolbar" aria-label="批量操作">
|
|
<PlatformActionButton
|
|
tone="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedProjectIds(
|
|
allSelected
|
|
? new Set()
|
|
: new Set(projects.map((project) => project.projectId)),
|
|
);
|
|
}}
|
|
>
|
|
{allSelected ? (
|
|
<CheckSquare className="h-4 w-4" />
|
|
) : (
|
|
<Square className="h-4 w-4" />
|
|
)}
|
|
{selectedCount > 0
|
|
? `${allSelected ? '取消全选' : '全选'} · 已选 ${selectedCount}`
|
|
: '全选'}
|
|
</PlatformActionButton>
|
|
<PlatformActionButton
|
|
tone="warning"
|
|
size="sm"
|
|
disabled={selectedCount === 0}
|
|
onClick={() => deleteProjects([...selectedProjectIds])}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
删除
|
|
</PlatformActionButton>
|
|
<PlatformActionButton tone="secondary" size="sm" onClick={closeSelectionMode}>
|
|
取消
|
|
</PlatformActionButton>
|
|
</div>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default ProjectGalleryView;
|