feat(agc): 共创列表页去掉粘贴入口,卡片时间改为真实的创建时间

This commit is contained in:
2026-10-07 00:53:28 +08:00
parent 501fd0925d
commit 2010c13826
6 changed files with 269 additions and 250 deletions
@@ -0,0 +1,79 @@
/**
* 共创相关页面(「共创」列表页 / Fork 工程确认页)共用的一套版式令牌。
*
* 目的:两个页面此前各写一套 Tailwind 串,间距(22/14/10px…)、状态文字形态(灰字当按钮看)、
* 卡片内标签列宽度都不一致。这里把**列宽、节奏、卡片、状态标签、按钮**收敛成一份:
*
* - 内容列:`min(860px, 100vw - 124px)`,正文再加 `72ch` 上限——正文不铺满整屏;
* - 节奏:一律 4/8 步进(`gap-2/3/6`、`p-3`、`py-2.5`);
* - 色板:只用 `packages/shared/src/theme.css` 里既有的 `--platform-*` 变量,不新增颜色;
* - 状态与按钮**形态上必须可区分**:状态用 `rounded-full` 徽章(描边 + 浅底 + 短文案),
* 按钮用 `rounded-lg` 实心/描边 —— 之前「已可确认 / 确认后开始」是灰字,像被禁用的按钮。
*
* 这些都是**字面量**类名(Tailwind 扫描器按字面量收集,拼字符串会扫不到)。
*/
/** 页面内容列:居中、限宽、随窗口收窄。 */
export const FORK_PAGE_COLUMN =
'mx-auto w-[min(860px,calc(100vw-124px))] max-[980px]:w-[min(100%,calc(100vw-64px))] max-[760px]:w-[min(100%,calc(100vw-40px))]';
/** 页面根:纵向节奏 24px,底部留白 32px。 */
export const FORK_PAGE_ROOT = 'fork-page grid content-start gap-6 pb-8';
/** 区块内部:标题 / 说明 / 卡片之间 12px。 */
export const FORK_SECTION = 'grid gap-3';
/** 区块标题行:标题 + 右侧状态徽章(窄窗口换行,不挤压标题)。 */
export const FORK_SECTION_HEAD =
'flex flex-wrap items-center justify-between gap-x-3 gap-y-2';
export const FORK_PAGE_TITLE =
'm-0 text-[19px] font-semibold text-(--platform-text-strong)';
export const FORK_SECTION_TITLE =
'm-0 text-[15px] font-semibold text-(--platform-text-strong)';
/** 正文:12/20 行高 + 72 字符上限。 */
export const FORK_BODY_TEXT =
'm-0 max-w-[72ch] text-[12px] leading-5 text-(--platform-text-soft)';
/** 次要说明:11/16。 */
export const FORK_META_TEXT =
'text-[11px] leading-4 text-(--platform-text-soft)';
/** 卡片外壳:描边 + 浅底 + 既有阴影。 */
export const FORK_CARD =
'rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) shadow-(--platform-panel-shadow)';
/** 主按钮(实心)与次按钮(描边);两者都与状态徽章形态不同。 */
export const FORK_BUTTON_PRIMARY =
'cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-3.5 py-2 text-[12px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55';
export const FORK_BUTTON_SECONDARY =
'cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3.5 py-2 text-[12px] text-(--platform-text-strong) disabled:cursor-not-allowed disabled:opacity-55';
/** 状态徽章:三种语义色,全部只读、不响应点击。 */
const FORK_BADGE_BASE =
'inline-flex shrink-0 items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px] font-bold leading-4 tabular-nums';
/** 进行中 / 需要注意(暖色)。 */
export const FORK_BADGE_WARM =
FORK_BADGE_BASE +
' border-(--platform-warm-border) bg-(--platform-warm-bg) text-(--platform-warm-text)';
/** 中性事实(未开始 / 统计信息)。 */
export const FORK_BADGE_NEUTRAL =
FORK_BADGE_BASE +
' border-(--platform-neutral-border) bg-(--platform-neutral-bg) text-(--platform-neutral-text)';
/** 完成(绿色)。 */
export const FORK_BADGE_SUCCESS =
FORK_BADGE_BASE +
' border-(--platform-success-border) bg-(--platform-success-bg) text-(--platform-success-text)';
/** 吸附底部操作条:整屏宽、不透明底、上边框 + 顶部投影,内容区滚动时始终可见。 */
export const FORK_STICKY_FOOTER =
'sticky bottom-0 z-10 mt-2 border-t border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) py-3 backdrop-blur-[2px] shadow-[0_-8px_20px_rgb(31_24_16_/_7%)]';
/** 卡片里的「标签 → 数值」两列表:标签列定宽 + 数值列等宽数字,避免换行跳动。 */
export const FORK_META_GRID =
'grid grid-cols-[52px_minmax(0,1fr)] items-baseline gap-x-2 gap-y-1 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)';
@@ -31,6 +31,37 @@ export type ForkCatalogResult = {
cappedByPageLimit: boolean;
};
/**
* 卡片上的时间只能是**真实存在**的字段。
*
* 公开目录 payload 有 `createdAt`(RFC3339,服务端 `format_timestamp_micros` 产出)**没有**
* `updatedAt`,所以标签写「创建时间」;取不到就返回 `null`,由调用方整行不渲染——
* 不留一个恒为「—」的死行。数字型(微秒/毫秒时间戳)也认,返回本地时区的 `YYYY-MM-DD`。
*/
export function formatCatalogCreatedAt(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
// 微秒时间戳按 1e6 折算;毫秒时间戳照原样用。两者都远超合理下限,因此能稳定区分。
const millis = value > 1e14 ? Math.round(value / 1000) : value;
return formatLocalDate(new Date(millis));
}
if (typeof value !== 'string' || !value.trim()) {
return null;
}
const parsed = Date.parse(value.trim());
if (!Number.isFinite(parsed)) {
return null;
}
return formatLocalDate(new Date(parsed));
}
function formatLocalDate(date: Date): string | null {
if (Number.isNaN(date.getTime())) {
return null;
}
const pad = (part: number) => String(part).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 支持共创 = 授权不是「禁止共创」;字段缺省按 `forbidden` 处理(与契约一致)。 */
export function isForkableGame(game: GameDistributionGame): boolean {
return (game.forkAuthorization ?? 'forbidden') !== 'forbidden';
@@ -1,61 +1,12 @@
/**
* 「从平台作品 Fork」的纯逻辑:输入解析与失败分类。
* 「从平台作品 Fork」的纯逻辑:取件失败分类。
*
* 与原生取件的分工:这里只做**能在渲染层判定的格式问题**(空、非法字符、链接里找不到作品
* ID)——判不了就不发请求;作品的可见性、授权与存在性一律以服务端为准(原生命令的
* 404 / 409 / 403 / 401 已折成带稳定前缀的文案,见 `classifyPlatformForkFailure`)。
* 作品标识的解析不在这里:深链的作品 ID 由原生解析(`game_fork::desktop`),列表页也不再提供
* 手动粘贴入口,因此渲染层只负责把原生失败文案折成可区分、可直接展示的结果。
* 作品的可见性、授权与存在性一律以服务端为准(原生命令的 404 / 409 / 403 / 401 已折成带稳定
* 前缀的文案,见 `classifyPlatformForkFailure`)。
*/
/** 平台作品 ID 的字符集,与原生 `fetch_platform_game_fork_source` 的路径安全判据一致。 */
const GAME_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
/** 链接里可能承载作品 ID 的查询参数名(网页端详情页用 `id`,其余命名一并容忍)。 */
const GAME_ID_QUERY_KEYS = ['gameId', 'gameid', 'game_id', 'id'] as const;
/**
* 从「作品 ID 或作品链接」解析出平台作品 ID;无法确定时返回 `null`(调用方不得发请求)。
*
* 两条口径,顺序固定:
* 1. 裸 ID:整串都落在作品 ID 字符集内即原样使用(存在性由服务端回答,不做本地白名单);
* 2. 链接:先看查询参数,再看 `/games/` 之后的首个路径段(`/games/detail?id=` 已被第 1 步命中)。
* 不解析 deep link、不依赖服务端:解析失败就是「请检查输入」,不是请求失败。
*/
export function parsePlatformGameId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
if (GAME_ID_PATTERN.test(trimmed)) {
return trimmed;
}
// 用户常直接粘贴不带 scheme 的地址;补一个占位 scheme 只用于解析,不参与任何请求。
const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(trimmed)
? trimmed
: `https://${trimmed.replace(/^\/+/u, '')}`;
let url: URL;
try {
url = new URL(withScheme);
} catch {
return null;
}
for (const key of GAME_ID_QUERY_KEYS) {
const candidate = url.searchParams.get(key)?.trim() ?? '';
if (candidate && GAME_ID_PATTERN.test(candidate)) {
return candidate;
}
}
const segments = url.pathname.split('/').filter(Boolean);
const gamesIndex = segments.indexOf('games');
if (gamesIndex < 0) {
return null;
}
// `/games/detail?id=` 已被上面的查询参数命中;这里取 `/games/<id>` 的首个非 detail 段。
const candidate = segments
.slice(gamesIndex + 1)
.find((segment) => segment !== 'detail');
return candidate && GAME_ID_PATTERN.test(candidate) ? candidate : null;
}
export type PlatformForkFailureKind =
/** 未登录 / 登录态过期:可操作提示是「重新登录」。 */
| 'auth'
@@ -100,7 +51,3 @@ export function classifyPlatformForkFailure(
}
return { kind: 'other', message: text };
}
/** 输入非法(解析不出作品 ID)时的统一提示:本地拦截,不发请求。 */
export const PLATFORM_FORK_INPUT_ERROR =
'请输入要 Fork 的平台作品 ID 或作品链接(例如 game_ab12… 或 https://…/games/detail?id=game_ab12…)';
@@ -1,27 +1,40 @@
/**
* 「共创」页:列出支持共创(可 Fork)的平台作品,并支持按作品 ID/链接 Fork。
* 「共创」页:列出支持共创(可 Fork)的平台作品。
*
* 对外口径只有一句:**Fork = 把整个工程复制一份到你的项目里继续改造(与 git fork 同理)**。
* 列表与「填详细内容」两条入口共用同一个提交状态机(`usePlatformFork`),卡片上的 Fork 按钮是
* **一步可达**的确认动作——不会自动下载,也不会跳去网页。
* 页面只有一条入口:卡片上的 Fork 按钮 → Fork 工程确认页(授权校验与工程同步都在那一页完成)。
*
* 这里**没有**「直接 Fork 指定作品」输入块:深链 `genarrative://fork?gameId=…` 由 launcher 直接
* 落到确认页(见 `WorkspaceLauncher`),手动粘贴 ID 属于重复入口,已按需求移除。
*/
import { RefreshCcw } from 'lucide-react';
import type { FormEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
import { resolveTauriInvoke } from '../../app/tauri';
import { useForkDeepLinkStore } from '../../features/platform-fork/forkDeepLink';
import {
FORK_BADGE_NEUTRAL,
FORK_BODY_TEXT,
FORK_BUTTON_SECONDARY,
FORK_CARD,
FORK_META_GRID,
FORK_META_LABEL,
FORK_META_VALUE,
FORK_PAGE_COLUMN,
FORK_PAGE_ROOT,
FORK_PAGE_TITLE,
FORK_SECTION,
FORK_SECTION_HEAD,
FORK_SECTION_TITLE,
} from '../../features/platform-fork/forkPageStyles';
import {
FORK_CATALOG_MAX_PAGES,
type ForkCatalogResult,
formatCatalogCreatedAt,
loadForkableGames,
} from '../../features/platform-fork/platformForkCatalog';
import {
parsePlatformGameId,
PLATFORM_FORK_INPUT_ERROR,
} from '../../features/platform-fork/platformGameForkModel';
import { usePlatformForkCovers } from '../../features/platform-fork/usePlatformForkCovers';
type CoCreationViewProps = {
@@ -29,35 +42,23 @@ type CoCreationViewProps = {
onOpenConfirm: (target: {
gameId: string;
game: GameDistributionGame | null;
origin: 'co-creation';
origin: 'co-creation' | 'deep-link';
}) => void;
};
const sectionClass =
'mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]';
function formatCatalogTime(value: string) {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) {
return '—';
}
const date = new Date(timestamp);
const pad = (part: number) => String(part).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
const [catalog, setCatalog] = useState<ForkCatalogResult | null>(null);
const [loading, setLoading] = useState(true);
const [catalogError, setCatalogError] = useState('');
const [input, setInput] = useState('');
const [inputError, setInputError] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [deepLinkNotice, setDeepLinkNotice] = useState('');
const requestIdRef = useRef(0);
/**
* 深链预填:原生收到 `genarrative://fork?gameId=…` 后放进 store,这里只把它填进输入框并聚焦,
* **不自动提交**(与卡片一样,必须由人按下动作)。
* 深链只在这里**消费**(同一链接只生效一次)。
*
* 带 `gameId` 的链接在 launcher 里就已经切到确认页,本页通常根本不会挂载;万一挂载了
* (用户又从侧边栏点回列表),这里只把待处理请求清掉,不报错、也不留幽灵状态。
* 只有「链接解析失败」的 message 会显示成一条提示。
*/
const pendingDeepLink = useForkDeepLinkStore((state) => state.pending);
const consumeDeepLink = useForkDeepLinkStore((state) => state.consume);
@@ -106,31 +107,10 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
if (!request) {
return;
}
if (request.gameId) {
setInput(request.gameId);
setInputError('');
inputRef.current?.focus();
}
if (request.message) {
setInputError(request.message);
}
setDeepLinkNotice(request.message ?? '');
}, [pendingDeepLink, consumeDeepLink]);
function submitPastedGameId(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// 与深链共用同一份解析(`parsePlatformGameId`),不另写一套;解析通过后统一去确认页。
const gameId = parsePlatformGameId(input);
if (!gameId) {
setInputError(PLATFORM_FORK_INPUT_ERROR);
return;
}
setInputError('');
onOpenConfirm({ gameId, game: null, origin: 'co-creation' });
}
function forkFromCard(game: GameDistributionGame) {
setInputError('');
setInput(game.id);
onOpenConfirm({ gameId: game.id, game, origin: 'co-creation' });
}
@@ -143,19 +123,17 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
: '';
return (
<div className="grid content-start gap-0 pb-10">
<header className={sectionClass}>
<div className="flex items-center justify-between gap-3">
<div className={FORK_PAGE_ROOT}>
<header className={FORK_PAGE_COLUMN}>
<div className="flex items-start justify-between gap-3">
<div className="grid gap-1">
<h1 className="m-0 text-[19px] font-semibold text-(--platform-text-strong)">
共创
</h1>
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
<h1 className={FORK_PAGE_TITLE}>共创</h1>
<p className={`${FORK_BODY_TEXT} max-w-[56ch]`}>
把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。
</p>
</div>
<button
className="shrink-0 cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3 py-1.5 text-[12px] text-(--platform-text-strong) disabled:cursor-not-allowed disabled:opacity-55"
className={`${FORK_BUTTON_SECONDARY} shrink-0`}
type="button"
disabled={loading}
onClick={() => void reloadCatalog()}
@@ -170,61 +148,27 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
</div>
</header>
<section className={sectionClass} aria-label="填详细内容">
<form
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2.5 max-[760px]:grid-cols-1"
onSubmit={submitPastedGameId}
>
<label className="grid gap-1.5">
<span className="text-[12px] font-bold text-(--platform-text-strong)">
直接 Fork 指定作品
</span>
<input
className="min-w-0 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-3 py-2 text-[12.5px] text-(--platform-text-strong) outline-none disabled:opacity-60"
ref={inputRef}
aria-label="平台作品 ID 或链接"
value={input}
placeholder="例如 game_ab12… 或 https://…/games/detail?id=game_ab12…"
onChange={(event) => {
setInput(event.target.value);
if (inputError) {
setInputError('');
}
}}
/>
</label>
<button
className="mt-6 shrink-0 cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-3.5 py-2 text-[12px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55 max-[760px]:mt-0"
type="submit"
disabled={input.trim().length === 0}
>
去确认并 Fork
</button>
</form>
{inputError ? (
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
{inputError}
{deepLinkNotice ? (
<section className={FORK_PAGE_COLUMN} aria-label="Fork 链接">
<p className={FORK_BODY_TEXT} role="alert">
{deepLinkNotice}
</p>
) : null}
</section>
</section>
) : null}
<section className={sectionClass} aria-label="支持共创的作品">
<header className="flex items-center justify-between gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
支持共创的作品
</h2>
<section
className={`${FORK_PAGE_COLUMN} ${FORK_SECTION}`}
aria-label="支持共创的作品"
>
<header className={FORK_SECTION_HEAD}>
<h2 className={FORK_SECTION_TITLE}>支持共创的作品</h2>
{scannedHint ? (
<small className="text-[11px] text-(--platform-text-soft)">
{scannedHint}
</small>
<small className={FORK_BADGE_NEUTRAL}>{scannedHint}</small>
) : null}
</header>
{loading ? (
<p
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
role="status"
>
<p className={FORK_BODY_TEXT} role="status">
正在读取共创目录…
</p>
) : null}
@@ -232,13 +176,13 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
{!loading && catalogError ? (
<div className="grid justify-items-start gap-2.5">
<p
className="m-0 text-[12px] leading-5 text-[#b42318]"
className="m-0 max-w-[72ch] text-[12px] leading-5 text-[#b42318]"
role="alert"
>
{catalogError}
</p>
<button
className="cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3 py-1.5 text-[12px] text-(--platform-text-strong)"
className={FORK_BUTTON_SECONDARY}
type="button"
onClick={() => void reloadCatalog()}
>
@@ -248,9 +192,9 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
) : null}
{!loading && !catalogError && games.length === 0 ? (
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
<p className={FORK_BODY_TEXT}>
最新{scannedHint ? ` ${catalog?.scannedGames ?? 0} 个` : ''}
作品里还没有开放共创的;可以在上面直接粘贴作品 ID 试试,或稍后再来。
作品里还没有开放共创的;稍后再来看看,或请作品作者在网页端开启共创。
</p>
) : null}
@@ -258,9 +202,12 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
<div className="grid grid-cols-3 gap-4 max-[900px]:grid-cols-2 max-[760px]:grid-cols-1">
{games.map((game) => {
const coverUrl = coverUrls[game.id];
const createdAt = formatCatalogCreatedAt(game.createdAt);
return (
<article className="grid min-w-0" key={game.id}>
<div className="grid min-h-34 w-full grid-cols-[96px_minmax(0,1fr)] items-stretch gap-3 overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-2 text-left shadow-(--platform-panel-shadow)">
<div
className={`${FORK_CARD} grid min-h-34 w-full grid-cols-[96px_minmax(0,1fr)] items-stretch gap-3 overflow-hidden p-2 text-left`}
>
<span
className="grid min-h-29 place-items-center overflow-hidden rounded-lg text-[26px] font-bold text-white shadow-inner"
style={{ backgroundColor: game.coverColor || '#c2653d' }}
@@ -281,26 +228,32 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
</span>
)}
</span>
<span className="grid min-w-0 content-start gap-1.5 py-1">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-4.5 text-(--platform-text-strong)">
<span className="grid min-w-0 content-start gap-2 py-1">
<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="grid grid-cols-[44px_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-[10px] leading-3.5 text-(--platform-text-soft)">
<span className="whitespace-nowrap">作者</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
<small className={FORK_META_GRID}>
<span className={FORK_META_LABEL}>作者</span>
<span className={FORK_META_VALUE}>
{game.author?.name || '—'}
</span>
<span className="whitespace-nowrap">类型</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
<span className={FORK_META_LABEL}>类型</span>
<span className={FORK_META_VALUE}>
{game.category || '—'}
</span>
<span className="whitespace-nowrap">更新时间</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
{formatCatalogTime(game.createdAt)}
</span>
{/*
公开目录没有 `updatedAt`,只有 `createdAt`:标签就写「创建时间」,
取不到时整行不渲染(不留恒为「—」的死行)。
*/}
{createdAt ? (
<>
<span className={FORK_META_LABEL}>创建时间</span>
<span className={FORK_META_VALUE}>{createdAt}</span>
</>
) : null}
</small>
<button
className="mt-1 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="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"
type="button"
onClick={() => void forkFromCard(game)}
>
@@ -6,10 +6,10 @@ import type { InitLocalProjectResult } from '../src/app/types';
import { useForkDeepLinkStore } from '../src/features/platform-fork/forkDeepLink';
import {
forkableGames,
formatCatalogCreatedAt,
isForkableGame,
loadForkableGames,
} from '../src/features/platform-fork/platformForkCatalog';
import { parsePlatformGameId } from '../src/features/platform-fork/platformGameForkModel';
import CoCreationView from '../src/view/co-creation';
const FORK_COMMAND = 'create_local_project_from_platform_game';
@@ -54,8 +54,8 @@ function catalogGame(id: string, title: string, forkAuthorization: string) {
function renderPage() {
const onOpenConfirm = vi.fn();
render(<CoCreationView onOpenConfirm={onOpenConfirm} />);
return { onOpenConfirm };
const rendered = render(<CoCreationView onOpenConfirm={onOpenConfirm} />);
return { ...rendered, onOpenConfirm };
}
function forkCalls(calls: InvokeCall[]) {
@@ -169,54 +169,96 @@ describe('共创页', () => {
});
});
it('粘贴作品链接与卡片共用同一解析,并同样进确认页', async () => {
const calls = installInvoke((command) => {
it('不再渲染「直接 Fork 指定作品」输入块(页面只留卡片入口)', async () => {
installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return { games: [], nextCursor: null };
return {
games: [catalogGame('game_open', '开放共创的作品', 'full')],
nextCursor: null,
};
}
throw new Error(`unexpected invoke ${command}`);
});
const { onOpenConfirm } = renderPage();
await screen.findByText(/还没有开放共创的/);
const { container } = renderPage();
await screen.findByText('开放共创的作品');
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
target: { value: 'https://platform.test/games/detail?id=game_pasted' },
});
fireEvent.click(screen.getByRole('button', { name: '去确认并 Fork' }));
expect(onOpenConfirm).toHaveBeenCalledTimes(1);
expect(onOpenConfirm.mock.calls[0]?.[0]).toMatchObject({
gameId: 'game_pasted',
game: null,
origin: 'co-creation',
});
expect(forkCalls(calls)).toHaveLength(0);
// 解析仍是同一份实现。
// 整块连同它的标题、输入框、按钮一起消失。
expect(screen.queryByText('直接 Fork 指定作品')).toBeNull();
expect(screen.queryByLabelText('平台作品 ID 或链接')).toBeNull();
expect(screen.queryByRole('button', { name: '去确认并 Fork' })).toBeNull();
expect(screen.queryByPlaceholderText(/例如 game_ab12…/)).toBeNull();
// 卡片入口仍在(否则上面几条会因为整页没渲染而假绿)。
expect(
parsePlatformGameId('https://platform.test/games/detail?id=game_pasted'),
).toBe('game_pasted');
screen.getAllByRole('button', { name: 'Fork' }).length,
).toBeGreaterThan(0);
// 页面自己**不建**滚动容器:滚动落在外壳 `.launcher-main`(见 styles.css 契约测试)。
expect(container.querySelector('.fork-page')).not.toBeNull();
expect(container.querySelector('.fork-page .overflow-y-auto')).toBeNull();
expect(container.querySelector('.fork-page .overflow-scroll')).toBeNull();
});
it('粘贴非法输入本地拦截,不进确认页也不发请求', async () => {
const calls = installInvoke((command) => {
it('卡片时间:公开目录只有 createdAt,就按「创建时间」渲染真实值', async () => {
installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return { games: [], nextCursor: null };
return {
games: [catalogGame('game_open', '开放共创的作品', 'full')],
nextCursor: null,
};
}
throw new Error(`unexpected invoke ${command}`);
});
const { onOpenConfirm } = renderPage();
await screen.findByText(/还没有开放共创的/);
renderPage();
await screen.findByText('开放共创的作品');
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
target: { value: 'https://platform.test/other/path' },
// 标签是「创建时间」而不是「更新时间」:目录 payload 没有 updatedAt,不拿 createdAt 冒充。
expect(screen.getByText('创建时间')).not.toBeNull();
expect(screen.queryByText('更新时间')).toBeNull();
expect(
screen.getByText(formatCatalogCreatedAt('2026-10-01T08:00:00Z')!),
).not.toBeNull();
});
it('卡片时间:字段缺失或不可解析时整行不渲染,不留恒为「—」的死行', async () => {
installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return {
games: [
{
...catalogGame('game_open', '开放共创的作品', 'full'),
createdAt: '',
},
{
...catalogGame('game_bad', '时间不可解析的作品', 'full'),
createdAt: '不是时间',
},
],
nextCursor: null,
};
}
throw new Error(`unexpected invoke ${command}`);
});
fireEvent.click(screen.getByRole('button', { name: '去确认并 Fork' }));
renderPage();
await screen.findByText('开放共创的作品');
expect(screen.getByRole('alert').textContent).toContain(
'请输入要 Fork 的平台作品 ID 或作品链接',
expect(screen.queryByText('创建时间')).toBeNull();
expect(screen.queryByText('更新时间')).toBeNull();
// 「作者 / 类型」两行照旧(确认不是整张卡没渲染)。
expect(screen.getAllByText('作者')).toHaveLength(2);
expect(screen.getAllByText('类型')).toHaveLength(2);
});
it('时间格式化把微秒时间戳按本地日期折算', () => {
expect(formatCatalogCreatedAt('2026-10-01T08:00:00Z')).toMatch(
/^\d{4}-\d{2}-\d{2}$/,
);
expect(onOpenConfirm).not.toHaveBeenCalled();
expect(forkCalls(calls)).toHaveLength(0);
// 微秒与毫秒两种写法必须落同一天:折算除数写错就会跑到几万年以外。
expect(formatCatalogCreatedAt(1_759_308_800_000_000)).toBe(
formatCatalogCreatedAt(1_759_308_800_000),
);
expect(formatCatalogCreatedAt('')).toBeNull();
expect(formatCatalogCreatedAt(undefined)).toBeNull();
expect(formatCatalogCreatedAt('不是时间')).toBeNull();
});
it('目录为空时给出明确空态,而不是空白', async () => {
@@ -1,41 +1,8 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
classifyPlatformForkFailure,
parsePlatformGameId,
} from '../src/features/platform-fork/platformGameForkModel';
describe('游戏共创入口输入解析', () => {
it('接受裸作品 ID 与常见作品链接形态', () => {
expect(parsePlatformGameId(' game_ab12cd ')).toBe('game_ab12cd');
expect(
parsePlatformGameId('https://platform.test/games/detail?id=game_1'),
).toBe('game_1');
expect(
parsePlatformGameId('https://platform.test/games/detail?gameId=game_2'),
).toBe('game_2');
expect(parsePlatformGameId('platform.test/games/game_3?from=share')).toBe(
'game_3',
);
expect(parsePlatformGameId('https://platform.test/games/game_3/play')).toBe(
'game_3',
);
});
it('解析不出作品 ID 时返回 null(本地拦截,不发请求)', () => {
for (const input of [
'',
' ',
'game/1',
'game 1',
'https://platform.test/other/path',
'https://platform.test/games/detail',
'https://platform.test/games',
]) {
expect(parsePlatformGameId(input), input).toBeNull();
}
});
import { classifyPlatformForkFailure } from '../src/features/platform-fork/platformGameForkModel';
describe('Fork 取件失败分类', () => {
it('按原生前缀分类失败,并去掉机器前缀后展示', () => {
expect(
classifyPlatformForkFailure(