diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 4af109317..1346b2290 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -623,6 +623,7 @@ pub(super) fn run() { read_game_cover_generation_price, generate_game_distribution_cover, list_platform_game_catalog, + read_public_game_cover_preview, read_game_distribution_publication, publish_local_project_game, read_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 865817694..d3446e0a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -1126,6 +1126,39 @@ pub(crate) async fn list_platform_game_catalog( serde_json::from_value(value).map_err(|error| format!("共创目录响应无效:{error}")) } +/// 公开作品封面预览:把公开目录里的 `coverObjectKey` 换成**有界 data URL**。 +/// +/// 为什么不让渲染层直接拿签名地址去 ``:AGC 既有约定是「渲染层不自行拼 read-url、 +/// 不接触对象键与 bearer token、也不让 WebView 直连签名地址」(见 `commands.rs` 里同一口径的注释)。 +/// 因此这里走与发布面板封面同一套链路:`read-url` 换签 + 有界下载 + data URL。 +/// +/// 失败与「没有封面」都由调用方按同一个分支处理(回落到首字占位),所以这里把失败如实返回成 +/// `Err`,不吞成空串。 +#[tauri::command] +pub(crate) async fn read_public_game_cover_preview(object_key: String) -> Result { + let object_key = object_key.trim().to_string(); + if object_key.is_empty() || object_key.len() > 512 || object_key.contains("..") { + return Err("封面对象键无效".to_string()); + } + let snapshot = require_platform_session()?; + current_scoped_session(&snapshot)?; + let client = build_client()?; + let source = json!({ "objectKey": object_key }); + let download = crate::assets::resolve_canvas_resource_download_with_limit_route_and_fence( + &client, + snapshot.api_base_url.as_str(), + snapshot.access_token.as_str(), + &source, + COVER_PREVIEW_MAX_BYTES, + "/api/assets/read-url", + || Ok(()), + ) + .await? + .ok_or_else(|| "封面内容为空".to_string())?; + current_scoped_session(&snapshot)?; + cover_preview_data_url(&download.media_type, &download.bytes) +} + /// 发布面板打开时的绑定恢复。 /// /// 只有「本地绑定(且属于当前账号/origin)+ 线上作品」或「无本地绑定时按作者 + localProjectId diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCover.ts b/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCover.ts new file mode 100644 index 000000000..e8184f880 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/platform-fork/platformForkCover.ts @@ -0,0 +1,81 @@ +/** + * 公开作品封面:把 `coverObjectKey` 换成可显示的地址,带会话内缓存与失败短路。 + * + * AGC 的约定是「渲染层不接触签名地址、不让 WebView 直连外部图片」(见 Rust 侧命令注释),因此 + * 这里调用 `read_public_game_cover_preview` 拿**有界 data URL**——没有过期时间,也就不需要 TTL; + * 代价是每张封面要过一次 IPC,所以按对象键缓存: + * - **成功**:会话内一直复用(data URL 自身不会过期); + * - **失败**:缓存 60 秒,避免同一张坏图在每个卡片上反复重试(沿用网页端同一条失败缓存窗口); + * - **并发**:同一对象键的并发请求去重(列表一次渲染多张卡时只发一次)。 + * + * 拿不到封面(没有对象键 / 请求失败 / 超限)时返回 `null`——调用方回落到首字占位 + `coverColor`。 + */ +import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution'; + +const COVER_COMMAND = 'read_public_game_cover_preview'; +const FAILURE_CACHE_WINDOW_MS = 60 * 1000; + +type CoverEntry = { url: string | null; at: number }; + +const coverCache = new Map(); +const inflight = new Map>(); + +type CoverInvoke = ( + command: string, + args?: Record, +) => Promise; + +/** 当前登录账号切换时清空缓存:换号后旧封面可能已无权访问。 */ +export function clearCoverPreviewCache() { + coverCache.clear(); + inflight.clear(); +} + +function cacheKey(objectKey: string, accountScope: string) { + return `${accountScope}\u0000${objectKey}`; +} + +/** + * 解析一张封面;失败一律 `null`(调用方保留占位),不抛给渲染层。 + * + * `accountScope` 一般是当前账号 id:换号后同一对象键的结果不能复用。 + */ +export async function resolveCoverPreview( + invoke: CoverInvoke, + game: Pick, + accountScope = '', +): Promise { + const objectKey = (game.coverObjectKey ?? '').trim(); + if (!objectKey) { + return null; + } + const key = cacheKey(objectKey, accountScope); + const cached = coverCache.get(key); + if ( + cached && + (cached.url !== null || Date.now() - cached.at < FAILURE_CACHE_WINDOW_MS) + ) { + return cached.url; + } + const pending = inflight.get(key); + if (pending) { + return pending; + } + const request = (async () => { + try { + const url = await invoke(COVER_COMMAND, { objectKey }); + const normalized = + typeof url === 'string' && url.startsWith('data:') ? url : null; + coverCache.set(key, { url: normalized, at: Date.now() }); + return normalized; + } catch { + // 失败同样进缓存(短窗口):列表里同一张坏图不重复打请求。 + coverCache.set(key, { url: null, at: Date.now() }); + return null; + } finally { + inflight.delete(key); + } + })(); + inflight.set(key, request); + return request; +} diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformForkCovers.ts b/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformForkCovers.ts new file mode 100644 index 000000000..3aba71142 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformForkCovers.ts @@ -0,0 +1,45 @@ +/** + * 列表封面:一次性解析当前列表里每张卡片的封面(有界 data URL),失败/缺封面就不返回该键。 + * + * 解析本身走 `resolveCoverPreview` 的会话内缓存与并发去重,所以翻页、重渲染都不会重复拉同一张图。 + */ +import { useEffect, useState } from 'react'; + +import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution'; +import { resolveTauriInvoke } from '../../app/tauri'; +import { resolveCoverPreview } from './platformForkCover'; + +export function usePlatformForkCovers( + games: readonly GameDistributionGame[], + accountScope = '', +): Record { + const [coverUrls, setCoverUrls] = useState>({}); + + useEffect(() => { + const invoke = resolveTauriInvoke(); + if (!invoke || games.length === 0) { + return; + } + let cancelled = false; + void (async () => { + const resolved: Record = {}; + await Promise.all( + games.map(async (game) => { + const url = await resolveCoverPreview(invoke, game, accountScope); + if (url) { + resolved[game.id] = url; + } + }), + ); + if (cancelled) { + return; + } + setCoverUrls((current) => ({ ...current, ...resolved })); + })(); + return () => { + cancelled = true; + }; + }, [games, accountScope]); + + return coverUrls; +} 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 64cdc18a9..b0a48f777 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 @@ -24,6 +24,7 @@ import { PLATFORM_FORK_INPUT_ERROR, } from '../../features/platform-fork/platformGameForkModel'; import { usePlatformFork } from '../../features/platform-fork/usePlatformFork'; +import { usePlatformForkCovers } from '../../features/platform-fork/usePlatformForkCovers'; import type { ProjectOpenAnalytics } from '../../services/clientAnalytics'; type CoCreationViewProps = { @@ -140,6 +141,7 @@ export default function CoCreationView({ } const games = catalog?.games ?? []; + const coverUrls = usePlatformForkCovers(games); const scannedHint = catalog ? `已检查最新 ${catalog.scannedGames} 个作品(${catalog.scannedPages} 页${ catalog.cappedByPageLimit ? `,最多 ${FORK_CATALOG_MAX_PAGES} 页` : '' @@ -285,15 +287,29 @@ export default function CoCreationView({
{games.map((game) => { const active = fork.activeGameId === game.id; + const coverUrl = coverUrls[game.id]; return (
diff --git a/apps/ai-game-creator-shell/tests/coCreation.test.tsx b/apps/ai-game-creator-shell/tests/coCreation.test.tsx index 2aedab6cf..e3453d6c1 100644 --- a/apps/ai-game-creator-shell/tests/coCreation.test.tsx +++ b/apps/ai-game-creator-shell/tests/coCreation.test.tsx @@ -272,6 +272,60 @@ describe('共创页', () => { expect(await screen.findByText('开放共创的作品')).not.toBeNull(); }); + it('有封面时用有界 data URL 渲染,换签失败或没有封面则保留首字占位', async () => { + const calls = installInvoke((command, args) => { + if (command === CATALOG_COMMAND) { + return { + games: [ + { + ...catalogGame('game_with_cover', '有封面的作品', 'full'), + coverObjectKey: 'game-distribution/cover/open.png', + }, + { + ...catalogGame('game_broken_cover', '换签失败的作品', 'full'), + coverObjectKey: 'game-distribution/cover/broken.png', + }, + catalogGame('game_no_cover', '没有封面的作品', 'full'), + ], + nextCursor: null, + }; + } + if (command === 'read_public_game_cover_preview') { + if ((args as { objectKey?: string })?.objectKey?.includes('broken')) { + throw new Error('换签失败'); + } + return 'data:image/png;base64,AAAA'; + } + throw new Error(`unexpected invoke ${command}`); + }); + const { container } = render(); + + // 有封面的那张走 ,src 是命令返回的 data URL。 + const image = await screen.findByRole('img', { + name: '有封面的作品 封面', + }); + expect(image.getAttribute('src')).toBe('data:image/png;base64,AAAA'); + // 失败与缺封面都保留首字占位:不出现破图(即没有第二张 )。 + expect(container.querySelectorAll('img')).toHaveLength(1); + expect( + screen.queryByRole('img', { name: '换签失败的作品 封面' }), + ).toBeNull(); + expect( + screen.queryByRole('img', { name: '没有封面的作品 封面' }), + ).toBeNull(); + // 没有对象键的那张根本不该发换签请求。 + expect( + calls.filter( + (call) => + call.command === 'read_public_game_cover_preview' && + (call.args as { objectKey?: string })?.objectKey === undefined, + ), + ).toHaveLength(0); + expect( + calls.filter((call) => call.command === 'read_public_game_cover_preview'), + ).toHaveLength(2); + }); + it('深链只预填输入框,不自动开始下载', async () => { const calls = installInvoke((command) => { if (command === CATALOG_COMMAND) {