修复作者昵称降级和关系列表无效链接
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
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user