新增图片画布项目页
新增 /project 项目页和我的页项目入口 补齐图片画布工程列表、重命名和删除 API 支持 /editor/canvas 按 projectid 加载指定工程 更新图片画布文档、TRACKING 和对应测试
This commit is contained in:
@@ -2,13 +2,15 @@
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||
|
||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
@@ -18,6 +20,8 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
...actual,
|
||||
editEditorImage: editEditorImageMock,
|
||||
generateEditorImage: generateEditorImageMock,
|
||||
loadEditorProject: loadEditorProjectMock,
|
||||
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,10 +40,47 @@ function dispatchPointerEvent(
|
||||
}
|
||||
|
||||
describe('ImageCanvasEditorView', () => {
|
||||
beforeEach(() => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
generateEditorImageMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
loadEditorProjectMock.mockReset();
|
||||
loadOrCreateRecentEditorProjectMock.mockReset();
|
||||
window.history.replaceState(null, '', '/editor/canvas');
|
||||
});
|
||||
|
||||
it('loads the project from projectid query before falling back to recent project', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-query',
|
||||
title: '查询项目',
|
||||
viewport: { x: 12, y: 16, scale: 0.8 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/editor/canvas?projectid=editor-project-query',
|
||||
);
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-query');
|
||||
});
|
||||
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles the shared sidebar from canvas panel buttons', () => {
|
||||
@@ -196,6 +237,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
value: createObjectUrlSpy,
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||||
|
||||
@@ -344,11 +388,15 @@ describe('ImageCanvasEditorView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||||
});
|
||||
const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!;
|
||||
const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||||
expect(anchoredGenerateDialog).toBeTruthy();
|
||||
expect(
|
||||
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
||||
).toBeCloseTo(initialComposerTop, 1);
|
||||
).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top));
|
||||
expect(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||||
).toBeLessThan(initialComposerTop);
|
||||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||
const metadataButtons = screen.getAllByRole('button', {
|
||||
name: /查看生成图片 .*元数据/,
|
||||
@@ -369,6 +417,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
taskId: 'editor-drag-frame-1',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||||
const initialComposerTop = Number.parseFloat(
|
||||
@@ -409,10 +460,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(anchoredGenerateDialog).toBeTruthy();
|
||||
expect(
|
||||
Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top),
|
||||
).toBeCloseTo(draggedComposerTop, 1);
|
||||
).toBeGreaterThan(Number.parseFloat((generatedLayer as HTMLElement).style.top));
|
||||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(700);
|
||||
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(150);
|
||||
expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(300);
|
||||
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180);
|
||||
});
|
||||
|
||||
it('keeps the generation composer when selecting another image', () => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type EditorImageGenerationResult,
|
||||
type EditorProjectLayerSnapshot,
|
||||
generateEditorImage,
|
||||
loadEditorProject,
|
||||
loadOrCreateRecentEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
@@ -639,7 +640,16 @@ export function ImageCanvasEditorView() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadOrCreateRecentEditorProject()
|
||||
const projectIdFromQuery =
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: new URLSearchParams(window.location.search).get('projectid')?.trim() ||
|
||||
null;
|
||||
const loadProject = projectIdFromQuery
|
||||
? loadEditorProject(projectIdFromQuery)
|
||||
: loadOrCreateRecentEditorProject();
|
||||
|
||||
loadProject
|
||||
.then((project) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
|
||||
@@ -1278,6 +1278,13 @@ const ImageCanvasEditorView = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const ProjectGalleryView = lazy(async () => {
|
||||
const module = await import('../project/ProjectGalleryView');
|
||||
return {
|
||||
default: module.ProjectGalleryView,
|
||||
};
|
||||
});
|
||||
|
||||
const UnifiedCreationWorkspace = lazy(async () => {
|
||||
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
||||
return {
|
||||
@@ -15162,6 +15169,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'project' && (
|
||||
<motion.div
|
||||
key="project-gallery"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="project-gallery-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载项目..." />}>
|
||||
<ProjectGalleryView
|
||||
onOpenProject={(projectId) => {
|
||||
setSelectionStage('image-editor');
|
||||
pushAppHistoryPath(
|
||||
`/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'platform' && (
|
||||
<motion.div
|
||||
key="platform-home"
|
||||
@@ -15274,6 +15301,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenFeedback={openProfileFeedback}
|
||||
onOpenProjects={() => setSelectionStage('project')}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'project'
|
||||
| 'image-editor'
|
||||
| 'profile-feedback'
|
||||
| 'work-detail'
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
project: true,
|
||||
'image-editor': true,
|
||||
'profile-feedback': false,
|
||||
'work-detail': true,
|
||||
|
||||
125
src/components/project/ProjectGalleryView.test.tsx
Normal file
125
src/components/project/ProjectGalleryView.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProjectGalleryView } from './ProjectGalleryView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
renameEditorProject: renameEditorProjectMock,
|
||||
deleteEditorProject: deleteEditorProjectMock,
|
||||
}));
|
||||
|
||||
const projectItems = [
|
||||
{
|
||||
projectId: 'editor-project-1',
|
||||
title: '角色设定板',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [{ layerId: 'layer-1', resourceId: 'resource-1' }],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-1',
|
||||
projectId: 'editor-project-1',
|
||||
imageSrc: 'data:image/png;base64,one',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T08:00:00.000Z',
|
||||
},
|
||||
{
|
||||
projectId: 'editor-project-2',
|
||||
title: '场景草图',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T07:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('ProjectGalleryView', () => {
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
renameEditorProjectMock.mockReset();
|
||||
deleteEditorProjectMock.mockReset();
|
||||
});
|
||||
|
||||
it('opens a project from the gallery card', async () => {
|
||||
const onOpenProject = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectGalleryView onOpenProject={onOpenProject} />);
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
||||
|
||||
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
|
||||
});
|
||||
|
||||
it('renames and deletes a project from the hover menu', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
renameEditorProjectMock.mockResolvedValueOnce({
|
||||
...projectItems[0],
|
||||
title: '新角色设定板',
|
||||
});
|
||||
deleteEditorProjectMock.mockResolvedValueOnce('editor-project-2');
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: /重命名/u }));
|
||||
await user.clear(screen.getByLabelText('项目名称'));
|
||||
await user.type(screen.getByLabelText('项目名称'), '新角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
expect(renameEditorProjectMock).toHaveBeenCalledWith(
|
||||
'editor-project-1',
|
||||
'新角色设定板',
|
||||
);
|
||||
await screen.findByText('新角色设定板');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开项目场景草图菜单' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: /删除/u }));
|
||||
|
||||
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('场景草图')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('supports batch selection actions from the bottom toolbar', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
deleteEditorProjectMock.mockResolvedValue('deleted');
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '选择' }));
|
||||
const toolbar = screen.getByRole('toolbar', { name: '批量操作' });
|
||||
|
||||
await user.click(within(toolbar).getByRole('button', { name: '全选' }));
|
||||
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '取消全选 · 已选 2' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
|
||||
|
||||
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
|
||||
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
|
||||
});
|
||||
});
|
||||
415
src/components/project/ProjectGalleryView.tsx
Normal file
415
src/components/project/ProjectGalleryView.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} 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 { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
|
||||
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 ? (
|
||||
<div className="project-gallery__card-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() =>
|
||||
setRenameDraft({
|
||||
projectId: project.projectId,
|
||||
title: project.title,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span>重命名</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => deleteProjects([project.projectId])}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
) : 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>
|
||||
)}
|
||||
|
||||
{renameDraft ? (
|
||||
<div className="project-gallery__modal-backdrop" role="presentation">
|
||||
<form
|
||||
className="project-gallery__rename-dialog"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitRename();
|
||||
}}
|
||||
>
|
||||
<div className="project-gallery__rename-header">
|
||||
<h2>重命名</h2>
|
||||
<PlatformIconButton
|
||||
label="关闭重命名"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
variant="surfaceFloating"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setRenameDraft(null)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
aria-label="项目名称"
|
||||
value={renameDraft.title}
|
||||
onChange={(event) =>
|
||||
setRenameDraft((currentDraft) =>
|
||||
currentDraft
|
||||
? { ...currentDraft, title: event.target.value }
|
||||
: currentDraft,
|
||||
)
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="project-gallery__rename-actions">
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={() => setRenameDraft(null)}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton type="submit">保存</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Crown,
|
||||
FolderKanban,
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
@@ -272,6 +273,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onCloseProfilePlayStats?: () => void;
|
||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onOpenFeedback?: () => void;
|
||||
onOpenProjects?: () => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileTaskRefreshKey?: number;
|
||||
createTabContent?: ReactNode;
|
||||
@@ -2566,6 +2568,7 @@ export function RpgEntryHomeView({
|
||||
onCloseProfilePlayStats,
|
||||
onOpenPlayedWork,
|
||||
onOpenFeedback,
|
||||
onOpenProjects,
|
||||
onRechargeSuccess,
|
||||
profileTaskRefreshKey = 0,
|
||||
createTabContent,
|
||||
@@ -4361,6 +4364,12 @@ export function RpgEntryHomeView({
|
||||
imageSrc={profileCommunityImage}
|
||||
onClick={() => openProfilePopupPanel('community')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="项目"
|
||||
subLabel="画布项目"
|
||||
icon={FolderKanban}
|
||||
onClick={onOpenProjects}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="反馈与建议"
|
||||
subLabel="帮我们优化产品"
|
||||
|
||||
Reference in New Issue
Block a user