补充创作者主页与关注粉丝工程文档 (#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
@@ -0,0 +1,62 @@
/* @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { CreatorFollowButton, CreatorUserRow } from './index';
afterEach(cleanup);
it('无链接的资料行正常展示资料和独立操作,不产生导航焦点', () => {
const follow = vi.fn();
render(
<CreatorUserRow
name="当前作者"
subtitle="ID 10001"
actions={<CreatorFollowButton followed={false} onClick={follow} />}
/>,
);
expect(
screen.getByText('当前作者').closest('a, button, [tabindex]'),
).toBeNull();
expect(screen.getByText('ID 10001')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关注' }));
expect(follow).toHaveBeenCalledTimes(1);
});
it('用户链接与关注按钮互相独立,长昵称完整且不嵌套交互元素', () => {
const navigate = vi.fn(),
follow = vi.fn();
const name = '很长的创作者名字'.repeat(10);
render(
<CreatorUserRow
name={name}
href="/creators?id=b"
onClick={(event) => {
event.preventDefault();
navigate();
}}
actions={<CreatorFollowButton followed={false} onClick={follow} />}
/>,
);
const link = screen.getByRole('link', { name });
expect(link.querySelector('button')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '关注' }));
expect(follow).toHaveBeenCalledTimes(1);
expect(navigate).not.toHaveBeenCalled();
fireEvent.click(link);
expect(navigate).toHaveBeenCalledTimes(1);
});
it('写入过程中按钮不可重复触发', () => {
const click = vi.fn();
render(<CreatorFollowButton followed={false} pending onClick={click} />);
fireEvent.click(screen.getByRole('button'));
expect(click).not.toHaveBeenCalled();
});
it('已关注状态文案仍明确提供取消关注的可访问名称', () => {
const click = vi.fn();
render(
<CreatorFollowButton followed followedLabel="已关注" onClick={click} />,
);
const button = screen.getByRole('button', { name: '取消关注' });
expect(button.textContent).toBe('已关注');
fireEvent.click(button);
expect(click).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,80 @@
import './style.css';
import type { MouseEventHandler, ReactNode } from 'react';
export function CreatorFollowButton({
followed,
pending = false,
disabled = false,
followLabel = '关注',
followedLabel = '取消关注',
onClick,
}: {
followed: boolean;
pending?: boolean;
disabled?: boolean;
followLabel?: string;
followedLabel?: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={`creator-follow ${followed ? 'creator-follow--active' : ''}`}
disabled={disabled || pending}
aria-busy={pending}
aria-label={followed && !pending ? '取消关注' : undefined}
onClick={onClick}
>
{pending ? '处理中…' : followed ? followedLabel : followLabel}
</button>
);
}
export function CreatorUserRow({
name,
avatarUrl,
subtitle,
href,
onClick,
actions,
}: {
name: string;
avatarUrl?: string | null;
subtitle?: string;
href?: string;
onClick?: MouseEventHandler<HTMLAnchorElement>;
actions?: ReactNode;
}) {
const content = (
<>
<span className="creator-avatar" aria-hidden="true">
{avatarUrl ? (
<img src={avatarUrl} alt="" />
) : (
Array.from(name)[0] || '创'
)}
</span>
<span className="creator-user-copy">
<strong>{name}</strong>
{subtitle && <small>{subtitle}</small>}
</span>
</>
);
return (
<div className="creator-user-row">
{href ? (
<a
className="creator-user-link"
href={href}
onClick={onClick}
aria-label={name ? undefined : '查看创作者主页'}
>
{content}
</a>
) : (
<div className="creator-user-link">{content}</div>
)}
{actions && <div className="creator-user-actions">{actions}</div>}
</div>
);
}
@@ -0,0 +1,77 @@
.creator-user-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
min-width: 0;
}
.creator-user-link {
display: flex;
align-items: center;
gap: 0.75rem;
color: inherit;
text-decoration: none;
min-width: 0;
flex: 1;
min-height: 44px;
}
.creator-avatar {
display: grid;
place-items: center;
flex: none;
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
background: var(--platform-panel-fill, #eee);
border: 1px solid var(--platform-surface-border, #ccc);
}
.creator-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-user-copy {
display: grid;
min-width: 0;
gap: 0.25rem;
overflow-wrap: anywhere;
}
.creator-user-copy small {
color: var(--platform-text-soft);
}
.creator-user-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.creator-follow {
min-height: 44px;
padding: 0.5rem 1rem;
border: 1px solid var(--platform-surface-border, #ccc);
border-radius: 999px;
background: var(--platform-panel-fill, #fff);
color: inherit;
cursor: pointer;
white-space: nowrap;
}
.creator-follow:disabled {
opacity: 0.55;
cursor: default;
}
.creator-follow:focus-visible,
.creator-user-link:focus-visible {
outline: 2px solid #b85d3d;
outline-offset: 3px;
}
.creator-follow--active {
color: var(--platform-text-soft);
}
@media (max-width: 480px) {
.creator-user-row > .creator-user-link {
flex-basis: 100%;
}
.creator-user-row > .creator-user-actions {
margin-left: 60px;
}
}
@@ -0,0 +1,38 @@
import './style.css';
import type { ReactNode } from 'react';
import type { GameDistributionGame } from '../../contracts/gameDistribution';
import { GameRatingSummaryText } from '../GameRatingSummaryText';
export function GameCard({
game,
cover,
onOpen,
}: {
game: GameDistributionGame;
cover: ReactNode;
onOpen: () => void;
}) {
return (
<button type="button" className="game-card" onClick={onOpen}>
{cover}
<span className="game-card__body">
<span className="game-card__title-row">
<strong>{game.title}</strong>
<span className="game-card__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
</span>
<span className="game-card__summary">{game.summary}</span>
<span className="game-card__rating">
<GameRatingSummaryText summary={game.ratingSummary} />
</span>
<span className="game-card__meta">
<span>{game.category}</span>
<span>·</span>
<span>{game.author.name}</span>
</span>
</span>
</button>
);
}
@@ -0,0 +1,65 @@
.game-card {
display: block;
min-width: 0;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 1.45rem;
padding: 0;
background: var(--platform-panel-fill);
color: inherit;
cursor: pointer;
text-align: left;
box-shadow: var(--platform-panel-shadow);
transition: 180ms ease;
}
.game-card:hover {
border-color: rgba(199, 101, 61, 0.38);
box-shadow: 0 1rem 2.4rem rgba(104, 54, 36, 0.14);
transform: translateY(-3px);
}
.game-card__body {
display: block;
padding: 0.95rem 1rem 1.1rem;
}
.game-card__title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.game-card__title-row strong {
overflow: hidden;
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-card__play-count {
flex: none;
color: var(--platform-text-soft);
font-size: 0.66rem;
}
.game-card__summary {
display: block;
overflow: hidden;
margin-top: 0.42rem;
color: var(--platform-text-base);
font-size: 0.78rem;
line-height: 1.55;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-card__rating {
display: block;
margin-top: 0.42rem;
color: var(--platform-text-base);
font-size: 0.78rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.game-card__meta {
display: flex;
gap: 0.38rem;
margin-top: 0.85rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
}
@@ -56,6 +56,8 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
/** 缺省时不显示游玩次数。 */
playCount?: number;
};
/** 替换默认作者行;缺省时按 game.author.name 展示。 */
authorContent?: ReactNode;
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
controlsLabel?: string;
/** 「当前版本」文案;缺省 —。 */
@@ -199,6 +201,7 @@ export function GameDetailDisplay({
icon,
assetUrls,
renderAsset,
authorContent,
controlsLabel,
versionLabel,
infoCards,
@@ -234,14 +237,15 @@ export function GameDetailDisplay({
{rating ? (
<div className="game-detail-hero__rating">{rating}</div>
) : null}
{authorName ? (
<div className="game-detail-hero__author">
<span className="game-detail-avatar" aria-hidden="true">
{Array.from(authorName)[0]}
</span>
<span title={authorName}>由 {authorName} 制作</span>
</div>
) : null}
{authorContent ??
(authorName ? (
<div className="game-detail-hero__author">
<span className="game-detail-avatar" aria-hidden="true">
{Array.from(authorName)[0]}
</span>
<span title={authorName}>由 {authorName} 制作</span>
</div>
) : null)}
<div className="game-detail-hero__actions">
{actions}
{typeof game.playCount === 'number' ? (
+2
View File
@@ -259,3 +259,5 @@ export {
// 后台管理端 chrome:页面壳、面板、按钮、提示、表格与表单外壳。
// 只承载表现与交互,路由、鉴权和后台接口仍留在 apps/admin-web。
export * from './admin';
export { CreatorFollowButton, CreatorUserRow } from './CreatorControls';
export { GameCard } from './GameCard';
+37
View File
@@ -0,0 +1,37 @@
export interface CreatorUser {
id: string;
publicUserCode: string;
displayName: string;
avatarUrl: string | null;
}
export interface CreatorRelationship {
isSelf: boolean;
isFollowing: boolean;
isFollowedBy: boolean;
}
export interface CreatorProfile {
user: CreatorUser;
followingCount: number;
followerCount: number;
}
export interface CreatorConnection {
user: CreatorUser;
/** 列表方向的关系建立时间,RFC 3339。 */
followedAt: string;
/** 仅本人访问自己的列表时返回关系。 */
relationship: CreatorRelationship | null;
}
export interface CreatorConnections {
items: CreatorConnection[];
nextCursor: string | null;
total: number;
}
export interface CreatorRelationshipResponse {
userId: string;
relationship: CreatorRelationship;
}
+1
View File
@@ -1,3 +1,4 @@
export type * from './creator';
export * from './editorAgent';
export type * from './editorAudio';
export * from './gameCreationApp';