feat(agc): 共创列表页去掉粘贴入口,卡片时间改为真实的创建时间
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user