修复作者昵称降级和关系列表无效链接
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m45s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m42s
Project CI / Backend tests (pull_request) Successful in 8m33s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m59s
Project CI / Repository checks (pull_request) Successful in 5m3s
Project CI / Native shell tests (pull_request) Successful in 6m49s

作者昵称仅使用补查结果,空名保留可访问的主页链接与关注操作
严格校验关系列表 tab,无效链接不请求列表并保留返回入口
纠正作者插槽注释,补充定向测试并同步工程文档和排障记忆
This commit is contained in:
2026-10-05 21:29:20 +08:00
parent bdcf67cb5f
commit 0c8554d113
9 changed files with 131 additions and 12 deletions
@@ -2,6 +2,11 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-05 自定义作者插槽应保留昵称降级语义
- 游戏公开投影里的「创作者」「未知作者」是角色占位词。作者昵称 hook 已将加载中和查询失败分别转成 null 与空串,宿主不能再用 `|| game.author.name` 把占位词补回。
- 替换共享详情组件的作者插槽时,同时接管了默认作者行的空名降级;只展示解析后的昵称,空名仍保留有可访问名称的主页入口,关注操作按作者 ID 和关系状态控制。回归覆盖补查等待、成功、空名和失败。
## 2026-10-05 页面返回兜底不能新增历史条目
- 普通链接整页打开的条目可能没有应用导航标记;此时返回若调用 `pushAppHistoryPath`,再点返回就会退回原深链,形成循环。无应用内历史的返回应使用 `replaceAppHistoryPath`,有历史时保留原生后退。
@@ -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 补查接口、不比对访问者自己的关注集合,也不使用预存关系缓存推导按钮。列表切换或账号切换后重新按主人/访问者身份确定模式;进入目标用户主页后,再按主页本身的规则读取关系和提供关注操作。
@@ -63,7 +63,12 @@ export function CreatorUserRow({
return (
<div className="creator-user-row">
{href ? (
<a className="creator-user-link" href={href} onClick={onClick}>
<a
className="creator-user-link"
href={href}
onClick={onClick}
aria-label={name ? undefined : '查看创作者主页'}
>
{content}
</a>
) : (
@@ -56,8 +56,9 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
/** 缺省时不显示游玩次数。 */
playCount?: number;
};
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
/** 替换默认作者行;缺省时按 game.author.name 展示。 */
authorContent?: ReactNode;
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
controlsLabel?: string;
/** 「当前版本」文案;缺省 —。 */
versionLabel?: string;
@@ -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 <p role="alert">缺少创作者 ID</p>;
if (!props.id || !props.tab)
return (
<div className="creator-page">
<button type="button" className="creator-follow" onClick={props.onBack}>
返回
</button>
<p role="alert">{!props.id ? '缺少创作者 ID' : '无效的关系列表链接'}</p>
</div>
);
return (
<ConnectionsContent
key={`${viewer}:${props.id}:${props.tab}`}
{...props}
id={props.id}
tab={props.tab}
viewer={viewer}
/>
);
@@ -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<HTMLElement>(null);
const locationKey = window.location.pathname + window.location.search;
@@ -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({
<CreatorAuthor
user={{
id: game.author.id,
displayName: authorName || game.author.name || '创作者',
displayName: authorName ?? '',
avatarUrl: game.author.avatarUrl ?? null,
publicUserCode: '',
}}
@@ -18,6 +18,7 @@ import type {
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GamePlayPage } from './GamePlayPage';
@@ -31,6 +32,19 @@ const recordGamePlayMock = vi.hoisted(() => 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<typeof import('../../services/authService')>()),
getPublicAuthUserById: getPublicAuthUserByIdMock,
}));
vi.mock('../../services/creatorProfileClient', async (original) => ({
...(await original<typeof import('../../services/creatorProfileClient')>()),
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(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCreator={onOpenCreator}
/>,
);
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(
@@ -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(
<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) => {
@@ -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({
<CreatorConnectionsPage
id={gameId}
tab={
gameSearchParams.get('tab') === 'followers'
? 'followers'
: 'following'
creatorConnectionTab === 'following' ||
creatorConnectionTab === 'followers'
? creatorConnectionTab
: null
}
onOpenCreator={openCreator}
onOpenConnections={openCreatorConnections}