Merge remote-tracking branch 'origin/master' into fix/multi-select
Project CI / Repository checks (pull_request) Successful in 56s
Project CI / Backend tests (pull_request) Successful in 3m46s
Project CI / Frontend tests (pull_request) Successful in 3m1s
Project CI / Native shell tests (pull_request) Failing after 5m15s

This commit is contained in:
2026-08-03 15:59:57 +08:00
66 changed files with 7405 additions and 1288 deletions
@@ -428,6 +428,24 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
],
layers: [
...projectLayers,
{
itemType: 'generation-dialog',
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
dialog: {
id: canvasCompletion.dialogId,
mode: dialogMode,
prompt,
status: 'idle',
composerOpen: false,
generatedLayerId: layerId,
placeholder,
imageModel: model,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
},
},
{
layerId,
resourceId,
@@ -450,24 +468,6 @@ describe('ImageCanvasEditorView generation integration', () => {
assetKind: input.assetKind ?? undefined,
generationInputs: input.generationInputs,
},
{
itemType: 'generation-dialog',
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
dialog: {
id: canvasCompletion.dialogId,
mode: dialogMode,
prompt,
status: 'idle',
composerOpen: true,
generatedLayerId: layerId,
placeholder,
imageModel: model,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
},
},
...projectLayers,
],
updatedAt: '2026-06-19T00:00:00.000Z',
},
@@ -887,10 +887,7 @@ describe('ImageCanvasEditorView generation integration', () => {
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
expect(generatedLayer).toBeTruthy();
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(
Number.isFinite(
Number.parseFloat((generatedLayer as HTMLElement).style.top),
@@ -978,10 +975,7 @@ describe('ImageCanvasEditorView generation integration', () => {
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
@@ -1522,7 +1516,28 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
expectedRevision: 1,
layers: expect.arrayContaining([
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
mode: 'spec',
status: 'idle',
generatedLayerId: 'layer-editor-spec-role-1',
specValues: expect.objectContaining({
playSetting: '平台跳跃玩法',
artStyle: '低多边形卡通',
bodyRatio: '4',
characterView: '左向三分之二侧身站姿',
}),
}),
}),
]),
}),
);
});
it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
@@ -2094,10 +2109,11 @@ describe('ImageCanvasEditorView generation integration', () => {
const generatedImage = await screen.findByAltText(/画布图片:生成图片/u);
const generatedLayerButton = generatedImage.closest('button')!;
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
fireEvent.click(generatedLayerButton);
expect(generatedLayerButton.className).toContain(
'image-canvas-editor__layer--selected',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
@@ -3652,7 +3668,7 @@ describe('ImageCanvasEditorView generation integration', () => {
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
const metadataCornerButton = screen.getAllByRole('button', {
name: /查看生成图片 .*图片信息/,
@@ -1158,6 +1158,8 @@ export function ImageCanvasEditorView({
viewportRef,
canvasGenerationDialogsRef,
canvasBackgroundColorRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[],
);
@@ -1167,6 +1169,8 @@ export function ImageCanvasEditorView({
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter: (value: number) => {
layerCounterRef.current = value;
@@ -1176,6 +1180,8 @@ export function ImageCanvasEditorView({
}),
[
applyCanvasBackgroundColor,
setSelectedLayerId,
setSelectedLayerIds,
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayers,
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ import { ApiClientError } from '../../services/apiClient';
import { uploadEditorMediaAssetObjectFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import {
createEditorProjectResource,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
@@ -42,6 +43,10 @@ import {
type ProjectCoverSnapshotViewportSize,
} from './ImageCanvasProjectCoverSnapshotModel';
import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer';
import {
firstSelectedLayerId,
normalizeCanvasSelectionIds,
} from './ImageCanvasSelectionModel';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
@@ -75,6 +80,12 @@ type PendingProjectLayoutSave = {
transportRetries?: number;
};
type ActiveProjectLayoutSaveAttempt = {
save: PendingProjectLayoutSave;
authorityEpoch: number;
ownerUserId: string | null | undefined;
};
type CachedEditorProjectSnapshot = {
project: EditorProjectSnapshot;
cachedAt: number;
@@ -87,11 +98,110 @@ type ApplyProjectSnapshotOptions = {
allowProjectSwitch?: boolean;
};
function canvasLayoutItemId(item: EditorProjectLayerSnapshot) {
return typeof item.layerId === 'string' ? item.layerId : null;
}
function mergePendingCanvasLayerLayout(
authoritative: EditorProjectLayerSnapshot,
pending: EditorProjectLayerSnapshot,
): EditorProjectLayerSnapshot {
if (pending.itemType === 'canvas-settings') {
return pending;
}
if (
authoritative.itemType === 'generation-dialog' &&
pending.itemType === 'generation-dialog'
) {
const authoritativeDialog = (
authoritative as { dialog?: CanvasGenerationDialogState }
).dialog;
const pendingDialog = (pending as { dialog?: CanvasGenerationDialogState })
.dialog;
if (authoritativeDialog && pendingDialog) {
return {
...authoritative,
dialog: {
...authoritativeDialog,
...pendingDialog,
status: authoritativeDialog.status,
generatedLayerId: authoritativeDialog.generatedLayerId,
errorMessage: authoritativeDialog.errorMessage,
generationStartedAt: authoritativeDialog.generationStartedAt,
generationFinishedAt: authoritativeDialog.generationFinishedAt,
characterAnimationResult:
authoritativeDialog.characterAnimationResult,
composerOpen: authoritativeDialog.composerOpen,
},
};
}
return pending;
}
return {
...authoritative,
// pendingItems 来自 serializeCanvasLayout,这些布局字段始终存在;直接
// 覆盖才能保留 false / undefined 代表的解锁、取消分组和取消翻转。
title: pending.title,
x: pending.x,
y: pending.y,
width: pending.width,
height: pending.height,
originalWidth: pending.originalWidth,
originalHeight: pending.originalHeight,
zIndex: pending.zIndex,
groupId: pending.groupId,
hidden: pending.hidden,
locked: pending.locked,
flipX: pending.flipX,
flipY: pending.flipY,
};
}
export function mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
authoritativeItems,
pendingItems,
previousAuthoritativeItemIds,
}: {
authoritativeItems: EditorProjectLayerSnapshot[];
pendingItems: EditorProjectLayerSnapshot[];
previousAuthoritativeItemIds: ReadonlySet<string>;
}) {
const authoritativeById = new Map(
authoritativeItems.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [[id, item] as const] : [];
}),
);
const pendingIds = new Set<string>();
const merged = pendingItems.flatMap((pendingItem) => {
const id = canvasLayoutItemId(pendingItem);
if (!id) {
return [pendingItem];
}
pendingIds.add(id);
const authoritativeItem = authoritativeById.get(id);
if (authoritativeItem) {
return [mergePendingCanvasLayerLayout(authoritativeItem, pendingItem)];
}
return previousAuthoritativeItemIds.has(id) ? [] : [pendingItem];
});
for (const authoritativeItem of authoritativeItems) {
const id = canvasLayoutItemId(authoritativeItem);
if (!id || pendingIds.has(id) || previousAuthoritativeItemIds.has(id)) {
continue;
}
merged.push(authoritativeItem);
}
return merged;
}
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
canvasBackgroundColorRef: RefObject<string>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef: RefObject<string[]>;
};
type ImageCanvasProjectPersistenceSetters = {
@@ -99,6 +209,8 @@ type ImageCanvasProjectPersistenceSetters = {
setProjectRenameValue: (title: string) => void;
setViewport: (viewport: CanvasViewport) => void;
setLayers: (layers: CanvasLayer[]) => void;
setSelectedLayerId: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
selectSingleLayer: (layerId: string | null) => void;
setLayerCounter: (value: number) => void;
restoreCanvasGenerationDialogs: (
@@ -394,6 +506,7 @@ export function useImageCanvasProjectPersistence({
const authoritativeProjectIdRef = useRef<string | null>(null);
const hasAuthoritativeProjectSnapshotRef = useRef(false);
const acceptedAuthoritativeSnapshotSequenceRef = useRef(0);
const authoritativeLayoutItemIdsRef = useRef<Set<string>>(new Set());
const lastAuthoritativeOwnerUserIdRef = useRef<string | null | undefined>(
undefined,
);
@@ -418,6 +531,8 @@ export function useImageCanvasProjectPersistence({
);
const isProjectLayoutSaveRunningRef = useRef(false);
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
const activeProjectLayoutSaveAttemptRef =
useRef<ActiveProjectLayoutSaveAttempt | null>(null);
const skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const projectTitleRef = useRef('未命名画布');
@@ -435,6 +550,8 @@ export function useImageCanvasProjectPersistence({
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs,
@@ -478,6 +595,12 @@ export function useImageCanvasProjectPersistence({
...pendingSave,
attemptExpectedRevision: expectedRevision,
};
const activeAttempt: ActiveProjectLayoutSaveAttempt = {
save: attemptedSave,
authorityEpoch: saveAuthorityEpoch,
ownerUserId: saveOwnerUserId,
};
activeProjectLayoutSaveAttemptRef.current = activeAttempt;
const saveStillBelongsToCurrentAuthority = () =>
projectAuthorityEpochRef.current === saveAuthorityEpoch &&
currentUserIdRef.current === saveOwnerUserId &&
@@ -492,11 +615,15 @@ export function useImageCanvasProjectPersistence({
if (
saveStillBelongsToCurrentAuthority() &&
result &&
typeof result.revision === 'number'
typeof result.revision === 'number' &&
result.revision > (projectRevisionRef.current ?? -1)
) {
projectRevisionRef.current = Math.max(
projectRevisionRef.current ?? 0,
result.revision,
projectRevisionRef.current = result.revision;
authoritativeLayoutItemIdsRef.current = new Set(
attemptedSave.input.layers.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [id] : [];
}),
);
}
runNextSave = Boolean(pendingProjectLayoutSaveRef.current);
@@ -518,6 +645,7 @@ export function useImageCanvasProjectPersistence({
return;
}
applyProjectSnapshotRef.current?.(latestProject);
runNextSave = Boolean(pendingProjectLayoutSaveRef.current);
};
const scheduleAuthoritativeReload = (retryCount: number) => {
if (
@@ -588,6 +716,9 @@ export function useImageCanvasProjectPersistence({
}
})
.finally(() => {
if (activeProjectLayoutSaveAttemptRef.current === activeAttempt) {
activeProjectLayoutSaveAttemptRef.current = null;
}
if (activeProjectLayoutSavePromiseRef.current === savePromise) {
activeProjectLayoutSavePromiseRef.current = null;
}
@@ -1049,6 +1180,22 @@ export function useImageCanvasProjectPersistence({
}
acceptedAuthoritativeSnapshotSequenceRef.current += 1;
}
const pendingSave = pendingProjectLayoutSaveRef.current;
const activeSaveAttempt = activeProjectLayoutSaveAttemptRef.current;
const pendingLocalLayout = authoritative
? pendingSave?.projectId === project.projectId
? pendingSave
: activeSaveAttempt?.save.projectId === project.projectId &&
activeSaveAttempt.authorityEpoch ===
projectAuthorityEpochRef.current &&
activeSaveAttempt.ownerUserId === currentUserIdRef.current
? activeSaveAttempt.save
: null
: null;
const previousAuthoritativeItemIds =
authoritativeLayoutItemIdsRef.current;
const applyingToCurrentProject =
projectIdRef.current === project.projectId;
clearPendingProjectLayoutSave();
skipNextProjectLayoutSaveRef.current = true;
if (projectIdRef.current !== project.projectId) {
@@ -1075,7 +1222,25 @@ export function useImageCanvasProjectPersistence({
projectTitleRef.current = nextProjectTitle;
setProjectTitle(nextProjectTitle);
setProjectRenameValue(nextProjectTitle);
const nextViewport = canvasDisplayViewportToViewport(project.viewport);
const appliedLayoutItems = pendingLocalLayout
? mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
authoritativeItems: project.layers,
pendingItems: pendingLocalLayout.input.layers,
previousAuthoritativeItemIds,
})
: project.layers;
if (authoritative) {
authoritativeLayoutItemIdsRef.current = new Set(
project.layers.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [id] : [];
}),
);
}
const appliedViewport = pendingLocalLayout
? pendingLocalLayout.input.viewport
: project.viewport;
const nextViewport = canvasDisplayViewportToViewport(appliedViewport);
setViewport(nextViewport);
refs.viewportRef.current = nextViewport;
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
@@ -1105,14 +1270,41 @@ export function useImageCanvasProjectPersistence({
]),
);
const { layerItems, generationDialogs, canvasBackgroundColor } =
splitCanvasLayoutItems(project.layers, resourcesById, currentUserId);
splitCanvasLayoutItems(
appliedLayoutItems,
resourcesById,
currentUserId,
);
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
setLayerCounter(hydratedLayers.length);
refs.layersRef.current = hydratedLayers;
setLayers(hydratedLayers);
selectSingleLayer(hydratedLayers[0]?.id ?? null);
if (applyingToCurrentProject) {
const currentSelectionIds = refs.selectedLayerIdsRef.current.length
? refs.selectedLayerIdsRef.current
: refs.selectedLayerIdRef.current
? [refs.selectedLayerIdRef.current]
: [];
const nextSelectionIds = normalizeCanvasSelectionIds({
selectionIds: currentSelectionIds,
layers: hydratedLayers,
generationDialogs,
});
const nextSelectedLayerId = firstSelectedLayerId(nextSelectionIds);
refs.selectedLayerIdRef.current = nextSelectedLayerId;
refs.selectedLayerIdsRef.current = nextSelectionIds;
setSelectedLayerId(nextSelectedLayerId);
setSelectedLayerIds(nextSelectionIds);
} else {
const nextSelectedLayerId = hydratedLayers[0]?.id ?? null;
refs.selectedLayerIdRef.current = nextSelectedLayerId;
refs.selectedLayerIdsRef.current = nextSelectedLayerId
? [nextSelectedLayerId]
: [];
selectSingleLayer(nextSelectedLayerId);
}
refs.canvasGenerationDialogsRef.current = generationDialogs;
restoreCanvasGenerationDialogs(generationDialogs);
applyCanvasBackgroundColor(
@@ -1124,10 +1316,22 @@ export function useImageCanvasProjectPersistence({
authoritativeProjectIdRef.current === project.projectId;
if (projectIsAuthoritative) {
writeEditorProjectSessionCache(
project,
pendingLocalLayout
? {
...project,
viewport: appliedViewport,
layers: appliedLayoutItems,
}
: project,
currentUserId,
project.canvas?.revision,
);
if (pendingLocalLayout) {
queueProjectLayoutSave(project.projectId, {
viewport: appliedViewport,
layers: appliedLayoutItems,
});
}
}
return projectIsAuthoritative;
},
@@ -1135,11 +1339,14 @@ export function useImageCanvasProjectPersistence({
applyCanvasBackgroundColor,
clearPendingProjectLayoutSave,
currentUserId,
queueProjectLayoutSave,
refs,
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayerCounter,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
setProjectRenameValue,
setProjectTitle,
setViewport,
@@ -1171,6 +1378,8 @@ export function useImageCanvasProjectPersistence({
hasAuthoritativeProjectSnapshotRef.current = false;
authoritativeProjectIdRef.current = null;
projectRevisionRef.current = null;
authoritativeLayoutItemIdsRef.current = new Set();
activeProjectLayoutSaveAttemptRef.current = null;
pendingCreatedProjectResourceLayersRef.current = [];
clearPendingProjectLayoutSave();
setIsProjectReady(false);
@@ -1179,6 +1388,8 @@ export function useImageCanvasProjectPersistence({
hasAuthoritativeProjectSnapshotRef.current = false;
authoritativeProjectIdRef.current = null;
projectRevisionRef.current = null;
authoritativeLayoutItemIdsRef.current = new Set();
activeProjectLayoutSaveAttemptRef.current = null;
clearPendingProjectLayoutSave();
setIsProjectReady(false);
let cancelled = false;
@@ -1,9 +1,11 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
import type { SelectionStage } from './platformEntryActiveTypes';
const authUiMock = vi.hoisted(() => ({
value: {
@@ -15,10 +17,18 @@ const authUiMock = vi.hoisted(() => ({
},
}));
const responsiveMock = vi.hoisted(() => ({
isDesktopLayout: true,
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
vi.mock('./platformEntryResponsive', () => ({
usePlatformDesktopLayout: () => responsiveMock.isDesktopLayout,
}));
vi.mock('../creation-home/CreationLandingView', () => ({
CreationLandingView: ({
onOpenProject,
@@ -102,10 +112,26 @@ vi.mock('./usePlatformProfileCenterController', () => ({
}),
}));
function StatefulPlatformEntryFlowShell({
initialStage,
}: {
initialStage: SelectionStage;
}) {
const [selectionStage, setSelectionStage] = useState(initialStage);
return (
<PlatformEntryFlowShellImpl
selectionStage={selectionStage}
setSelectionStage={(nextStage) => setSelectionStage(nextStage)}
/>
);
}
describe('PlatformEntryActiveFlowShell', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
authUiMock.value.openLoginModal.mockReset();
responsiveMock.isDesktopLayout = true;
});
it('keeps the active desktop rail and the shared account capsule', async () => {
@@ -174,11 +200,12 @@ describe('PlatformEntryActiveFlowShell', () => {
).toBe('page');
});
it('keeps creation, project, and profile reachable from the mobile dock', async () => {
it('keeps only profile reachable from the mobile dock', async () => {
responsiveMock.isDesktopLayout = false;
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="project"
selectionStage="platform"
setSelectionStage={setSelectionStage}
/>,
);
@@ -190,12 +217,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '项目' })
.getAttribute('aria-current'),
).toBe('page');
).toEqual(['我的']);
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
expect(window.location.pathname).toBe('/profile');
@@ -204,6 +226,48 @@ describe('PlatformEntryActiveFlowShell', () => {
});
});
it.each(['creation-home', 'project', 'image-editor'] as const)(
'shows the desktop guide instead of mounting the %s page on mobile',
async (selectionStage) => {
responsiveMock.isDesktopLayout = false;
render(
<PlatformEntryFlowShellImpl
selectionStage={selectionStage}
setSelectionStage={vi.fn()}
/>,
);
expect(
await screen.findByRole('main', { name: '桌面端创作提示' }),
).toBeTruthy();
expect(
screen.queryByRole('main', { name: '陶泥儿创作主页' }),
).toBeNull();
expect(screen.queryByRole('main', { name: '项目' })).toBeNull();
expect(
screen.queryByRole('main', { name: '图片画布编辑器' }),
).toBeNull();
},
);
it('shows the desktop guide instead of the editor after opening a canvas tool from the mobile root', async () => {
responsiveMock.isDesktopLayout = false;
render(<StatefulPlatformEntryFlowShell initialStage="platform" />);
fireEvent.click(
await screen.findByRole('button', { name: '打开音乐工具' }),
);
expect(
await screen.findByRole('main', { name: '桌面端创作提示' }),
).toBeTruthy();
expect(
screen.queryByRole('main', { name: '图片画布编辑器' }),
).toBeNull();
});
it('switches between creation and projects and passes search to the active page', async () => {
const setSelectionStage = vi.fn();
const { rerender } = render(
@@ -1,4 +1,11 @@
import { FolderKanban, Palette, Search, UserRound } from 'lucide-react';
import {
FolderKanban,
Home,
Monitor,
Palette,
Search,
UserRound,
} from 'lucide-react';
import {
type ComponentType,
type FormEvent,
@@ -20,6 +27,8 @@ import {
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import {
resolveActivePublicUserCode,
resolveActiveUserAvatarLabel,
@@ -161,6 +170,45 @@ function LoadingPanel({ label }: { label: string }) {
);
}
function MobileCreationDesktopGuide({
onReturnHome,
}: {
onReturnHome: () => void;
}) {
return (
<main
className="flex min-h-full flex-1 items-center justify-center px-4 py-8"
aria-label="桌面端创作提示"
>
<PlatformSubpanel
as="section"
radius="xl"
padding="none"
className="platform-remap-surface w-full max-w-sm px-5 py-6 text-center"
>
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-[1.05rem] bg-[rgba(199,101,50,0.12)] text-[var(--platform-accent-strong)]">
<Monitor aria-hidden="true" className="h-7 w-7" />
</span>
<h1 className="mt-4 text-xl font-black leading-tight text-[var(--platform-text-strong)]">
请在桌面端打开创作工具
</h1>
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
图片画布、项目管理和素材库需要更大的操作空间。
</p>
<PlatformActionButton
onClick={onReturnHome}
size="md"
shape="pill"
className="mt-5 min-h-11 w-full"
>
<Home aria-hidden="true" className="h-4 w-4" />
返回首页
</PlatformActionButton>
</PlatformSubpanel>
</main>
);
}
export function PlatformEntryFlowShellImpl({
selectionStage,
setSelectionStage,
@@ -173,6 +221,8 @@ export function PlatformEntryFlowShellImpl({
const [isApiKeysOpen, setIsApiKeysOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
useState(false);
const isDesktopLayout = usePlatformDesktopLayout();
const refreshDashboard = useCallback(async () => {
@@ -206,14 +256,22 @@ export function PlatformEntryFlowShellImpl({
});
const openCreation = useCallback(() => {
if (!isDesktopLayout) {
setIsMobileDesktopGuideOpen(true);
return;
}
pushAppHistoryPath('/creation');
setSelectionStage('creation-home', { path: '/creation' });
}, [setSelectionStage]);
}, [isDesktopLayout, setSelectionStage]);
const openProjects = useCallback(() => {
if (!isDesktopLayout) {
setIsMobileDesktopGuideOpen(true);
return;
}
pushAppHistoryPath('/project');
setSelectionStage('project', { path: '/project' });
}, [setSelectionStage]);
}, [isDesktopLayout, setSelectionStage]);
const openProfile = useCallback(() => {
pushAppHistoryPath('/profile');
@@ -222,6 +280,10 @@ export function PlatformEntryFlowShellImpl({
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
if (!isDesktopLayout) {
setIsMobileDesktopGuideOpen(true);
return;
}
const params = new URLSearchParams();
params.set('projectid', projectId);
if (options?.guide) {
@@ -234,7 +296,7 @@ export function PlatformEntryFlowShellImpl({
pushAppHistoryPath(path);
setSelectionStage('image-editor', { path });
},
[setSelectionStage],
[isDesktopLayout, setSelectionStage],
);
const replaceWithProjectGallery = useCallback(() => {
@@ -242,6 +304,26 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('project', { path: '/project' });
}, [setSelectionStage]);
const isMobileToolStage =
!isDesktopLayout &&
(selectionStage === 'creation-home' ||
selectionStage === 'project' ||
selectionStage === 'image-editor');
if ((!isDesktopLayout && isMobileDesktopGuideOpen) || isMobileToolStage) {
return (
<MobileCreationDesktopGuide
onReturnHome={() => {
setIsMobileDesktopGuideOpen(false);
if (selectionStage === 'platform') {
return;
}
setSelectionStage('platform', { path: '/' });
}}
/>
);
}
if (selectionStage === 'image-editor') {
return (
<div className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
@@ -469,22 +551,9 @@ export function PlatformEntryFlowShellImpl({
</div>
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
<nav
className="platform-bottom-nav grid grid-cols-3"
className="platform-bottom-nav grid grid-cols-1"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
active={isCreationStage}
emphasized={isCreationStage}
icon={Palette}
label="创作"
onClick={openCreation}
/>
<ActiveBottomNavButton
active={selectionStage === 'project'}
icon={FolderKanban}
label="项目"
onClick={openProjects}
/>
<ActiveBottomNavButton
active={isProfileStage}
icon={UserRound}