fix: refine profile shortcuts and puzzle next button

This commit is contained in:
2026-06-01 16:45:39 +00:00
parent fae4db6a09
commit 1cb11bc1dd
6 changed files with 113 additions and 32 deletions

View File

@@ -52,7 +52,7 @@
8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。 9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。 发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
## RPG / 自定义世界 ## RPG / 自定义世界
@@ -107,7 +107,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。 - 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。
- 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work``mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。 - 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work``mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
- 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回设置按钮的点击容器只提供透明点击区,不再叠加默认白色圆形底底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 - 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。
- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest推荐卡片的后台读写请求仍使用 local auth impact避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 - 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest推荐卡片的后台读写请求仍使用 local auth impact避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。

View File

@@ -95,7 +95,7 @@ server-rs + Axum + SpacetimeDB
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。 7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal至少支持玩法类型过滤与排序切换筛选结果为空时显示空状态不把筛选内容展开在当前列表下方。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal至少支持玩法类型过滤与排序切换筛选结果为空时显示空状态不把筛选内容展开在当前列表下方。
10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。 10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。 11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。
12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px``14px` 的普通 UI 字号区间,避免展示级字号挤压内容。 12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px``14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。 13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。

View File

@@ -617,7 +617,11 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
expect(within(dialog).getByText('#1')).toBeTruthy(); expect(within(dialog).getByText('#1')).toBeTruthy();
expect(within(dialog).getByText('测试作者')).toBeTruthy(); expect(within(dialog).getByText('测试作者')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' })); const nextButton = within(dialog).getByRole('button', { name: '下一关' });
expect(nextButton.textContent).toContain('下一关');
expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull();
fireEvent.click(nextButton);
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1); expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
vi.useRealTimers(); vi.useRealTimers();
@@ -876,13 +880,16 @@ test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关
expect( expect(
screen.getByRole('button', { name: '打开拼图设置' }).className, screen.getByRole('button', { name: '打开拼图设置' }).className,
).not.toContain('rounded-full'); ).not.toContain('rounded-full');
const nextSprite = screen const nextButton = screen.getByRole('button', { name: '下一关' });
.getByRole('button', { name: '下一关' }) expect(nextButton.dataset.puzzleUiSprite).toBe('next');
.querySelector('[data-puzzle-ui-sprite="next"]') as HTMLElement | null; expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull();
expect(nextSprite).toBeTruthy(); expect(nextButton.style.backgroundSize).toBe('320% 480%');
expect(nextSprite?.style.backgroundSize).toBe('320% 480%'); expect(nextButton.style.backgroundPosition).toBe('50% 57.89473684210527%');
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%'); expect(nextButton.className).not.toContain('puzzle-runtime-primary-button');
expect(screen.getByRole('button', { name: '下一关' }).textContent).toBe(''); expect(nextButton.className).not.toContain('rounded-full');
expect(nextButton.className).not.toContain('px-5');
expect(nextButton.className).not.toContain('py-2.5');
expect(nextButton.textContent).toBe('');
expect( expect(
screen screen
.getByRole('button', { name: '提示' }) .getByRole('button', { name: '提示' })
@@ -962,7 +969,7 @@ test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
expect(backgroundImage).toBeTruthy(); expect(backgroundImage).toBeTruthy();
}); });
test('关闭通关弹窗后保留底部下一关入口', () => { test('关闭通关弹窗后保留底部下一关入口', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn(); const onAdvanceNextLevel = vi.fn();
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = { const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
@@ -988,10 +995,31 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
onAdvanceNextLevel={onAdvanceNextLevel} onAdvanceNextLevel={onAdvanceNextLevel}
/>, />,
); );
await act(async () => {});
act(() => { act(() => {
vi.advanceTimersByTime(1_400); vi.advanceTimersByTime(1_400);
}); });
const dialog = screen.getByRole('dialog', { name: '通关完成' });
const dialogNextButton = within(dialog).getByRole('button', {
name: '下一关',
});
expect(dialogNextButton.dataset.puzzleUiSprite).toBe('next');
expect(
dialogNextButton.querySelector('[data-puzzle-ui-sprite="next"]'),
).toBeNull();
expect(dialogNextButton.style.backgroundSize).toBe('320% 480%');
expect(dialogNextButton.style.backgroundPosition).toBe(
'50% 57.89473684210527%',
);
expect(dialogNextButton.className).not.toContain(
'puzzle-runtime-primary-button',
);
expect(dialogNextButton.className).not.toContain('rounded-full');
expect(dialogNextButton.className).not.toContain('px-5');
expect(dialogNextButton.className).not.toContain('py-2.5');
expect(dialogNextButton.textContent).toBe('');
act(() => { act(() => {
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' })); fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
}); });
@@ -1050,9 +1078,8 @@ test('推荐页关闭通关弹窗后保留底部下一关入口且不叠加下
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
const nextButton = screen.getByRole('button', { name: //u }); const nextButton = screen.getByRole('button', { name: //u });
expect(nextButton).toBeTruthy(); expect(nextButton).toBeTruthy();
expect( expect(nextButton.dataset.puzzleUiSprite).toBe('next');
nextButton.querySelector('[data-puzzle-ui-sprite="next"]'), expect(nextButton.querySelector('[data-puzzle-ui-sprite="next"]')).toBeNull();
).toBeTruthy();
expect(nextButton.textContent?.trim()).toBe(''); expect(nextButton.textContent?.trim()).toBe('');
vi.useRealTimers(); vi.useRealTimers();
}); });

View File

@@ -162,6 +162,15 @@ function PuzzleUiSprite({
); );
} }
function resolvePuzzleUiSpriteAspectRatio(
kind: PuzzleUiSpriteKind,
layout: PuzzleUiSpritesheetLayout | null,
fallback: string,
) {
const region = layout?.regions[kind];
return region ? `${region.width} / ${region.height}` : fallback;
}
function buildMergedGroupViewModels( function buildMergedGroupViewModels(
groups: PuzzleMergedGroupState[], groups: PuzzleMergedGroupState[],
pieces: PuzzleBoardPieceViewModel[], pieces: PuzzleBoardPieceViewModel[],
@@ -1221,6 +1230,22 @@ export function PuzzleRuntimeShell({
const clearResultOverlayClassName = embedded const clearResultOverlayClassName = embedded
? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm` ? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm`
: 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm'; : 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm';
const nextSpriteButtonClassName =
'inline-flex h-12 appearance-none items-center justify-center border-0 bg-transparent p-0 leading-none shadow-none transition hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] disabled:cursor-not-allowed disabled:opacity-45';
const nextSpriteButtonStyle = hasUiSpritesheet
? {
...buildPuzzleUiSpriteBackgroundStyle({
src: resolvedUiSpritesheetImage,
kind: 'next',
layout: uiSpritesheetLayout,
}),
aspectRatio: resolvePuzzleUiSpriteAspectRatio(
'next',
uiSpritesheetLayout,
'2 / 1',
),
}
: undefined;
const handleBackRequest = () => { const handleBackRequest = () => {
if (hideExitControls) { if (hideExitControls) {
return; return;
@@ -1457,6 +1482,8 @@ export function PuzzleRuntimeShell({
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4"> <footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
<button <button
type="button" type="button"
aria-label="下一关"
data-puzzle-ui-sprite={hasUiSpritesheet ? 'next' : undefined}
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
onAdvanceNextLevel({ onAdvanceNextLevel({
@@ -1464,14 +1491,23 @@ export function PuzzleRuntimeShell({
levelId: run.nextLevelId ?? null, levelId: run.nextLevelId ?? null,
}); });
}} }}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45" style={nextSpriteButtonStyle}
className={
hasUiSpritesheet
? nextSpriteButtonClassName
: 'puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45'
}
> >
{isBusy ? ( {hasUiSpritesheet ? null : (
<Loader2 className="h-4 w-4 animate-spin" /> <>
) : ( {isBusy ? (
<ArrowRight className="h-4 w-4" /> <Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
下一关
</>
)} )}
下一关
</button> </button>
</footer> </footer>
) : null} ) : null}
@@ -1933,6 +1969,7 @@ export function PuzzleRuntimeShell({
<button <button
type="button" type="button"
disabled={isBusy} disabled={isBusy}
data-puzzle-ui-sprite={hasUiSpritesheet ? 'next' : undefined}
aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'} aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'}
onClick={() => { onClick={() => {
if (hasSimilarWorkChoices) { if (hasSimilarWorkChoices) {
@@ -1945,16 +1982,18 @@ export function PuzzleRuntimeShell({
levelId: run.nextLevelId ?? null, levelId: run.nextLevelId ?? null,
}); });
}} }}
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45" style={nextSpriteButtonStyle}
className={
hasUiSpritesheet
? nextSpriteButtonClassName
: 'puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45'
}
> >
<PuzzleUiSprite {hasUiSpritesheet ? null : (
src={resolvedUiSpritesheetImage} <>
kind="next" <ArrowRight className="h-4 w-4" />
layout={uiSpritesheetLayout} {hasSimilarWorkChoices ? '换个作品' : '下一关'}
className="h-8 w-12 rounded-full" </>
/>
{resolvedUiSpritesheetImage ? null : (
<ArrowRight className="h-4 w-4" />
)} )}
</button> </button>
) : null} ) : null}

View File

@@ -2412,6 +2412,21 @@ test('mobile profile page matches the reference layout sections', async () => {
.querySelector('.platform-profile-shortcut-grid') .querySelector('.platform-profile-shortcut-grid')
?.classList.contains('platform-profile-shortcut-grid'), ?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true); ).toBe(true);
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.className,
).toContain('!grid-cols-4');
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.className,
).toContain('w-full');
for (const shortcutButton of shortcutRegion.querySelectorAll(
'.platform-profile-shortcut-button',
)) {
expect(shortcutButton.className).toContain('w-full');
}
for (const label of [ for (const label of [
'泥点充值', '泥点充值',
'兑换码', '兑换码',

View File

@@ -2434,7 +2434,7 @@ function ProfileShortcutButton({
<button <button
type="button" type="button"
onClick={onClick ?? undefined} onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[5.25rem] flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition" className="platform-profile-shortcut-button flex min-h-[5.25rem] w-full flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
> >
<div className="platform-profile-shortcut-button__icon"> <div className="platform-profile-shortcut-button__icon">
{imageSrc ? ( {imageSrc ? (
@@ -6368,7 +6368,7 @@ export function RpgEntryHomeView({
className="platform-profile-shortcut-panel" className="platform-profile-shortcut-panel"
aria-label="常用功能" aria-label="常用功能"
> >
<div className="platform-profile-shortcut-grid"> <div className="platform-profile-shortcut-grid grid w-full !grid-cols-4">
<ProfileShortcutButton <ProfileShortcutButton
label="泥点充值" label="泥点充值"
subLabel="充值泥点" subLabel="充值泥点"