Files
kdletters ee3948b491 合并旧创作模板退役分支
合并 codex/retire-legacy-creation,退役旧创作模板业务并保留历史数据壳
保留 master 最新编辑器 Agent、画布和个人页能力
补齐现役编辑器 Agent 的 LLM 与生成结果读取链路
同步 Vite、ESLint、Rust workspace、SpacetimeDB 与文档边界
2026-07-20 20:06:26 +08:00

549 lines
17 KiB
TypeScript

import {
Check,
CheckSquare,
MoreHorizontal,
Pencil,
Plus,
Square,
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
createEditorProject,
deleteEditorProject,
type EditorProjectSnapshot,
listEditorProjects,
renameEditorProject,
} from '../../services/image-editor/editorProjectClient';
import { readEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformToolModalShell } from '../common/PlatformToolModalShell';
import {
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
resolveProjectCoverResource,
} from './ProjectCanvasCover';
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
searchKeyword?: string;
};
type RenameDraft = {
projectId: string;
title: string;
};
type ProjectCoverObjectUrl = {
projectId: string;
url: string;
updatedAt: string;
};
function isUnauthorizedError(error: unknown) {
return error instanceof ApiClientError && error.status === 401;
}
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,
searchKeyword = '',
}: ProjectGalleryViewProps) {
const authUi = useAuthUi();
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 [localCoverObjectUrls, setLocalCoverObjectUrls] = useState<
ProjectCoverObjectUrl[]
>([]);
const localCoverObjectUrlsRef = useRef<ProjectCoverObjectUrl[]>([]);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const selectedCount = selectedProjectIds.size;
const allSelected =
projects.length > 0 && selectedProjectIds.size === projects.length;
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
const visibleProjects = useMemo(() => {
if (!normalizedSearchKeyword) {
return projects;
}
return projects.filter((project) =>
project.title.toLocaleLowerCase().includes(normalizedSearchKeyword),
);
}, [normalizedSearchKeyword, projects]);
const localCoverUrlByProjectId = useMemo(
() =>
new Map(
localCoverObjectUrls.map((cover) => [cover.projectId, cover] as const),
),
[localCoverObjectUrls],
);
const replaceLocalCoverObjectUrls = useCallback(
(nextCoverObjectUrls: ProjectCoverObjectUrl[]) => {
localCoverObjectUrlsRef.current.forEach((cover) =>
URL.revokeObjectURL(cover.url),
);
localCoverObjectUrlsRef.current = nextCoverObjectUrls;
setLocalCoverObjectUrls(nextCoverObjectUrls);
},
[],
);
const refreshProjects = useCallback(() => {
setIsLoading(true);
setErrorMessage(null);
listEditorProjects()
.then((items) => {
setProjects(items);
void readEditorProjectCoverCache(
items.map((project) => project.projectId),
)
.then((cachedCovers) => {
const nextCoverObjectUrls = [...cachedCovers.values()].map(
(cover) => ({
projectId: cover.projectId,
url: URL.createObjectURL(cover.blob),
updatedAt: cover.updatedAt,
}),
);
replaceLocalCoverObjectUrls(nextCoverObjectUrls);
})
.catch(() => {
replaceLocalCoverObjectUrls([]);
});
setSelectedProjectIds((currentIds) => {
const availableIds = new Set(
items.map((project) => project.projectId),
);
return new Set(
[...currentIds].filter((projectId) => availableIds.has(projectId)),
);
});
})
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(refreshProjects);
return;
}
setErrorMessage(
error instanceof Error ? error.message : '读取项目列表失败',
);
})
.finally(() => setIsLoading(false));
}, [authUi, replaceLocalCoverObjectUrls]);
useEffect(() => {
return () => {
localCoverObjectUrlsRef.current.forEach((cover) =>
URL.revokeObjectURL(cover.url),
);
localCoverObjectUrlsRef.current = [];
};
}, []);
useEffect(() => {
refreshProjects();
}, [refreshProjects]);
const createProject = useCallback(() => {
if (authUi && !authUi.user) {
authUi.openLoginModal(createProject);
return;
}
setErrorMessage(null);
createEditorProject()
.then((project) => onOpenProject(project.projectId, { guide: true }))
.catch((error: unknown) => {
setErrorMessage(
error instanceof Error ? error.message : '创建项目失败',
);
});
}, [authUi, 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(
() =>
visibleProjects.map((project) => {
const selected = selectedProjectIds.has(project.projectId);
const localCover = localCoverUrlByProjectId.get(project.projectId);
const projectWithLocalCover = localCover
? {
...project,
resources: [
...project.resources,
{
resourceId: `local-cover-cache:${project.projectId}`,
projectId: project.projectId,
imageSrc: localCover.url,
width: 320,
height: 240,
sourceType: 'uploaded' as const,
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
updatedAt: localCover.updatedAt,
},
],
}
: project;
const coverResource = resolveProjectCoverResource(
projectWithLocalCover,
);
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;
}
if (authUi) {
authUi.requireAuth(() => onOpenProject(project.projectId));
return;
}
onOpenProject(project.projectId);
}}
aria-label={`打开项目${project.title}`}
>
<PlatformMediaFrame
src={coverResource?.imageSrc ?? null}
objectKey={coverResource?.objectKey ?? null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
refreshKey={
coverResource?.updatedAt ??
coverResource?.createdAt ??
coverResource?.resourceId ??
null
}
className="project-gallery__preview"
>
{isSelectionMode ? (
<span className="project-gallery__checkbox">
{selected ? <Check className="h-4 w-4" /> : null}
</span>
) : null}
</PlatformMediaFrame>
<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,
authUi,
deleteProjects,
isSelectionMode,
localCoverUrlByProjectId,
onOpenProject,
selectedProjectIds,
toggleProjectSelection,
visibleProjects,
],
);
return (
<main className="project-gallery" aria-label="项目">
<header className="project-gallery__header">
<div className="project-gallery__heading">
<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 ? (
<PlatformStatusMessage
tone="error"
surface="platform"
className="project-gallery__error"
role="alert"
>
{errorMessage}
</PlatformStatusMessage>
) : null}
{isLoading ? (
<PlatformEmptyState surface="subpanel" size="panel">
正在读取项目
</PlatformEmptyState>
) : visibleProjects.length === 0 && normalizedSearchKeyword ? (
<PlatformEmptyState surface="subpanel" size="panel">
没有匹配项目
</PlatformEmptyState>
) : projects.length === 0 ? (
<PlatformEmptyState
asChild="button"
surface="subpanel"
size="panel"
className="project-gallery__new-card"
onClick={createProject}
>
<Plus className="h-6 w-6" />
<span>新建项目</span>
</PlatformEmptyState>
) : (
<section className="project-gallery__grid">{projectCards}</section>
)}
<PlatformToolModalShell
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}
</PlatformToolModalShell>
{isSelectionMode ? (
<PlatformBatchActionToolbar>
<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>
</PlatformBatchActionToolbar>
) : null}
</main>
);
}
export default ProjectGalleryView;