修复创作者主页返回循环
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m16s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m7s
Project CI / Repository checks (pull_request) Successful in 6m49s
Project CI / Backend tests (pull_request) Successful in 8m37s
Project CI / Native shell tests (pull_request) Successful in 10m15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m7s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m16s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m7s
Project CI / Repository checks (pull_request) Successful in 6m49s
Project CI / Backend tests (pull_request) Successful in 8m37s
Project CI / Native shell tests (pull_request) Successful in 10m15s
返回兜底改为替换历史,关系列表回所属主页,主页回游戏广场 主页资料行改为静态展示,保留详情和关系列表的作者链接 补充导航与共享组件回归测试,并同步产品规则和排障记忆
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
|
||||
|
||||
## 2026-10-05 页面返回兜底不能新增历史条目
|
||||
|
||||
- 普通链接整页打开的条目可能没有应用导航标记;此时返回若调用 `pushAppHistoryPath`,再点返回就会退回原深链,形成循环。无应用内历史的返回应使用 `replaceAppHistoryPath`,有历史时保留原生后退。
|
||||
- 创作者主页顶部资料不自链接;详情和关系列表中的用户链接保留。具体兜底目标见创作者专题合同;验证须覆盖真实浏览器连续返回及列表分页/滚动恢复,不能只检查 `history.back()` 被调用。
|
||||
|
||||
## 2026-10-05 运行画面点选的高亮框被尺寸上报当成页面内容:点选后预览自己缩放
|
||||
|
||||
- **现象**:three 项目里点选 3D 对象后预览画面自己缩放、视角异常(相机 aspect 与重新适配的距离都变了),点选结束后视口仍停在放大后的尺寸。实测夹具里是**自激振荡**:10 秒内 1459 条尺寸消息、1457 次 iframe 尺寸/缩放变更、1454 次 `resize`,相机 aspect 在 1.607143 ↔ 1.421112 之间来回变。
|
||||
|
||||
@@ -110,6 +110,7 @@ type CreatorRelationshipResponse = {
|
||||
- 移除粉丝成功从粉丝列表去行,回关/取消回关不去行。确认弹窗复用公共组件;两个方向的按钮在同一目标写入期间一起禁用,写后刷新摘要和相关列表。
|
||||
- 进入页面、返回和重新聚焦时回读;旧请求通过请求序号/取消机制隔离。认证变化清除私有关系缓存和旧页请求,不让上个账号响应覆盖新账号。
|
||||
- 新主页路由接入页面标题、导航高亮、返回兜底和滚动位置恢复。移动点击区域至少 44px;加载、错误、空态分别呈现,不因关系失败阻断公开游戏阅读。
|
||||
- 返回有应用内历史时使用 `history.back()`;没有时使用 `replaceAppHistoryPath`,关系列表回所属主页,主页回 `/games`,再同步页面状态,禁止通过 `openCreator` 新增兜底历史。主页顶部 `CreatorUserRow` 不传 `href`,渲染无链接、无额外键盘焦点的资料区域;列表和游戏详情继续传入链接与导航回调。
|
||||
|
||||
- 顶层访问层登记到 `vite.config.ts` 的现役模块白名单及 ESLint 范围,开发代理显式转发 `/api/creators`。页面深链同步三份 nginx 模板与 Pingora 路由清单。
|
||||
- 浏览器历史条目仅保存 URL、访问者、已加载页数和滚动位置,不持久化关系列表或暂留行;新导航清除继承的恢复标记。同一舞台切换用户、页签以及返回时,应用重读 URL 并重新渲染。
|
||||
@@ -179,4 +180,6 @@ git diff --check
|
||||
|
||||
可重复浏览器脚本为 `scripts/test-creator-web.cjs`:先运行隔离后端脚本、再用仓库 `dev:web` 启动同库并把 `RUST_SERVER_TARGET` 指向该隔离 API;脚本从 `.app/dev-stack.json` 读取实际端口,仅接受 `creator-hub-validation` 和 loopback。通过 `CREATOR_PLAYWRIGHT_DIR` 指向临时 Playwright 安装,`PLAYWRIGHT_BROWSERS_PATH` 指向临时浏览器目录。截图输出到临时目录,不提交登录信息、数据库或构建产物。
|
||||
|
||||
返回循环修复的定向验证:共享控件、创作者页面、平台导航与路由共 54 项测试通过,类型、定向 ESLint、编码与文档索引检查通过。真实浏览器确认裸主页点击本人资料不改变地址、返回到游戏广场;直接打开关系列表后连续返回依次到所属主页和广场;加载两页 40 行后进入用户主页,原生返回恢复全部行及滚动位置。
|
||||
|
||||
验证边界:浏览器使用真实 API/SpacetimeDB;超时、旧请求与账号切换由可控前端测试覆盖,没有声称做真实断网注入。游戏夹具无发行包,不验证游玩加载;未部署生产,未用真实用户数据。本次交付按用户指令提交并推送,生产部署另行执行。
|
||||
|
||||
@@ -575,6 +575,8 @@
|
||||
- 移动端为“游戏 / 创作者主页 / 我的”,保留创作、项目的现有桌面限制,不把五项桌面入口强行塞入底部导航。
|
||||
- 主页路由 `/creators?id=<userId>`;裸 `/creators` 表示自己的主页,登录后解析当前账号。显式 ID 深链可刷新、分享及直接访问,自己的主页与他人的主页共用页面。
|
||||
- 列表路由 `/creators/connections?id=<userId>&tab=following|followers`,关注数和粉丝数分别进入对应列表;返回恢复来源页及分页/滚动位置,直接访问的返回兜底为所属主页。
|
||||
- 主页和关系列表的返回优先使用应用内浏览器历史;没有可回退的应用内历史时,列表替换当前条目到所属主页,主页替换当前条目到游戏广场 `/games`。兜底返回不得新增历史条目,避免连续返回在主页地址之间循环。
|
||||
- 主页顶部的头像、昵称和陶泥号只展示当前作者资料,不链接到当前主页;游戏详情及关系列表的作者链接保持可点击,包括当前登录用户本人。
|
||||
- 主页展示头像、昵称、陶泥号、关注按钮、`关注数 | 粉丝数` 和公开游戏列表。没有游戏或关系时显示真实空态,接口失败展示重试,不以 0 或空列表冒充成功。
|
||||
- 他人主页按钮为“关注”或“已关注”,点击“已关注”执行取消关注;自己的主页隐藏该按钮,不允许关注自己。按钮的可访问名称明确表达“取消关注”。
|
||||
- 游戏详情的作者头像/名称可点击进入主页,包括本人;作者旁增加同一套关注按钮。作者链接和关注按钮是独立点击目标,不因关注同时触发跳转;自己的游戏隐藏关注按钮。
|
||||
|
||||
@@ -4,6 +4,22 @@ import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CreatorFollowButton, CreatorUserRow } from './index';
|
||||
afterEach(cleanup);
|
||||
it('无链接的资料行正常展示资料和独立操作,不产生导航焦点', () => {
|
||||
const follow = vi.fn();
|
||||
render(
|
||||
<CreatorUserRow
|
||||
name="当前作者"
|
||||
subtitle="ID 10001"
|
||||
actions={<CreatorFollowButton followed={false} onClick={follow} />}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('当前作者').closest('a, button, [tabindex]'),
|
||||
).toBeNull();
|
||||
expect(screen.getByText('ID 10001')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关注' }));
|
||||
expect(follow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('用户链接与关注按钮互相独立,长昵称完整且不嵌套交互元素', () => {
|
||||
const navigate = vi.fn(),
|
||||
follow = vi.fn();
|
||||
|
||||
@@ -41,25 +41,34 @@ export function CreatorUserRow({
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
subtitle?: string;
|
||||
href: string;
|
||||
href?: string;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="creator-avatar" aria-hidden="true">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
Array.from(name)[0] || '创'
|
||||
)}
|
||||
</span>
|
||||
<span className="creator-user-copy">
|
||||
<strong>{name}</strong>
|
||||
{subtitle && <small>{subtitle}</small>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="creator-user-row">
|
||||
<a className="creator-user-link" href={href} onClick={onClick}>
|
||||
<span className="creator-avatar" aria-hidden="true">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
Array.from(name)[0] || '创'
|
||||
)}
|
||||
</span>
|
||||
<span className="creator-user-copy">
|
||||
<strong>{name}</strong>
|
||||
{subtitle && <small>{subtitle}</small>}
|
||||
</span>
|
||||
</a>
|
||||
{href ? (
|
||||
<a className="creator-user-link" href={href} onClick={onClick}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="creator-user-link">{content}</div>
|
||||
)}
|
||||
{actions && <div className="creator-user-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { CreatorProfile } from '../../../packages/shared/src/contracts/crea
|
||||
import {
|
||||
connectionsPath,
|
||||
type CreatorConnectionKind,
|
||||
creatorPath,
|
||||
getCreator,
|
||||
} from '../../services/creatorProfileClient';
|
||||
import {
|
||||
@@ -128,7 +127,6 @@ function CreatorProfileContent({
|
||||
name={profile.user.displayName}
|
||||
avatarUrl={profile.user.avatarUrl}
|
||||
subtitle={`ID ${profile.user.publicUserCode}`}
|
||||
href={creatorPath(id)}
|
||||
actions={<CreatorRelationshipControl target={id} />}
|
||||
/>
|
||||
<nav className="creator-counts" aria-label="关注与粉丝">
|
||||
|
||||
@@ -304,6 +304,33 @@ it('同账号切换主页,迟到资料不覆盖新目标;关系读取失败
|
||||
followerCount: 2,
|
||||
});
|
||||
});
|
||||
expect(screen.getByRole('link', { name: /新的创作者/ })).toBeTruthy();
|
||||
expect(screen.getByText('新的创作者')).toBeTruthy();
|
||||
expect(screen.queryByText('旧作者')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([null, 'a', 'b'])(
|
||||
'主页 %s 顶部资料不自跳转,关系入口仍可用',
|
||||
async (id) => {
|
||||
const target = id ?? 'a';
|
||||
api.profile.mockResolvedValue({
|
||||
user: { ...user, id: target },
|
||||
followingCount: 1,
|
||||
followerCount: 2,
|
||||
});
|
||||
render(
|
||||
<CreatorPage
|
||||
id={id}
|
||||
onOpenConnections={callbacks.onOpenConnections}
|
||||
onOpenGame={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const name = await screen.findByText('用户乙');
|
||||
expect(name.closest('a, button, [tabindex]')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('link', { name: '1 关注' }));
|
||||
expect(callbacks.onOpenConnections).toHaveBeenCalledWith(
|
||||
target,
|
||||
'following',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,7 +12,10 @@ import { type ReactElement, type ReactNode, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import { pushAppHistoryPath } from '../../routing/activeAppPageRoutes';
|
||||
import {
|
||||
hasAppHistoryBackEntry,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/activeAppPageRoutes';
|
||||
import {
|
||||
usePlatformWalletLifecycle,
|
||||
usePlatformWalletStore,
|
||||
@@ -63,6 +66,16 @@ const gameDistributionMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
|
||||
|
||||
const creatorClientMock = vi.hoisted(() => ({
|
||||
getCreator: vi.fn(),
|
||||
getCreatorConnections: vi.fn(),
|
||||
getCreatorRelationship: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/creatorProfileClient', async (original) => ({
|
||||
...(await original<typeof import('../../services/creatorProfileClient')>()),
|
||||
...creatorClientMock,
|
||||
}));
|
||||
|
||||
const frontendRuntimeConfigMock = vi.hoisted(() => ({
|
||||
loadFrontendRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
@@ -944,6 +957,112 @@ describe('游戏发布灰度入口', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('创作者页面返回', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(Element.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
gameDistributionMock.listGames.mockResolvedValue([]);
|
||||
creatorClientMock.getCreator.mockResolvedValue({
|
||||
user: authUiMock.value.user,
|
||||
followingCount: 0,
|
||||
followerCount: 0,
|
||||
});
|
||||
creatorClientMock.getCreatorConnections.mockResolvedValue({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
creatorClientMock.getCreatorRelationship.mockResolvedValue({
|
||||
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['/creators', '/creators?id=user-1', '/creators?id=user-2'])(
|
||||
'直接打开 %s 后返回替换为游戏广场,不增加历史',
|
||||
async (path) => {
|
||||
window.history.replaceState(null, '', path);
|
||||
const historyLength = window.history.length;
|
||||
const setSelectionStage = vi.fn();
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creator-home"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
||||
expect(window.location.pathname).toBe('/games');
|
||||
expect(window.location.search).toBe('');
|
||||
expect(window.history.length).toBe(historyLength);
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
expect(setSelectionStage).toHaveBeenCalledWith('games', {
|
||||
path: '/games',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['following', 'followers'])(
|
||||
'直接打开 %s 列表连续返回:所属主页、游戏广场,均不增加历史',
|
||||
async (tab) => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/creators/connections?id=user-2&tab=${tab}`,
|
||||
);
|
||||
const historyLength = window.history.length;
|
||||
render(
|
||||
<StatefulPlatformEntryFlowShell initialStage="creator-connections" />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
||||
await screen.findByRole('heading', { name: '创作者主页' });
|
||||
expect(window.location.pathname + window.location.search).toBe(
|
||||
'/creators?id=user-2',
|
||||
);
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(window.location.pathname).toBe('/games');
|
||||
expect(window.history.length).toBe(historyLength);
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/games/detail?id=game-1',
|
||||
'/creators/connections?id=user-1&tab=following',
|
||||
])('从 %s 进入主页后使用原生返回,不覆盖来源', async (source) => {
|
||||
window.history.replaceState(null, '', source);
|
||||
pushAppHistoryPath('/creators?id=user-2');
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
try {
|
||||
const setSelectionStage = vi.fn();
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creator-home"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(setSelectionStage).not.toHaveBeenCalled();
|
||||
expect(window.location.search).toBe('?id=user-2');
|
||||
} finally {
|
||||
back.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('游戏详情返回', () => {
|
||||
function mockDetailGame() {
|
||||
gameDistributionMock.getGame.mockResolvedValue({
|
||||
|
||||
@@ -466,8 +466,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectionStage === 'creator-connections'
|
||||
? new URLSearchParams(window.location.search).get('id')
|
||||
: null;
|
||||
openCreator(owner ?? undefined);
|
||||
}, [openCreator, selectionStage]);
|
||||
// 兜底返回替换当前条目,避免主页深链与本人主页互相新增历史。
|
||||
const path = owner ? creatorPath(owner) : '/games';
|
||||
replaceAppHistoryPath(path);
|
||||
setSelectionStage(owner ? 'creator-home' : 'games', { path });
|
||||
}, [selectionStage, setSelectionStage]);
|
||||
const openGames = useCallback(() => {
|
||||
setSelectionStage('games', { path: '/games' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
Reference in New Issue
Block a user