From 0c8554d1137d7b55c2821dc4288059632c2289ad Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 5 Oct 2026 21:29:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BD=9C=E8=80=85=E6=98=B5?= =?UTF-8?q?=E7=A7=B0=E9=99=8D=E7=BA=A7=E5=92=8C=E5=85=B3=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=97=A0=E6=95=88=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 作者昵称仅使用补查结果,空名保留可访问的主页链接与关注操作 严格校验关系列表 tab,无效链接不请求列表并保留返回入口 纠正作者插槽注释,补充定向测试并同步工程文档和排障记忆 --- docs/project-memory/shared-memory/pitfalls.md | 5 ++ ...案】创作者主页与关注粉丝工程设计-2026-10-05.md | 3 +- .../src/components/CreatorControls/index.tsx | 7 +- .../components/GameDetailDisplay/index.tsx | 3 +- .../creator/CreatorConnectionsPage.tsx | 15 +++- .../game-distribution/GameDetailPage.tsx | 4 +- .../GameDistributionPages.test.tsx | 72 +++++++++++++++++++ .../PlatformEntryActiveFlowShell.test.tsx | 26 ++++++- .../PlatformEntryActiveFlowShell.tsx | 8 ++- 9 files changed, 131 insertions(+), 12 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index eaa166ee0..9cbc28991 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,11 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-05 自定义作者插槽应保留昵称降级语义 + +- 游戏公开投影里的「创作者」「未知作者」是角色占位词。作者昵称 hook 已将加载中和查询失败分别转成 null 与空串,宿主不能再用 `|| game.author.name` 把占位词补回。 +- 替换共享详情组件的作者插槽时,同时接管了默认作者行的空名降级;只展示解析后的昵称,空名仍保留有可访问名称的主页入口,关注操作按作者 ID 和关系状态控制。回归覆盖补查等待、成功、空名和失败。 + ## 2026-10-05 页面返回兜底不能新增历史条目 - 普通链接整页打开的条目可能没有应用导航标记;此时返回若调用 `pushAppHistoryPath`,再点返回就会退回原深链,形成循环。无应用内历史的返回应使用 `replaceAppHistoryPath`,有历史时保留原生后退。 diff --git a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md index 47c8f2689..a10d7b7e9 100644 --- a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md +++ b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md @@ -102,7 +102,8 @@ type CreatorRelationshipResponse = { ## 前端状态与组件责任 -- 主页 `/creators` 解析当前账号,`/creators?id=...` 读取指定公开用户;关系列表用 `/creators/connections?id=...&tab=following|followers`,缺主人或非法 tab 展示明确无效链接状态。显式他人 ID 不因登录切换改成自己的主页。 +- 主页 `/creators` 解析当前账号,`/creators?id=...` 读取指定公开用户;关系列表用 `/creators/connections?id=...&tab=following|followers`,缺主人或非法 tab 展示明确无效链接状态。tab 缺省、空串及其他值均无效,不请求关系列表,并保留返回入口。显式他人 ID 不因登录切换改成自己的主页。 +- 游戏详情作者昵称只使用 `useGameAuthorDisplayName` 的解析结果,加载中或查询失败时不回退到公开投影的角色占位词;保留头像与可访问的主页链接,关注操作继续按作者 ID 和关系状态控制,不依赖昵称补查成功。 - 关系状态按访问者 ID、目标 ID 隔离。业务 hook/client 负责请求、写入单飞、失败回读和失效;共享关注按钮、用户行只接收数据、pending 和回调,不直接持有认证或网络副作用。 - 自己/他人的关注列表、粉丝列表四种组合均支持头像/昵称进入任意用户主页;用独立链接和按钮避免嵌套交互。列表用户为自己时隐藏关注按钮,主页仍可访问。 - 他人的关注、粉丝列表统一使用只读用户行,不挂载关系动作或触发关系 hook,不调用 relationship 补查接口、不比对访问者自己的关注集合,也不使用预存关系缓存推导按钮。列表切换或账号切换后重新按主人/访问者身份确定模式;进入目标用户主页后,再按主页本身的规则读取关系和提供关注操作。 diff --git a/packages/shared/src/components/CreatorControls/index.tsx b/packages/shared/src/components/CreatorControls/index.tsx index 9ffe8129d..3c3acf8ae 100644 --- a/packages/shared/src/components/CreatorControls/index.tsx +++ b/packages/shared/src/components/CreatorControls/index.tsx @@ -63,7 +63,12 @@ export function CreatorUserRow({ return (
{href ? ( - + {content} ) : ( diff --git a/packages/shared/src/components/GameDetailDisplay/index.tsx b/packages/shared/src/components/GameDetailDisplay/index.tsx index 7d555cd27..fe0f5104f 100644 --- a/packages/shared/src/components/GameDetailDisplay/index.tsx +++ b/packages/shared/src/components/GameDetailDisplay/index.tsx @@ -56,8 +56,9 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps { /** 缺省时不显示游玩次数。 */ playCount?: number; }; - /** 「游玩方式」文案;缺省「未标注操作方式」。 */ + /** 替换默认作者行;缺省时按 game.author.name 展示。 */ authorContent?: ReactNode; + /** 「游玩方式」文案;缺省「未标注操作方式」。 */ controlsLabel?: string; /** 「当前版本」文案;缺省 —。 */ versionLabel?: string; diff --git a/src/components/creator/CreatorConnectionsPage.tsx b/src/components/creator/CreatorConnectionsPage.tsx index 8535a68ba..83432f987 100644 --- a/src/components/creator/CreatorConnectionsPage.tsx +++ b/src/components/creator/CreatorConnectionsPage.tsx @@ -21,7 +21,7 @@ import { CREATOR_CHANGED_EVENT } from './creatorRelationshipState'; type Props = { id: string | null; - tab: CreatorConnectionKind; + tab: CreatorConnectionKind | null; onOpenCreator: (id: string) => void; onOpenConnections: (id: string, tab: CreatorConnectionKind) => void; onBack: () => void; @@ -29,12 +29,21 @@ type Props = { export function CreatorConnectionsPage(props: Props) { const auth = useAuthUi(); const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null; - if (!props.id) return

缺少创作者 ID

; + if (!props.id || !props.tab) + return ( +
+ +

{!props.id ? '缺少创作者 ID' : '无效的关系列表链接'}

+
+ ); return ( ); @@ -46,7 +55,7 @@ function ConnectionsContent({ onOpenCreator, onOpenConnections, onBack, -}: Props & { id: string; viewer: string | null }) { +}: Props & { id: string; tab: CreatorConnectionKind; viewer: string | null }) { const own = viewer === id; const root = useRef(null); const locationKey = window.location.pathname + window.location.search; diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index a6e5e2061..6d04e8409 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -301,7 +301,7 @@ function GameDetailContent({ const reviews = useGameReviews(game.id, canReview, publiclyVisible); /** * 作者展示名:公开投影只会退化成占位角色词,真实昵称按作者 ID 补查一次 - * (见 useGameAuthorName);查不到时给空串,展示层就不渲染「由 X 制作」那一行。 + * (见 useGameAuthorName);查不到时不显示昵称,仍保留头像、主页入口和关注操作。 */ const authorName = useGameAuthorDisplayName(game.author); const displayGame = { @@ -335,7 +335,7 @@ function GameDetailContent({ vi.fn()); const resolvedAssetUrlMock = vi.hoisted(() => vi.fn()); const listGameReviewsMock = vi.hoisted(() => vi.fn()); const getMyGameReviewMock = vi.hoisted(() => vi.fn()); +const getPublicAuthUserByIdMock = vi.hoisted(() => vi.fn()); +const getCreatorRelationshipMock = vi.hoisted(() => vi.fn()); +const changeCreatorRelationshipMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/authService', async (original) => ({ + ...(await original()), + getPublicAuthUserById: getPublicAuthUserByIdMock, +})); +vi.mock('../../services/creatorProfileClient', async (original) => ({ + ...(await original()), + getCreatorRelationship: getCreatorRelationshipMock, + changeCreatorRelationship: changeCreatorRelationshipMock, +})); vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, @@ -129,6 +143,13 @@ afterEach(() => { beforeEach(() => { vi.clearAllMocks(); + setCreatorViewer(null); + getCreatorRelationshipMock.mockResolvedValue({ + relationship: { isSelf: false, isFollowing: false, isFollowedBy: false }, + }); + changeCreatorRelationshipMock.mockResolvedValue({ + relationship: { isSelf: false, isFollowing: true, isFollowedBy: false }, + }); setViewportWidth(1024); listGamesMock.mockResolvedValue([]); getGameMock.mockResolvedValue(createGame()); @@ -188,6 +209,57 @@ describe('GameGalleryPage', () => { }); describe('GameDetailPage', () => { + it.each([ + ['创作者', '成功'], + ['未知作者', '空名'], + ['创作者', '失败'], + ])( + '作者为占位词 %s 时,补查%s均不闪现伪昵称且保留作者操作', + async (name, outcome) => { + const lookup = deferred<{ displayName: string }>(); + getPublicAuthUserByIdMock.mockReturnValueOnce(lookup.promise); + getGameMock.mockResolvedValueOnce( + createGame({ author: { id: 'author-2', name } }), + ); + const onOpenCreator = vi.fn(); + render( + , + ); + const link = await screen.findByRole('link', { name: '查看创作者主页' }); + expect(link.getAttribute('href')).toBe('/creators?id=author-2'); + expect(link.querySelector('strong')?.textContent).toBe(''); + expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2'); + await waitFor(() => { + expect( + (screen.getByRole('button', { name: '关注' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }); + await act(async () => { + if (outcome === '失败') lookup.reject(new Error('查询失败')); + else + lookup.resolve({ displayName: outcome === '成功' ? '真实昵称' : '' }); + }); + expect(link.querySelector('strong')?.textContent).toBe( + outcome === '成功' ? '真实昵称' : '', + ); + expect(screen.queryByText(name, { exact: true })).toBeNull(); + fireEvent.click(link); + expect(onOpenCreator).toHaveBeenCalledWith('author-2'); + fireEvent.click(screen.getByRole('button', { name: '关注' })); + await screen.findByRole('button', { name: '取消关注' }); + expect(changeCreatorRelationshipMock).toHaveBeenCalledWith( + 'author-2', + 'follow', + ); + }, + ); + it('blocks desktop-only games on a mobile viewport', async () => { setViewportWidth(375); getGameMock.mockResolvedValueOnce( diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index bed374949..e515f58c9 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -957,7 +957,7 @@ describe('游戏发布灰度入口', () => { }); }); -describe('创作者页面返回', () => { +describe('创作者页面深链与返回', () => { beforeEach(() => { Object.defineProperty(Element.prototype, 'scrollTo', { configurable: true, @@ -990,6 +990,30 @@ describe('创作者页面返回', () => { }); }); + it.each(['', '&tab=', '&tab=invalid'])( + '关系列表拒绝无效 tab:%s,不请求列表且仍可返回', + async (query) => { + creatorClientMock.getCreatorConnections.mockClear(); + window.history.replaceState( + null, + '', + `/creators/connections?id=user-2${query}`, + ); + render( + , + ); + expect((await screen.findByRole('alert')).textContent).toBe( + '无效的关系列表链接', + ); + expect(creatorClientMock.getCreatorConnections).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '返回' })); + await screen.findByRole('heading', { name: '创作者主页' }); + expect(window.location.pathname + window.location.search).toBe( + '/creators?id=user-2', + ); + }, + ); + it.each(['/creators', '/creators?id=user-1', '/creators?id=user-2'])( '直接打开 %s 后返回替换为游戏广场,不增加历史', async (path) => { diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 47ae83d1b..d2050a738 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -680,6 +680,7 @@ export function PlatformEntryFlowShellImpl({ typeof window === 'undefined' ? '' : window.location.search, ); const gameId = gameSearchParams.get('id'); + const creatorConnectionTab = gameSearchParams.get('tab'); const publishGameId = gameSearchParams.get('game'); const avatarUrl = authUi?.user?.avatarUrl?.trim() || null; const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user); @@ -910,9 +911,10 @@ export function PlatformEntryFlowShellImpl({