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({