feat(agc): 共创卡片补显示作品自身游玩次数(客户端无树,不写合计口径)
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 / Frontend 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-07 12:34:24 +08:00
parent 94cd35e8f7
commit 76ee18133e
4 changed files with 115 additions and 5 deletions
@@ -81,6 +81,12 @@ export const FORK_STICKY_FOOTER =
/** 卡片里的「标签 → 数值」两列表:标签列定宽 + 数值列等宽数字,避免换行跳动。 */
export const FORK_META_GRID =
'grid grid-cols-[52px_minmax(0,1fr)] items-baseline gap-x-2 gap-y-1 text-[11px] leading-4';
/**
* 同上一套,但行距收到 2px:给「四行信息」(作者 / 类型 / 游玩 / 创建时间)的列表卡片用,
* 让多出来的一行不至于把卡片撑高。字号与颜色与 `FORK_META_GRID` 完全一致。
*/
export const FORK_META_GRID_COMPACT =
'grid grid-cols-[52px_minmax(0,1fr)] items-baseline gap-x-2 gap-y-0.5 text-[11px] leading-4';
export const FORK_META_LABEL = 'whitespace-nowrap text-(--platform-text-soft)';
export const FORK_META_VALUE =
'min-w-0 truncate whitespace-nowrap tabular-nums text-(--platform-neutral-text)';
@@ -62,6 +62,22 @@ function formatLocalDate(date: Date): string | null {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/**
* 卡片上的游玩次数文案:只格式化**该作品自身**的 `playCount`(公开目录投影保证下发)。
*
* 为什么是自身值:客户端拿不到血缘树(要树得逐作品拉 `/lineage`,列表页不能这么打接口),
* 因此**刻意与网页侧不同**——网页侧能按子树合计,客户端只能给自身值。文案只写「N 次」,
* 不出现「含衍生」这类会被读成合计的措辞。
*
* 缺失/非法(旧响应或字段被裁)返回 `null`,由调用方整行不渲染,不留恒为「—」的死行。
*/
export function formatCatalogPlayCount(value: unknown): string | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return `${Math.max(0, Math.floor(value)).toLocaleString('zh-CN')} 次`;
}
/** 支持共创 = 授权不是「禁止共创」;字段缺省按 `forbidden` 处理(与契约一致)。 */
export function isForkableGame(game: GameDistributionGame): boolean {
return (game.forkAuthorization ?? 'forbidden') !== 'forbidden';
@@ -20,7 +20,7 @@ import {
FORK_BODY_TEXT,
FORK_BUTTON_SECONDARY,
FORK_CARD,
FORK_META_GRID,
FORK_META_GRID_COMPACT,
FORK_META_LABEL,
FORK_META_VALUE,
FORK_PAGE_COLUMN,
@@ -34,6 +34,7 @@ import {
FORK_CATALOG_MAX_PAGES,
type ForkCatalogResult,
formatCatalogCreatedAt,
formatCatalogPlayCount,
loadForkableGames,
} from '../../features/platform-fork/platformForkCatalog';
import { usePlatformForkCovers } from '../../features/platform-fork/usePlatformForkCovers';
@@ -202,6 +203,7 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
{games.map((game) => {
const coverUrl = coverUrls[game.id];
const createdAt = formatCatalogCreatedAt(game.createdAt);
const selfPlayCountLabel = formatCatalogPlayCount(game.playCount);
return (
<article className="grid min-w-0" key={game.id}>
<div
@@ -227,11 +229,11 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
</span>
)}
</span>
<span className="grid min-w-0 content-start gap-2 py-1">
<span className="grid min-w-0 content-start gap-1 py-0.5">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-5 text-(--platform-text-strong)">
{game.title || '(无标题)'}
</strong>
<small className={FORK_META_GRID}>
<small className={FORK_META_GRID_COMPACT}>
<span className={FORK_META_LABEL}>作者</span>
<span className={FORK_META_VALUE}>
{game.author?.name || '—'}
@@ -240,6 +242,23 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
<span className={FORK_META_VALUE}>
{game.category || '—'}
</span>
{/*
游玩次数:**只显示该作品自身的计数**(`game.playCount`),不叠加任何子代。
客户端拿不到血缘树(要树得逐作品拉 `/lineage`,列表页不能这么打接口),
所以这里与网页端**刻意不同**——网页侧能按子树合计,客户端只能给自身值。
因此文案固定写「游玩 N 次」,**不写「含衍生」之类会被读成合计的措辞**:
这不是漏了聚合,是客户端没有聚合所需的数据(别当成 bug 修)。
字段由公开目录投影保证下发(api-server `public_game_payload` → `"playCount"`);
万一缺失/非法就整行不渲染,不留恒为「—」的死行。
*/}
{selfPlayCountLabel ? (
<>
<span className={FORK_META_LABEL}>游玩</span>
<span className={FORK_META_VALUE}>
{selfPlayCountLabel}
</span>
</>
) : null}
{/*
公开目录没有 `updatedAt`,只有 `createdAt`:标签就写「创建时间」,
取不到时整行不渲染(不留恒为「—」的死行)。
@@ -252,7 +271,7 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
) : null}
</small>
<button
className="mt-0.5 w-fit cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-2.5 py-1 text-[11px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55"
className="w-fit cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-2.5 py-1 text-[11px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55"
type="button"
onClick={() => void forkFromCard(game)}
>
@@ -7,6 +7,7 @@ import { useForkDeepLinkStore } from '../src/features/platform-fork/forkDeepLink
import {
forkableGames,
formatCatalogCreatedAt,
formatCatalogPlayCount,
isForkableGame,
loadForkableGames,
} from '../src/features/platform-fork/platformForkCatalog';
@@ -39,7 +40,12 @@ function installInvoke(
return calls;
}
function catalogGame(id: string, title: string, forkAuthorization: string) {
function catalogGame(
id: string,
title: string,
forkAuthorization: string,
playCount?: number,
) {
return {
id,
title,
@@ -49,6 +55,7 @@ function catalogGame(id: string, title: string, forkAuthorization: string) {
coverColor: '#c2653d',
forkAuthorization,
createdAt: '2026-10-01T08:00:00Z',
...(typeof playCount === 'number' ? { playCount } : {}),
};
}
@@ -254,6 +261,68 @@ describe('共创页', () => {
expect(screen.getAllByText('类型')).toHaveLength(2);
});
it('卡片显示作品自身的游玩次数,且不带会被读成合计的措辞', async () => {
installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return {
games: [
catalogGame('game_played', '有游玩记录的作品', 'full', 7),
catalogGame('game_fresh', '没人玩过的作品', 'full', 0),
],
nextCursor: null,
};
}
throw new Error(`unexpected invoke ${command}`);
});
renderPage();
await screen.findByText('有游玩记录的作品');
// 自身值:7 次 / 0 次都照实显示(0 是真实值,不是缺字段)。
expect(screen.getAllByText('游玩')).toHaveLength(2);
expect(screen.getByText('7 次')).not.toBeNull();
expect(screen.getByText('0 次')).not.toBeNull();
// 文案只讲自身,**不出现**任何合计/衍生口径(客户端没有树,聚合在网页端)。
const pageText = document.body.textContent ?? '';
expect(pageText).not.toMatch(/含衍生|子树|合计|衍生作品/u);
// 「含衍生」那条口径的反面证据:文案里就是「N 次」,前面不带别的词。
expect(
[...document.querySelectorAll('.fork-page article small span')].some(
(node) => node.textContent?.trim() === '游玩',
),
).toBe(true);
});
it('游玩次数字段缺失时整行不渲染(不留恒为「—」的死行)', async () => {
installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return {
games: [catalogGame('game_no_plays', '旧响应没有该字段', 'full')],
nextCursor: null,
};
}
throw new Error(`unexpected invoke ${command}`);
});
renderPage();
await screen.findByText('旧响应没有该字段');
expect(screen.queryByText('游玩')).toBeNull();
// 其余信息行照旧(确认不是整张卡没渲染)。
expect(screen.getByText('作者')).not.toBeNull();
expect(screen.getByText('类型')).not.toBeNull();
});
it('游玩次数格式化:千分位、负数收敛到 0、非法值返回 null', () => {
expect(formatCatalogPlayCount(7)).toBe('7 次');
expect(formatCatalogPlayCount(12345)).toBe('12,345 次');
expect(formatCatalogPlayCount(0)).toBe('0 次');
expect(formatCatalogPlayCount(-3)).toBe('0 次');
expect(formatCatalogPlayCount(3.8)).toBe('3 次');
expect(formatCatalogPlayCount(undefined)).toBeNull();
expect(formatCatalogPlayCount(null)).toBeNull();
expect(formatCatalogPlayCount(Number.NaN)).toBeNull();
expect(formatCatalogPlayCount('7')).toBeNull();
});
it('时间格式化把微秒时间戳按本地日期折算', () => {
expect(formatCatalogCreatedAt('2026-10-01T08:00:00Z')).toMatch(
/^\d{4}-\d{2}-\d{2}$/,