From 2010c138269643409848a1c3f77a8df50d2d37c4 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 00:53:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(agc):=20=E5=85=B1=E5=88=9B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=A1=B5=E5=8E=BB=E6=8E=89=E7=B2=98=E8=B4=B4=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E5=8D=A1=E7=89=87=E6=97=B6=E9=97=B4=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=9C=9F=E5=AE=9E=E7=9A=84=E5=88=9B=E5=BB=BA=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/platform-fork/forkPageStyles.ts | 79 +++++++ .../platform-fork/platformForkCatalog.ts | 31 +++ .../platform-fork/platformGameForkModel.ts | 63 +----- .../src/view/co-creation/index.tsx | 197 +++++++----------- .../tests/coCreation.test.tsx | 112 ++++++---- .../tests/platformGameForkModel.test.ts | 37 +--- 6 files changed, 269 insertions(+), 250 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts b/apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts new file mode 100644 index 000000000..8edc6b6a2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/platform-fork/forkPageStyles.ts @@ -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)'; diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCatalog.ts b/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCatalog.ts index a158a990c..f1294902d 100644 --- a/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCatalog.ts +++ b/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCatalog.ts @@ -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'; diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/platformGameForkModel.ts b/apps/ai-game-creator-shell/src/features/platform-fork/platformGameForkModel.ts index af9ce4fdd..03a2facf7 100644 --- a/apps/ai-game-creator-shell/src/features/platform-fork/platformGameForkModel.ts +++ b/apps/ai-game-creator-shell/src/features/platform-fork/platformGameForkModel.ts @@ -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/` 的首个非 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…)'; diff --git a/apps/ai-game-creator-shell/src/view/co-creation/index.tsx b/apps/ai-game-creator-shell/src/view/co-creation/index.tsx index bb16714ce..c9e20dc57 100644 --- a/apps/ai-game-creator-shell/src/view/co-creation/index.tsx +++ b/apps/ai-game-creator-shell/src/view/co-creation/index.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [catalogError, setCatalogError] = useState(''); - const [input, setInput] = useState(''); - const [inputError, setInputError] = useState(''); - const inputRef = useRef(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) { - 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 ( -
-
-
+
+
+
-

- 共创 -

-

+

共创

+

把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。

-
-
- - -
- {inputError ? ( -

- {inputError} + {deepLinkNotice ? ( +

+

+ {deepLinkNotice}

- ) : null} -
+
+ ) : null} -
-
-

- 支持共创的作品 -

+
+
+

支持共创的作品

{scannedHint ? ( - - {scannedHint} - + {scannedHint} ) : null}
{loading ? ( -

+

正在读取共创目录…

) : null} @@ -232,13 +176,13 @@ export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) { {!loading && catalogError ? (

{catalogError}