共创模块C: 我的作品分类标签(全部/原创/我发布的共创/草稿) + 世代与子衍生数 + 数据概览
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-06 17:16:58 +08:00
parent b99dcb7ef2
commit 3e3e009b11
2 changed files with 142 additions and 4 deletions
@@ -763,6 +763,58 @@ test('衍生列表读取失败时行内给出错误与重试', async () => {
).toBeTruthy();
});
test('分类标签按文档口径过滤,并给出总作品数 / 累计被 Fork 次数 / 世代数', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ id: 'g-original', title: '母版甲', status: 'published', forkCount: 2 }),
createGame({
id: 'g-fork',
title: '共创乙',
status: 'published',
forkCount: 1,
lineage: {
generation: 1,
rootGameId: 'g-original',
rootTitle: '母版甲',
parentGameId: 'g-original',
parentTitle: '母版甲',
parentAuthorName: '原作者',
},
}),
createGame({ id: 'g-draft', title: '草稿丙', status: 'unpublished', forkCount: 0 }),
]);
renderPage(createAuthValue());
// 数据概览:总作品数 + 累计被 Fork 次数(世代数字段缺失 → 该项不渲染)。
expect(await screen.findByText('总作品数 3')).toBeTruthy();
expect(screen.getByText('累计被 Fork 次数 3')).toBeTruthy();
expect(screen.queryByText(/累计衍生世代数/u)).toBeNull();
// 卡片上的世代数与子衍生作品数量。
expect(screen.getByText('子衍生作品 2')).toBeTruthy();
expect(screen.getByText('子衍生作品 1')).toBeTruthy();
// 分类:原创作品只看无父作品的;我发布的共创只看有父且已公开的;草稿只看非公开的。
fireEvent.click(screen.getByRole('tab', { name: '原创作品' }));
expect(screen.getByText('母版甲')).toBeTruthy();
expect(screen.queryByText('共创乙')).toBeNull();
expect(screen.queryByText('草稿丙')).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '我发布的共创' }));
expect(screen.getByText('共创乙')).toBeTruthy();
expect(screen.queryByText('母版甲')).toBeNull();
expect(screen.queryByText('草稿丙')).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '我的草稿' }));
expect(screen.getByText('草稿丙')).toBeTruthy();
expect(screen.queryByText('母版甲')).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '全部作品' }));
expect(screen.getByText('母版甲')).toBeTruthy();
expect(screen.getByText('共创乙')).toBeTruthy();
expect(screen.getByText('草稿丙')).toBeTruthy();
});
test('未公开作品不渲染「被改编 N」入口', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ status: 'unpublished', forkCount: 0 }),
@@ -34,6 +34,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { coCreationGenerationShortLabel } from './coCreationCopy';
import { GameCover } from './GameCover';
import {
type GamePublishDraft,
@@ -221,6 +222,15 @@ export function MyGamesPage({
* 不要求作者身份,门禁只要求登录,与这一页的既有门禁一致。
*/
const [tab, setTab] = useState<'works' | 'collections'>('works');
/**
* 「我的作品」下的分类标签(文档:全部作品 / 原创作品 / 我发布的共创 / 我的草稿)。
*
* 口径说明:原创 = 没有父作品(`lineage` 缺省或 generation 0);我发布的共创 = 有父作品
* 且已公开;我的草稿 = 状态非 `published`(与卡片上的状态标签同一口径)。
*/
const [workCategory, setWorkCategory] = useState<
'all' | 'original' | 'coCreated' | 'draft'
>('all');
const [collections, setCollections] = useState<GameDistributionGame[]>([]);
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [collectionsError, setCollectionsError] = useState('');
@@ -330,7 +340,40 @@ export function MyGamesPage({
}),
[games, statusFilter, versionFilter],
);
const hasActiveFilter = statusFilter !== 'all' || versionFilter !== 'all';
/** 分类标签(文档口径):原创 = 无父作品;我发布的共创 = 有父作品且已公开;草稿 = 非公开。 */
const categoryGames = useMemo(() => {
if (workCategory === 'all') return visibleGames;
return visibleGames.filter((game) => {
const isCoCreated = (game.lineage?.generation ?? 0) > 0;
// 三个分类互斥:草稿优先(非公开),其余按是否二创分原创 / 共创。
if (workCategory === 'draft') return game.status !== 'published';
if (game.status !== 'published') return false;
return workCategory === 'original' ? !isCoCreated : isCoCreated;
});
}, [visibleGames, workCategory]);
const totalForkCount = useMemo(
() => games.reduce((sum, game) => sum + (game.forkCount ?? 0), 0),
[games],
);
/**
* 累计衍生世代数:`my-games` 目前没有这个字段(后端待补),因此**有才渲染**,
* 缺失时概览少一项而不是拿别的数字凑。
*/
const totalDerivedGenerations = useMemo(() => {
const values = games
.map((game) =>
'descendantGenerationCount' in game &&
typeof game.descendantGenerationCount === 'number'
? game.descendantGenerationCount
: null,
)
.filter((value): value is number => value !== null);
return values.length > 0
? values.reduce((sum, value) => sum + value, 0)
: null;
}, [games]);
const hasActiveFilter =
statusFilter !== 'all' || versionFilter !== 'all' || workCategory !== 'all';
function resolveLatestRecoveryAction(game: GameDistributionMyGame) {
const latest = game.latestVersion;
@@ -645,6 +688,42 @@ export function MyGamesPage({
{tab === 'works' ? (
<>
{/*
分类标签 + 数据概览(文档「个人二创作品管理页」)。
概览里的「累计衍生世代数」等后端字段存在时才渲染,缺失不拿别的数字凑。
*/}
<div className="game-tabs" role="tablist" aria-label="我的作品分类">
{(
[
['all', '全部作品'],
['original', '原创作品'],
['coCreated', '我发布的共创'],
['draft', '我的草稿'],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={workCategory === key}
className="game-tabs__button"
onClick={() => setWorkCategory(key)}
>
{label}
</button>
))}
</div>
<div className="co-creation-overview" aria-label="创作数据概览">
<span className="co-creation-stat">总作品数 {games.length}</span>
<span className="co-creation-stat">
累计被 Fork 次数 {totalForkCount}
</span>
{totalDerivedGenerations === null ? null : (
<span className="co-creation-stat">
累计衍生世代数 {totalDerivedGenerations}
</span>
)}
</div>
{error ? (
<PlatformStatusMessage tone="error" surface="platform">
{error}
@@ -729,16 +808,16 @@ export function MyGamesPage({
{canLoad &&
games.length > 0 &&
visibleGames.length === 0 &&
categoryGames.length === 0 &&
!isLoading ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>没有符合当前筛选条件的作品。</span>
</PlatformEmptyState>
) : null}
{canLoad && visibleGames.length > 0 ? (
{canLoad && categoryGames.length > 0 ? (
<div className="my-game-list">
{visibleGames.map((game) => {
{categoryGames.map((game) => {
const version = resolveVersionStatus(game);
const latestVersion = game.latestVersion;
const actionCopy = resolveLatestActionCopy(game);
@@ -813,6 +892,13 @@ export function MyGamesPage({
) : null}
<span>·</span>
<span>共创:{forkAuthorizationLabel}</span>
{/* 文档要求卡片显示世代数与子衍生作品数量(世代取血缘 generation)。 */}
<span>·</span>
<span>
{coCreationGenerationShortLabel(game.lineage?.generation ?? 0)}
</span>
<span>·</span>
<span>子衍生作品 {forkCount}</span>
</div>
{version?.reviewReason ? (
<p className="my-game-card__reason">