优化画布顶部栏布局

收窄画布顶部栏高度和按钮间距

移除未命名画布下方的画布副标题

将画布标题和重命名入口贴近返回按钮

在顶部栏右端展示当前泥点余额

补充画布顶部栏测试和技术方案说明
This commit is contained in:
2026-06-22 20:35:54 +08:00
parent 1176c71f72
commit 7fe4a10d19
7 changed files with 322 additions and 88 deletions
@@ -9,6 +9,7 @@
- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。
- 主站新增 `/project` 项目页,从“我的”页项目入口进入,展示当前用户所有图片画布工程;点击项目进入 `/editor/canvas?projectid=<projectId>`
- 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。
- 编辑器顶部栏采用紧凑高度,项目标题和重命名入口贴近返回项目按钮;右侧常驻展示当前账号泥点余额,样式对齐创作主页顶部钱包 chip。
- 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。
- 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
@@ -98,6 +98,8 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
isProjectRenameSaving: false,
projectRenameError: null,
layers: [],
walletBalanceLabel: '0泥点',
isWalletBalanceLoading: false,
assetExportStatus: null,
isExportingAssets: false,
setProjectRenameValue: vi.fn(),
@@ -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(
<AuthUiContext.Provider
value={createAuthValue({
user: {
id: 'user-1',
publicUserCode: 'U001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
})}
>
<ImageCanvasEditorView />
</AuthUiContext.Provider>,
);
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();
@@ -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<string | null>(
null,
);
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(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,
@@ -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: '画布素材已导出',
@@ -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 (
<div className="image-canvas-editor__topbar">
<a
className="image-canvas-editor__project-back-button"
href="/project"
aria-label="返回项目页面"
title="返回项目"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</a>
<div className="image-canvas-editor__title-block">
{isRenamingProject ? (
<form
className="image-canvas-editor__project-title-form"
onSubmit={(event) => {
event.preventDefault();
submitProjectRename(projectId);
}}
>
<PlatformTextField
aria-label="项目名称"
value={projectRenameValue}
autoFocus
disabled={isProjectRenameSaving}
className="image-canvas-editor__project-title-input"
onChange={(event) => {
setProjectRenameValue(event.target.value);
resetProjectRenameError();
<div className="image-canvas-editor__topbar-project">
<a
className="image-canvas-editor__project-back-button"
href="/project"
aria-label="返回项目页面"
title="返回项目"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</a>
<div className="image-canvas-editor__title-block">
{isRenamingProject ? (
<form
className="image-canvas-editor__project-title-form"
onSubmit={(event) => {
event.preventDefault();
submitProjectRename(projectId);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelProjectRename();
}
}}
/>
<EditorIconButton
type="submit"
label="保存项目名称"
title="保存"
icon={Check}
disabled={isProjectRenameSaving}
/>
<EditorIconButton
label="取消修改项目名称"
title="取消"
icon={X}
disabled={isProjectRenameSaving}
onClick={cancelProjectRename}
/>
{projectRenameError ? (
<span
className="image-canvas-editor__project-title-error"
role="alert"
>
{projectRenameError}
</span>
) : null}
</form>
) : (
<div className="image-canvas-editor__project-title-row">
<button
type="button"
className="image-canvas-editor__project-title-button"
onDoubleClick={startProjectRename}
aria-label={`编辑项目名称${projectTitle}`}
>
<h1>{projectTitle}</h1>
</button>
<EditorIconButton
className="image-canvas-editor__project-rename-button"
label="编辑项目名称"
title="编辑项目名称"
icon={Pencil}
onClick={startProjectRename}
/>
</div>
)}
<span></span>
<PlatformTextField
aria-label="项目名称"
value={projectRenameValue}
autoFocus
disabled={isProjectRenameSaving}
className="image-canvas-editor__project-title-input"
onChange={(event) => {
setProjectRenameValue(event.target.value);
resetProjectRenameError();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelProjectRename();
}
}}
/>
<EditorIconButton
type="submit"
label="保存项目名称"
title="保存"
icon={Check}
disabled={isProjectRenameSaving}
/>
<EditorIconButton
label="取消修改项目名称"
title="取消"
icon={X}
disabled={isProjectRenameSaving}
onClick={cancelProjectRename}
/>
{projectRenameError ? (
<span
className="image-canvas-editor__project-title-error"
role="alert"
>
{projectRenameError}
</span>
) : null}
</form>
) : (
<div className="image-canvas-editor__project-title-row">
<button
type="button"
className="image-canvas-editor__project-title-button"
onDoubleClick={startProjectRename}
aria-label={`编辑项目名称${projectTitle}`}
>
<h1>{projectTitle}</h1>
</button>
<EditorIconButton
className="image-canvas-editor__project-rename-button"
label="编辑项目名称"
title="编辑项目名称"
icon={Pencil}
onClick={startProjectRename}
/>
</div>
)}
</div>
</div>
<div className="image-canvas-editor__topbar-actions">
<EditorIconButton
@@ -158,6 +167,20 @@ export function ImageCanvasTopbarView({
{assetExportStatus.message}
</PlatformStatusMessage>
) : null}
<div
className="image-canvas-editor__wallet-chip"
aria-label={walletAriaLabel}
aria-busy={isWalletBalanceLoading}
aria-live="polite"
>
<img
src="/creation-home/topbar-wallet.png"
alt=""
aria-hidden="true"
draggable={false}
/>
<span>{walletDisplayLabel}</span>
</div>
</div>
</div>
);
+84 -12
View File
@@ -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 {