补充创作者主页与关注粉丝工程文档 (#638)
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s

本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。

桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。

工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。

验证:
- 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。
- npm run check:encoding 通过。
- npm run check:doc-index 通过。
- git diff --check 与暂存差异检查通过。
- 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。

Reviewed-on: #638
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #638.
This commit is contained in:
2026-10-05 22:05:11 +08:00
committed by 孔令弘
parent 96859666ff
commit 51cb05f418
100 changed files with 10581 additions and 5269 deletions
@@ -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(),
}));
@@ -456,7 +469,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '游戏', '我的']);
).toEqual(['创作', '项目', '游戏', '创作者主页', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '创作' })
@@ -522,7 +535,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['游戏', '我的']);
).toEqual(['游戏', '创作者主页', '我的']);
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
expect(
@@ -607,7 +620,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['游戏', '我的']);
).toEqual(['游戏', '创作者主页', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '我的' })
@@ -944,6 +957,136 @@ 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(['', '&tab=', '&tab=invalid'])(
'关系列表拒绝无效 tab:%s,不请求列表且仍可返回',
async (query) => {
creatorClientMock.getCreatorConnections.mockClear();
window.history.replaceState(
null,
'',
`/creators/connections?id=user-2${query}`,
);
render(
<StatefulPlatformEntryFlowShell initialStage="creator-connections" />,
);
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) => {
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({