feat(游戏共创): 共创列表补上真实封面(有界 data URL,失败回退首字占位)
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
- 新命令 `read_public_game_cover_preview(objectKey)`:复用发布面板封面那套链路 (`read-url` 换签 → `resolve_canvas_resource_download_with_limit_route_and_fence` 有界下载 → `cover_preview_data_url`),返回**有界 data URL**。 为什么不返回签名地址让渲染层 `<img src>`:AGC 既有约定是「渲染层不自行拼 read-url、不接触对象键 与 bearer token、也不让 WebView 直连签名地址」(`commands.rs` 里同一口径的注释),因此沿用 data URL 方案——顺带免掉签名过期与 CSP 白名单两个问题。 对象键做最小校验(非空、≤512、不含 `..`);失败如实返回 `Err`,由调用方回退占位。 - 渲染层 `platformForkCover.ts`:会话内缓存 + 并发去重。 · 成功:会话内一直复用(data URL 自身不过期); · 失败:缓存 60 秒(沿用网页端失败缓存窗口),避免同一张坏图在每张卡片上反复重试; · 提供 `clearCoverPreviewCache()` 供换号时清空(换号后旧封面可能已无权访问)。 - `usePlatformForkCovers` 钩子按当前列表批量解析;卡片上有封面就 `<img>`(`object-cover`), 没有封面 / 换签失败 / 超限一律**保留原来的首字占位 + `coverColor` 底色**(降级逐条测到)。 测试:新增一条覆盖三种情形——有封面(断言 `src` 是命令返回的 data URL)、换签失败(保留占位、 不出现第二张 `<img>`)、没有 `coverObjectKey`(不发换签请求)。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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**。
|
||||
///
|
||||
/// 为什么不让渲染层直接拿签名地址去 `<img src>`: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<String, String> {
|
||||
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
|
||||
|
||||
@@ -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<string, CoverEntry>();
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
type CoverInvoke = <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
/** 当前登录账号切换时清空缓存:换号后旧封面可能已无权访问。 */
|
||||
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<GameDistributionGame, 'coverObjectKey'>,
|
||||
accountScope = '',
|
||||
): Promise<string | null> {
|
||||
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<string>(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;
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const [coverUrls, setCoverUrls] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || games.length === 0) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const resolved: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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({
|
||||
<div className="grid grid-cols-3 gap-4 max-[900px]:grid-cols-2 max-[760px]:grid-cols-1">
|
||||
{games.map((game) => {
|
||||
const active = fork.activeGameId === game.id;
|
||||
const coverUrl = coverUrls[game.id];
|
||||
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)">
|
||||
<span
|
||||
className="grid min-h-29 place-items-center rounded-lg text-[26px] font-bold text-white shadow-inner"
|
||||
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' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{(game.title || game.id).slice(0, 1)}
|
||||
{/*
|
||||
封面:优先用有界 data URL(AGC 不让渲染层直连签名地址)。没有封面、换签失败
|
||||
或超限时保持首字占位——**降级必须保留**,否则列表会出现破图。
|
||||
*/}
|
||||
{coverUrl ? (
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={coverUrl}
|
||||
alt={`${game.title || game.id} 封面`}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">
|
||||
{(game.title || game.id).slice(0, 1)}
|
||||
</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)">
|
||||
|
||||
@@ -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(<CoCreationView onProjectCreated={vi.fn()} />);
|
||||
|
||||
// 有封面的那张走 <img>,src 是命令返回的 data URL。
|
||||
const image = await screen.findByRole('img', {
|
||||
name: '有封面的作品 封面',
|
||||
});
|
||||
expect(image.getAttribute('src')).toBe('data:image/png;base64,AAAA');
|
||||
// 失败与缺封面都保留首字占位:不出现破图(即没有第二张 <img>)。
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user