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
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:
@@ -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}$/,
|
||||
|
||||
Reference in New Issue
Block a user