diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 91471a614..12bee8406 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -9,6 +9,7 @@ - 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=`。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 +- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 - 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 0742151e3..2a7c09fac 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -98,6 +98,8 @@ function createTopbarProps(): ImageCanvasTopbarViewProps { isProjectRenameSaving: false, projectRenameError: null, layers: [], + walletBalanceLabel: '0泥点', + isWalletBalanceLoading: false, assetExportStatus: null, isExportingAssets: false, setProjectRenameValue: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 126d2a053..f6fe85590 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -10,7 +10,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import JSZip from 'jszip'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, @@ -38,6 +38,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); +const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -64,6 +65,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); +vi.mock('../../services/platform-entry/platformProfileClient', () => ({ + getPlatformProfileDashboard: getPlatformProfileDashboardMock, +})); + describe('ImageCanvasEditorView', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, @@ -84,6 +89,19 @@ describe('ImageCanvasEditorView', () => { saveEditorProjectLayoutMock, }); + beforeEach(() => { + getPlatformProfileDashboardMock.mockResolvedValue({ + walletBalance: 1234, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: null, + }); + }); + + afterEach(() => { + getPlatformProfileDashboardMock.mockReset(); + }); + it('loads the project from projectid query before falling back to recent project', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-query', @@ -157,6 +175,36 @@ describe('ImageCanvasEditorView', () => { }); }); + it('shows the live mud point balance in the canvas topbar when logged in', async () => { + render( + + + , + ); + + expect(await screen.findByLabelText('泥点余额 1,234泥点')).toBeTruthy(); + expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }); + }); + it('opens the login modal immediately when entering the editor while logged out', async () => { const openLoginModal = vi.fn(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4374aab68..e4e86dee0 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -14,8 +14,10 @@ import { type EditorAssetSnapshot, loadEditorGenerationPricing, } from '../../services/image-editor/editorProjectClient'; +import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, generationInputsOrNull, @@ -66,6 +68,10 @@ import { export function ImageCanvasEditorView() { const authUi = useAuthUi(); const [, setGenerationPricingVersion] = useState(0); + const [walletBalanceLabel, setWalletBalanceLabel] = useState( + null, + ); + const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false); const editorRootRef = useRef(null); const canvasViewportRef = useRef(null); const assetListRef = useRef(null); @@ -175,6 +181,66 @@ export function ImageCanvasEditorView() { window.location.reload(); }); }, [authUi]); + useEffect(() => { + if (!authUi?.canAccessProtectedData || !authUi.user) { + setWalletBalanceLabel(null); + setIsWalletBalanceLoading(false); + return; + } + + let isMounted = true; + let requestId = 0; + + const refreshWalletBalance = () => { + const currentRequestId = requestId + 1; + requestId = currentRequestId; + setIsWalletBalanceLoading(true); + void getPlatformProfileDashboard({ + authImpact: 'local', + skipRefresh: true, + notifyAuthStateChange: false, + clearAuthOnUnauthorized: false, + }) + .then((dashboard) => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setWalletBalanceLabel(`${formatDashboardCount(dashboard.walletBalance)}泥点`); + }) + .catch(() => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setWalletBalanceLabel(null); + }) + .finally(() => { + if (!isMounted || currentRequestId !== requestId) { + return; + } + setIsWalletBalanceLoading(false); + }); + }; + + refreshWalletBalance(); + + const handleWindowFocus = () => { + refreshWalletBalance(); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + refreshWalletBalance(); + } + }; + + window.addEventListener('focus', handleWindowFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + isMounted = false; + window.removeEventListener('focus', handleWindowFocus); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [authUi?.canAccessProtectedData, authUi?.user?.id]); const { projectTitle, setProjectTitle, @@ -1179,6 +1245,8 @@ export function ImageCanvasEditorView() { isProjectRenameSaving, projectRenameError, layers, + walletBalanceLabel, + isWalletBalanceLoading, assetExportStatus, isExportingAssets, setProjectRenameValue, diff --git a/src/components/image-editor/ImageCanvasTopbarView.test.tsx b/src/components/image-editor/ImageCanvasTopbarView.test.tsx index 2648957eb..96a75e133 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.test.tsx @@ -38,6 +38,8 @@ function renderTopbar( isProjectRenameSaving: false, projectRenameError: null, layers: [], + walletBalanceLabel: '1,234泥点', + isWalletBalanceLoading: false, assetExportStatus: null, isExportingAssets: false, setProjectRenameValue: vi.fn(), @@ -60,6 +62,7 @@ describe('ImageCanvasTopbarView', () => { const props = renderTopbar(); expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy(); + expect(screen.queryByText('画布')).toBeNull(); expect( screen.getByRole('link', { name: '返回项目页面' }).getAttribute('href'), ).toBe('/project'); @@ -73,6 +76,19 @@ describe('ImageCanvasTopbarView', () => { expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1); }); + it('shows the current mud point balance in the topbar', () => { + renderTopbar({ + walletBalanceLabel: '1.2万泥点', + }); + + const walletChip = screen.getByLabelText('泥点余额 1.2万泥点'); + + expect(walletChip.textContent).toBe('1.2万泥点'); + expect(walletChip.querySelector('img')?.getAttribute('src')).toBe( + '/creation-home/topbar-wallet.png', + ); + }); + it('submits, resets, and cancels project rename edits', () => { const props = renderTopbar({ isRenamingProject: true, @@ -105,6 +121,8 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[]} + walletBalanceLabel="0泥点" + isWalletBalanceLoading={false} assetExportStatus={null} isExportingAssets={false} setProjectRenameValue={vi.fn()} @@ -131,6 +149,8 @@ describe('ImageCanvasTopbarView', () => { isProjectRenameSaving={false} projectRenameError={null} layers={[createLayer()]} + walletBalanceLabel="0泥点" + isWalletBalanceLoading={false} assetExportStatus={{ tone: 'success', message: '画布素材已导出', diff --git a/src/components/image-editor/ImageCanvasTopbarView.tsx b/src/components/image-editor/ImageCanvasTopbarView.tsx index 3e02ab576..a75d9ec8e 100644 --- a/src/components/image-editor/ImageCanvasTopbarView.tsx +++ b/src/components/image-editor/ImageCanvasTopbarView.tsx @@ -21,6 +21,8 @@ export type ImageCanvasTopbarViewProps = { isProjectRenameSaving: boolean; projectRenameError: string | null; layers: CanvasLayer[]; + walletBalanceLabel: string | null; + isWalletBalanceLoading: boolean; assetExportStatus: AssetExportStatus | null; isExportingAssets: boolean; setProjectRenameValue: (value: string) => void; @@ -40,6 +42,8 @@ export function ImageCanvasTopbarView({ isProjectRenameSaving, projectRenameError, layers, + walletBalanceLabel, + isWalletBalanceLoading, assetExportStatus, isExportingAssets, setProjectRenameValue, @@ -53,86 +57,91 @@ export function ImageCanvasTopbarView({ const hasExportableLayer = layers.some( (layer) => layer.src.trim().length > 0, ); + const walletDisplayLabel = walletBalanceLabel ?? '--泥点'; + const walletAriaLabel = walletBalanceLabel + ? `泥点余额 ${walletBalanceLabel}` + : '泥点余额读取中'; return (
- - -
- {isRenamingProject ? ( -
{ - event.preventDefault(); - submitProjectRename(projectId); - }} - > - { - setProjectRenameValue(event.target.value); - resetProjectRenameError(); +
+ + +
+ {isRenamingProject ? ( + { + event.preventDefault(); + submitProjectRename(projectId); }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); - cancelProjectRename(); - } - }} - /> - - - {projectRenameError ? ( - - {projectRenameError} - - ) : null} - - ) : ( -
- - -
- )} - 画布 + { + setProjectRenameValue(event.target.value); + resetProjectRenameError(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelProjectRename(); + } + }} + /> + + + {projectRenameError ? ( + + {projectRenameError} + + ) : null} + + ) : ( +
+ + +
+ )} +
) : null} +
+ + {walletDisplayLabel} +
); diff --git a/src/index.css b/src/index.css index 407cf8b38..b027c8b5c 100644 --- a/src/index.css +++ b/src/index.css @@ -4775,13 +4775,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__topbar { display: flex; - min-height: 3.4rem; + min-height: 2.86rem; align-items: center; justify-content: space-between; - gap: 0.75rem; + gap: 0.55rem; border-bottom: 1px solid #d9dee8; background: #ffffff; - padding: 0.55rem 0.7rem; + padding: 0.36rem 0.58rem; +} + +.image-canvas-editor__topbar-project { + display: inline-flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 0.34rem; } .image-canvas-editor__topbar-actions { @@ -4790,7 +4798,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { flex: 0 1 auto; align-items: center; justify-content: flex-end; - gap: 0.45rem; + gap: 0.36rem; } .image-canvas-editor__shortcut-modal { @@ -4893,8 +4901,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .image-canvas-editor__project-back-button { - width: 2.25rem; - height: 2.25rem; + width: 2.04rem; + height: 2.04rem; flex: 0 0 auto; border-radius: 0.45rem; text-decoration: none; @@ -4904,12 +4912,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; min-width: 0; align-items: center; - gap: 0.25rem; + gap: 0.18rem; } .image-canvas-editor__project-title-button { min-width: 0; - max-width: min(24rem, 42vw); + max-width: min(22rem, 38vw); border: 0; background: transparent; padding: 0; @@ -4939,13 +4947,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__project-title-form { display: inline-flex; min-width: 0; - max-width: min(34rem, 58vw); + max-width: min(32rem, 54vw); align-items: center; - gap: 0.35rem; + gap: 0.28rem; } .image-canvas-editor__project-title-input { - width: clamp(9rem, 28vw, 18rem); + width: clamp(8.5rem, 24vw, 16rem); } .image-canvas-editor__project-title-error { @@ -4958,6 +4966,35 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { white-space: nowrap; } +.image-canvas-editor__wallet-chip { + display: inline-flex; + min-width: 6.65rem; + height: 2.04rem; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 0.34rem; + border: 1px solid rgba(214, 184, 159, 0.72); + border-radius: 999px; + background: rgba(255, 250, 244, 0.82); + padding: 0 0.56rem 0 0.42rem; + color: #7a4427; + font-size: 0.76rem; + font-weight: 860; + line-height: 1; + white-space: nowrap; +} + +.image-canvas-editor__wallet-chip img { + width: 1.16rem; + height: 1.16rem; + flex: 0 0 auto; + object-fit: cover; + object-position: center; + filter: drop-shadow(0 0.12rem 0.12rem rgba(112, 62, 32, 0.14)); + mix-blend-mode: multiply; +} + .image-canvas-editor__zoom-menu-wrap { margin-left: auto; position: relative; @@ -8211,7 +8248,21 @@ button.image-canvas-editor__reference-chip:disabled { } .image-canvas-editor__topbar { - min-height: 3.2rem; + min-height: 2.78rem; + padding: 0.34rem 0.48rem; + } + + .image-canvas-editor__topbar-project { + gap: 0.26rem; + } + + .image-canvas-editor__topbar-actions { + gap: 0.28rem; + } + + .image-canvas-editor__project-back-button { + width: 1.94rem; + height: 1.94rem; } .image-canvas-editor__shortcut-sections { @@ -8231,6 +8282,27 @@ button.image-canvas-editor__reference-chip:disabled { font-size: 0.84rem; } + .image-canvas-editor__project-title-button { + max-width: min(14rem, 34vw); + } + + .image-canvas-editor__project-title-input { + width: clamp(7rem, 34vw, 11rem); + } + + .image-canvas-editor__wallet-chip { + min-width: 5.72rem; + height: 1.94rem; + gap: 0.26rem; + padding-inline: 0.34rem 0.42rem; + font-size: 0.7rem; + } + + .image-canvas-editor__wallet-chip img { + width: 1.02rem; + height: 1.02rem; + } + .image-canvas-editor__floating-toolbar button, .image-canvas-editor__bottom-toolbar button, .image-canvas-editor__reset-button {