新增图片画布项目页

新增 /project 项目页和我的页项目入口

补齐图片画布工程列表、重命名和删除 API

支持 /editor/canvas 按 projectid 加载指定工程

更新图片画布文档、TRACKING 和对应测试
This commit is contained in:
2026-06-14 00:11:36 +08:00
parent b2122481ff
commit 85834a423d
32 changed files with 1800 additions and 20 deletions
@@ -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,
@@ -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');
});
});
@@ -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="帮我们优化产品"
+287
View File
@@ -2973,6 +2973,293 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: #ffffff;
}
.project-gallery-stage-shell {
background: #ffffff;
}
.project-gallery {
position: relative;
display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
background: #ffffff;
color: #1f2937;
}
.project-gallery__header {
display: flex;
min-height: 4.5rem;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid #e5e7eb;
padding: 0 1.5rem;
}
.project-gallery__header h1 {
margin: 0;
color: #111827;
font-size: 1.25rem;
font-weight: 900;
}
.project-gallery__header span {
display: block;
margin-top: 0.2rem;
color: #6b7280;
font-size: 0.8rem;
font-weight: 700;
}
.project-gallery__header-actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.project-gallery__grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
align-content: start;
gap: 1rem;
overflow: auto;
padding: 1.5rem;
padding-bottom: 6.5rem;
}
.project-gallery__card {
position: relative;
min-width: 0;
}
.project-gallery__card-button,
.project-gallery__new-card {
display: grid;
width: 100%;
min-width: 0;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: #ffffff;
color: #111827;
text-align: left;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.project-gallery__card-button {
overflow: hidden;
}
.project-gallery__card-button:hover,
.project-gallery__card--selected .project-gallery__card-button,
.project-gallery__new-card:hover {
border-color: rgba(75, 181, 170, 0.58);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.1);
transform: translateY(-1px);
}
.project-gallery__preview {
position: relative;
display: grid;
aspect-ratio: 4 / 3;
overflow: hidden;
place-items: center;
background: #f3f5f8;
}
.project-gallery__preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.project-gallery__preview-empty {
width: 36%;
aspect-ratio: 1;
border-radius: 0.5rem;
background:
linear-gradient(135deg, rgba(75, 181, 170, 0.28), transparent 58%),
#e5e7eb;
}
.project-gallery__meta {
display: grid;
min-width: 0;
gap: 0.25rem;
padding: 0.8rem;
}
.project-gallery__meta span:first-child {
overflow: hidden;
color: #111827;
font-size: 0.94rem;
font-weight: 860;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-gallery__meta span + span {
color: #6b7280;
font-size: 0.76rem;
font-weight: 680;
}
.project-gallery__card-menu-wrap {
position: absolute;
right: 0.6rem;
bottom: 3.7rem;
z-index: 3;
opacity: 0;
transition: opacity 140ms ease;
}
.project-gallery__card:hover .project-gallery__card-menu-wrap,
.project-gallery__card-menu-wrap:focus-within {
opacity: 1;
}
.project-gallery__card-menu {
position: absolute;
right: 0;
bottom: 2.4rem;
display: grid;
min-width: 7rem;
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.project-gallery__card-menu button {
display: flex;
align-items: center;
gap: 0.45rem;
border: 0;
background: transparent;
padding: 0.65rem 0.75rem;
color: #111827;
font-size: 0.82rem;
font-weight: 760;
text-align: left;
}
.project-gallery__card-menu button:hover {
background: #f3f4f6;
}
.project-gallery__checkbox {
position: absolute;
top: 0.65rem;
right: 0.65rem;
display: grid;
width: 1.45rem;
height: 1.45rem;
place-items: center;
border: 1px solid rgba(15, 23, 42, 0.18);
border-radius: 0.35rem;
background: rgba(255, 255, 255, 0.92);
color: #238a82;
}
.project-gallery__batch-toolbar {
position: absolute;
left: 50%;
bottom: 1.1rem;
z-index: 12;
display: flex;
align-items: center;
gap: 0.6rem;
max-width: calc(100% - 2rem);
transform: translateX(-50%);
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.96);
padding: 0.55rem;
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.14);
}
.project-gallery__new-card {
place-items: center;
align-self: center;
justify-self: center;
width: min(22rem, calc(100% - 2rem));
min-height: 13rem;
margin: auto;
color: #238a82;
font-weight: 860;
text-align: center;
}
.project-gallery__error {
flex-shrink: 0;
margin: 1rem 1.5rem 0;
border: 1px solid #fecdd3;
border-radius: 0.5rem;
background: #fff1f2;
padding: 0.75rem 0.9rem;
color: #be123c;
font-size: 0.85rem;
font-weight: 760;
}
.project-gallery__modal-backdrop {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.18);
}
.project-gallery__rename-dialog {
display: grid;
width: min(24rem, calc(100% - 2rem));
gap: 0.9rem;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
background: #ffffff;
padding: 1rem;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
}
.project-gallery__rename-header,
.project-gallery__rename-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.project-gallery__rename-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 900;
}
.project-gallery__rename-dialog input {
min-height: 2.65rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 0 0.8rem;
color: #111827;
font-weight: 760;
outline: none;
}
.project-gallery__rename-dialog input:focus {
border-color: #4bb5aa;
box-shadow: 0 0 0 3px rgba(75, 181, 170, 0.16);
}
.image-canvas-editor {
position: relative;
display: flex;
+6
View File
@@ -31,6 +31,12 @@ describe('appPageRoutes', () => {
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor/canvas');
});
it('resolves the project route', () => {
expect(resolveSelectionStageFromPath('/project')).toBe('project');
expect(resolveSelectionStageFromPath('/PROJECT/')).toBe('project');
expect(resolvePathForSelectionStage('project')).toBe('/project');
});
it('resolves jump-hop creation, gallery and runtime routes', () => {
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
'jump-hop-workspace',
+1
View File
@@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['project', '/project'],
['image-editor', '/editor/canvas'],
['profile-feedback', '/profile/feedback'],
['work-detail', '/works/detail'],
@@ -3,9 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createEditorProject,
createEditorProjectResource,
deleteEditorProject,
editEditorImage,
generateEditorImage,
listEditorProjects,
loadEditorProject,
loadOrCreateRecentEditorProject,
renameEditorProject,
saveEditorProjectLayout,
} from './editorProjectClient';
@@ -139,6 +143,121 @@ describe('editorProjectClient', () => {
);
});
it('lists editor projects from the project API', async () => {
requestJsonMock.mockResolvedValueOnce({
projects: [
{
projectId: 'editor-project-1',
title: '角色设定板',
canvas: {
canvasId: 'editor-project-1:canvas:default',
projectId: 'editor-project-1',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
],
});
const projects = await listEditorProjects();
expect(projects).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/projects',
{ method: 'GET' },
'读取图片画布工程列表失败',
expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
);
});
it('loads an explicit project by id', async () => {
requestJsonMock.mockResolvedValueOnce({
project: {
projectId: 'editor-project-1',
title: '角色设定板',
canvas: {
canvasId: 'editor-project-1:canvas:default',
projectId: 'editor-project-1',
title: '默认画布',
viewport: { x: 8, y: 9, scale: 1.5 },
layers: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 8, y: 9, scale: 1.5 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
});
const project = await loadEditorProject('editor-project-1');
expect(project.viewport.scale).toBe(1.5);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/projects/editor-project-1',
{ method: 'GET' },
'读取图片画布工程失败',
expect.objectContaining({
clearAuthOnUnauthorized: false,
notifyAuthStateChange: false,
}),
);
});
it('renames and deletes an editor project', async () => {
requestJsonMock
.mockResolvedValueOnce({
project: {
projectId: 'editor-project-1',
title: '新标题',
canvas: {
canvasId: 'editor-project-1:canvas:default',
projectId: 'editor-project-1',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
},
})
.mockResolvedValueOnce({ deletedProjectId: 'editor-project-1' });
await renameEditorProject('editor-project-1', '新标题');
await deleteEditorProject('editor-project-1');
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/editor/projects/editor-project-1/metadata',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '新标题' }),
}),
'重命名图片画布工程失败',
expect.any(Object),
);
expect(requestJsonMock).toHaveBeenNthCalledWith(
2,
'/api/editor/projects/editor-project-1',
{ method: 'DELETE' },
'删除图片画布工程失败',
expect.any(Object),
);
});
it('creates a resource with upload or generated metadata', async () => {
requestJsonMock.mockResolvedValueOnce({
resource: {
@@ -131,6 +131,16 @@ function jsonRequest(method: 'POST' | 'PATCH', body: Record<string, unknown>) {
};
}
export async function listEditorProjects() {
const response = await requestJson<{ projects: EditorProjectSnapshot[] }>(
EDITOR_PROJECT_API_BASE,
{ method: 'GET' },
'读取图片画布工程列表失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.projects;
}
export async function loadRecentEditorProject() {
return requestJson<EditorProjectRecentResponse>(
`${EDITOR_PROJECT_API_BASE}/recent`,
@@ -158,6 +168,36 @@ export async function loadOrCreateRecentEditorProject() {
return createEditorProject({ title: DEFAULT_PROJECT_TITLE });
}
export async function loadEditorProject(projectId: string) {
const response = await requestJson<EditorProjectResponse>(
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
{ method: 'GET' },
'读取图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.project;
}
export async function renameEditorProject(projectId: string, title: string) {
const response = await requestJson<EditorProjectResponse>(
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}/metadata`,
jsonRequest('PATCH', { title }),
'重命名图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.project;
}
export async function deleteEditorProject(projectId: string) {
const response = await requestJson<{ deletedProjectId: string }>(
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
{ method: 'DELETE' },
'删除图片画布工程失败',
EDITOR_PROJECT_REQUEST_OPTIONS,
);
return response.deletedProjectId;
}
export async function saveEditorProjectLayout(
projectId: string,
input: EditorProjectLayoutSaveInput,