合并主分支并解决排障文档冲突
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m29s
Project CI / Backend tests (pull_request) Successful in 5m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m52s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m43s
Project CI / Native shell tests (pull_request) Successful in 7m23s
Project CI / Frontend tests (pull_request) Successful in 2m37s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / Repository checks (pull_request) Successful in 2m28s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m29s
Project CI / Backend tests (pull_request) Successful in 5m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m52s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m43s
Project CI / Native shell tests (pull_request) Successful in 7m23s
Project CI / Frontend tests (pull_request) Successful in 2m37s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / Repository checks (pull_request) Successful in 2m28s
合并 origin/master 的最新改动到 fix/ga 保留宿主继续请求输入修复与最近项目检查恢复的两条排障记录
This commit is contained in:
+2
-6
@@ -237,14 +237,10 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
|
||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||
# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.world,release 对应 https://www.genarrative.world。
|
||||
# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。
|
||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||
|
||||
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。
|
||||
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。
|
||||
# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。
|
||||
# dev 使用 https://dev.genarrative.world;release 使用 https://www.genarrative.world。
|
||||
GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -339,7 +339,10 @@ test.each([409, 503])(
|
||||
await confirmWrite();
|
||||
const message = await screen.findByRole('alert');
|
||||
const feedback = message.parentElement!;
|
||||
expect(document.activeElement).toBe(feedback);
|
||||
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
|
||||
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
|
||||
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
|
||||
await waitFor(() => expect(document.activeElement).toBe(feedback));
|
||||
expect(feedback.tabIndex).toBe(-1);
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(viewport.scrollTop).toBe(20);
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
TauriInvoke,
|
||||
} from './app/types';
|
||||
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
|
||||
import { GamePublishBlockedDialog } from './components/game-distribution/GamePublishBlockedDialog';
|
||||
import {
|
||||
GamePublishProgressDialog,
|
||||
type GamePublishProgressState,
|
||||
@@ -359,6 +360,9 @@ export function App({
|
||||
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
|
||||
const [publishProgress, setPublishProgress] =
|
||||
useState<GamePublishProgressState | null>(null);
|
||||
const [publishBlockedMessage, setPublishBlockedMessage] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
|
||||
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
|
||||
const [projectChatError, setProjectChatError] = useState('');
|
||||
@@ -1166,27 +1170,16 @@ export function App({
|
||||
* 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区,
|
||||
* 导出失败在弹窗内回显;成功后才切换到发布资料面板。
|
||||
*/
|
||||
/**
|
||||
* 发布相关提示同时写工作台状态与 DirectProject 对话。
|
||||
*
|
||||
* 普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,只写 workspaceStatus
|
||||
* 会让「点了发布没反应」;这里统一通过聊天容器的 announce 出口回话。
|
||||
*/
|
||||
function announcePublishMessage(message: string) {
|
||||
setWorkspaceStatus(message);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
}
|
||||
|
||||
async function requestGamePublish() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
announcePublishMessage('需要在 Tauri App 内发布');
|
||||
setPublishBlockedMessage('需要在 Tauri App 内发布');
|
||||
return;
|
||||
}
|
||||
const nextProjectPath =
|
||||
resolveChatProjectPath(localProject) ?? projectPath.trim();
|
||||
if (!nextProjectPath) {
|
||||
announcePublishMessage('先打开一个项目再发布');
|
||||
setPublishBlockedMessage('先打开一个项目再发布');
|
||||
return;
|
||||
}
|
||||
const publishManifest = manifestRef.current;
|
||||
@@ -1197,7 +1190,7 @@ export function App({
|
||||
publishManifest.preview?.status === 'running' &&
|
||||
Boolean(publishManifest.preview.url?.trim());
|
||||
if (!hasCompletedPrototype && !hasRunningPreview) {
|
||||
announcePublishMessage(
|
||||
setPublishBlockedMessage(
|
||||
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
|
||||
);
|
||||
return;
|
||||
@@ -2276,6 +2269,10 @@ export function App({
|
||||
progress={publishProgress}
|
||||
onClose={() => setPublishProgress(null)}
|
||||
/>
|
||||
<GamePublishBlockedDialog
|
||||
message={publishBlockedMessage}
|
||||
onClose={() => setPublishBlockedMessage(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2431,6 +2428,10 @@ export function App({
|
||||
progress={publishProgress}
|
||||
onClose={() => setPublishProgress(null)}
|
||||
/>
|
||||
<GamePublishBlockedDialog
|
||||
message={publishBlockedMessage}
|
||||
onClose={() => setPublishBlockedMessage(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export function resolveTauriInvoke() {
|
||||
import type { TauriInvoke } from './types';
|
||||
|
||||
/** 返回 undefined 表示不在 Tauri 环境(浏览器预览、测试壳),调用方必须先判空。 */
|
||||
export function resolveTauriInvoke(): TauriInvoke | undefined {
|
||||
return window.__TAURI__?.core?.invoke;
|
||||
}
|
||||
|
||||
+615
-246
File diff suppressed because it is too large
Load Diff
+31
@@ -0,0 +1,31 @@
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
export function GamePublishBlockedDialog({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ThemedModal
|
||||
open={message !== null}
|
||||
ariaLabel="发布提示"
|
||||
onClose={onClose}
|
||||
overlayClassName="game-publish-progress-overlay"
|
||||
panelClassName="game-publish-progress-dialog game-publish-blocked-dialog"
|
||||
panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
|
||||
>
|
||||
<div className="game-publish-progress-icon is-failed" aria-hidden="true">
|
||||
<CircleAlert size={30} />
|
||||
</div>
|
||||
<h2>暂不能发布</h2>
|
||||
<p role="alert">{message}</p>
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { LocalProjectDirectoryStatus } from '../../app/types';
|
||||
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
|
||||
import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
@@ -21,6 +21,90 @@ import {
|
||||
} from './model';
|
||||
|
||||
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
|
||||
/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */
|
||||
const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
|
||||
/** 一轮结束仍有可重试失败时重跑整张列表的退避,数组长度即重跑次数上限。 */
|
||||
const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000];
|
||||
/**
|
||||
* 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`,
|
||||
* 而提权闸门只存在于单次 invoke 内,重试等于在用户刚点「否」后再弹一次 UAC。
|
||||
* 判据与 config.rs 的 `windows_acl_error_may_need_elevation` 同口径。
|
||||
*/
|
||||
const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [
|
||||
'DACL',
|
||||
'权限',
|
||||
'error 5',
|
||||
'安全对象不属于当前用户',
|
||||
'启用 Windows',
|
||||
'特权',
|
||||
'1300',
|
||||
'AGC ACL 提权修复未成功',
|
||||
];
|
||||
|
||||
type RecentWorkspaceInspection = {
|
||||
path: string;
|
||||
status: LocalProjectDirectoryStatus | null;
|
||||
/** false 表示提权/权限类失败:既不重试,也不驱动整表重查。 */
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
function recentWorkspaceFailureIsRetryable(message: string): boolean {
|
||||
return !RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS.some((marker) =>
|
||||
message.includes(marker),
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectRecentWorkspace(
|
||||
invoke: TauriInvoke,
|
||||
workspace: string,
|
||||
): Promise<RecentWorkspaceInspection> {
|
||||
let timeoutHandle: number | undefined;
|
||||
try {
|
||||
const status = await Promise.race([
|
||||
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
|
||||
projectPath: workspace,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = window.setTimeout(
|
||||
() => reject(new Error('项目目录检查超时')),
|
||||
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
return { path: workspace, status, retryable: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
path: workspace,
|
||||
status: null,
|
||||
retryable: recentWorkspaceFailureIsRetryable(message),
|
||||
};
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectRecentWorkspaceWithRetry(
|
||||
invoke: TauriInvoke,
|
||||
workspace: string,
|
||||
): Promise<RecentWorkspaceInspection> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const inspection = await inspectRecentWorkspace(invoke, workspace);
|
||||
const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt];
|
||||
if (
|
||||
inspection.status ||
|
||||
!inspection.retryable ||
|
||||
retryDelayMs === undefined
|
||||
) {
|
||||
return inspection;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, retryDelayMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const [recentWorkspaces, setRecentWorkspaces] =
|
||||
@@ -35,32 +119,28 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const recentWorkspacesRef = useRef(recentWorkspaces);
|
||||
recentWorkspacesRef.current = recentWorkspaces;
|
||||
const inspectionGenerationRef = useRef(0);
|
||||
const failureRecheckTimerRef = useRef<number | undefined>(undefined);
|
||||
const failureStreakRef = useRef(0);
|
||||
const lastRetryableFailureKeyRef = useRef('');
|
||||
// 提权类失败在用户再次主动操作前不再自动重试。
|
||||
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
async function inspectRecentWorkspace(
|
||||
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
|
||||
workspace: string,
|
||||
): Promise<[string, LocalProjectDirectoryStatus | null]> {
|
||||
let timeoutHandle: number | undefined;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
|
||||
projectPath: workspace,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = window.setTimeout(
|
||||
() => reject(new Error('项目目录检查超时')),
|
||||
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
return [workspace, result];
|
||||
} catch {
|
||||
return [workspace, null];
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
}
|
||||
function scheduleFailureRecheck(retryableFailureCount: number) {
|
||||
window.clearTimeout(failureRecheckTimerRef.current);
|
||||
if (retryableFailureCount === 0) {
|
||||
failureStreakRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const recheckDelayMs =
|
||||
RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current];
|
||||
if (recheckDelayMs === undefined) {
|
||||
return;
|
||||
}
|
||||
failureStreakRef.current += 1;
|
||||
failureRecheckTimerRef.current = window.setTimeout(() => {
|
||||
failureRecheckTimerRef.current = undefined;
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
}, recheckDelayMs);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,24 +153,43 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
}
|
||||
const inspectionGeneration = ++inspectionGenerationRef.current;
|
||||
let disposed = false;
|
||||
let pendingCount = recentWorkspaces.length;
|
||||
// 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或
|
||||
// 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表
|
||||
// 清空成同一个异常状态。
|
||||
const pendingWorkspaces = recentWorkspaces.filter(
|
||||
(workspace) => !nonRetryablePathsRef.current.has(workspace),
|
||||
);
|
||||
let pendingCount = pendingWorkspaces.length;
|
||||
const retryableFailures: string[] = [];
|
||||
const finishRound = () => {
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
// 失败集合变化即重置退避预算,避免长期坏目录吃满新瞬时失败的额度。
|
||||
const failureKey = [...retryableFailures].sort().join('|');
|
||||
if (failureKey !== lastRetryableFailureKeyRef.current) {
|
||||
lastRetryableFailureKeyRef.current = failureKey;
|
||||
failureStreakRef.current = 0;
|
||||
}
|
||||
scheduleFailureRecheck(retryableFailures.length);
|
||||
};
|
||||
// 刷新是增量投影:成功结果保留,但上一轮的失败结果不进新投影,
|
||||
// 失败项回到「检查中」并在本轮重新检查。
|
||||
setRecentWorkspaceStatuses((current) => {
|
||||
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
|
||||
for (const workspace of recentWorkspaces) {
|
||||
if (Object.prototype.hasOwnProperty.call(current, workspace)) {
|
||||
next[workspace] = current[workspace] ?? null;
|
||||
const status = current[workspace];
|
||||
if (status) {
|
||||
next[workspace] = status;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setRecentWorkspaceRefreshing(true);
|
||||
|
||||
for (const workspace of recentWorkspaces) {
|
||||
void inspectRecentWorkspace(invoke, workspace).then(
|
||||
([projectPath, status]) => {
|
||||
if (pendingCount === 0) {
|
||||
finishRound();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const workspace of pendingWorkspaces) {
|
||||
void inspectRecentWorkspaceWithRetry(invoke, workspace).then(
|
||||
({ path: projectPath, status, retryable }) => {
|
||||
if (
|
||||
disposed ||
|
||||
inspectionGeneration !== inspectionGenerationRef.current ||
|
||||
@@ -98,23 +197,34 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (status) {
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
} else if (retryable) {
|
||||
retryableFailures.push(projectPath);
|
||||
} else {
|
||||
nonRetryablePathsRef.current.add(projectPath);
|
||||
}
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
[projectPath]: status,
|
||||
}));
|
||||
pendingCount -= 1;
|
||||
if (pendingCount === 0) {
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
finishRound();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(failureRecheckTimerRef.current);
|
||||
failureRecheckTimerRef.current = undefined;
|
||||
};
|
||||
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
|
||||
|
||||
function rememberRecentWorkspace(projectPath: string) {
|
||||
// 用户主动打开或新建项目:解除提权类失败的跳过标记。
|
||||
nonRetryablePathsRef.current.clear();
|
||||
setRecentWorkspaces(writeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
}
|
||||
@@ -124,17 +234,29 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
const inspection = await inspectRecentWorkspaceWithRetry(
|
||||
invoke,
|
||||
projectPath,
|
||||
);
|
||||
if (!recentWorkspacesRef.current.includes(projectPath)) {
|
||||
return;
|
||||
}
|
||||
if (inspection.status) {
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
} else if (inspection.retryable) {
|
||||
scheduleFailureRecheck(1);
|
||||
} else {
|
||||
nonRetryablePathsRef.current.add(projectPath);
|
||||
}
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
[projectPath]: status,
|
||||
[projectPath]: inspection.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRecentWorkspaceRemove(projectPath: string) {
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
setRecentWorkspaces(removeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceStatuses((current) => {
|
||||
const { [projectPath]: _removed, ...rest } = current;
|
||||
|
||||
@@ -14,8 +14,31 @@ export const TEMPLATE_CARD_MIN_WIDTH = 250;
|
||||
export const TEMPLATE_CARD_GAP = 14;
|
||||
/** 封面宽高比:16:9。 */
|
||||
export const TEMPLATE_CARD_COVER_RATIO = 9 / 16;
|
||||
/** 卡片封面以下的文字与按钮区固定高度。 */
|
||||
export const TEMPLATE_CARD_TEXT_HEIGHT = 150;
|
||||
/**
|
||||
* 卡片封面以下的文字与按钮区固定高度。
|
||||
*
|
||||
* 虚拟列表要求行高完全确定,所以文字区**不放任内容撑高**:`TemplateCard` 里每一行都
|
||||
* 写死高度(标题 `h-5`、元信息 `h-4`、简介 `h-8`、标签 `h-5.5`、按钮行 `h-7`),
|
||||
* 这里按同样的口径把总高算出来。两边必须同时改,下面的分项常量就是这条契约的锚点。
|
||||
*/
|
||||
export const TEMPLATE_CARD_TITLE_HEIGHT = 20;
|
||||
export const TEMPLATE_CARD_META_HEIGHT = 16;
|
||||
export const TEMPLATE_CARD_SUMMARY_HEIGHT = 32;
|
||||
export const TEMPLATE_CARD_TAGS_HEIGHT = 22;
|
||||
export const TEMPLATE_CARD_ACTIONS_HEIGHT = 28;
|
||||
/** 文字区上下内边距(`p-3`)。 */
|
||||
export const TEMPLATE_CARD_TEXT_PADDING = 12;
|
||||
/** 文字区行间距(`gap-2`)。 */
|
||||
export const TEMPLATE_CARD_TEXT_GAP = 8;
|
||||
/** 文字区共 5 行、4 个行间距。 */
|
||||
export const TEMPLATE_CARD_TEXT_HEIGHT =
|
||||
TEMPLATE_CARD_TEXT_PADDING * 2 +
|
||||
TEMPLATE_CARD_TITLE_HEIGHT +
|
||||
TEMPLATE_CARD_META_HEIGHT +
|
||||
TEMPLATE_CARD_SUMMARY_HEIGHT +
|
||||
TEMPLATE_CARD_TAGS_HEIGHT +
|
||||
TEMPLATE_CARD_ACTIONS_HEIGHT +
|
||||
TEMPLATE_CARD_TEXT_GAP * 4;
|
||||
/** 额外预渲染的行数,减小快速滚动时的白屏。 */
|
||||
export const TEMPLATE_GRID_OVERSCAN_ROWS = 2;
|
||||
|
||||
@@ -68,6 +91,40 @@ export function computeTemplateGridLayout({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 带竖滚动条预留的布局。
|
||||
*
|
||||
* react-window 的内层宽度是 `列数 × 列宽`,而**经典(非 overlay)滚动条**会吃掉外层
|
||||
* `clientWidth`:Windows / WebView2 上竖滚动条约 15–17px,于是内层比外层可用宽度多出
|
||||
* 正好一个滚动条,卡片区底部就多出一条横向滚动条。这里在「内容确实会竖向溢出」时,
|
||||
* 先把滚动条宽度从容器宽度里扣掉再算列宽;不会竖向溢出时保持原口径,避免右侧留一条
|
||||
* 无意义的白边。overlay 滚动条平台(占宽 0)结果与不预留完全一致。
|
||||
*/
|
||||
export function computeTemplateGridLayoutWithScrollbar({
|
||||
containerWidth,
|
||||
itemCount,
|
||||
viewportHeight,
|
||||
scrollbarWidth,
|
||||
}: {
|
||||
containerWidth: number;
|
||||
itemCount: number;
|
||||
viewportHeight: number;
|
||||
scrollbarWidth: number;
|
||||
}): TemplateGridLayout {
|
||||
const full = computeTemplateGridLayout({ containerWidth, itemCount });
|
||||
const reserve = Math.max(0, scrollbarWidth);
|
||||
if (reserve === 0 || full.rowCount * full.rowHeight <= viewportHeight) {
|
||||
return full;
|
||||
}
|
||||
const usableWidth = Math.max(0, containerWidth - reserve);
|
||||
const reserved = computeTemplateGridLayout({
|
||||
containerWidth: usableWidth,
|
||||
itemCount,
|
||||
});
|
||||
// 预留后行数变多时,竖向溢出只会更明显,不会退回「不需要滚动条」的情况。
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/** 按行切分,行尾补 `null` 占位,保证虚拟列表的列索引与条目一一对应。 */
|
||||
export function buildTemplateRows(
|
||||
templates: readonly GameTemplateEntry[],
|
||||
|
||||
@@ -127,10 +127,16 @@ export function filterGameTemplates(
|
||||
});
|
||||
}
|
||||
|
||||
/** 筛选条上的单个标签:名称 + 命中的模板数。 */
|
||||
export type GameTemplateTagOption = {
|
||||
tag: string;
|
||||
assetCount: number;
|
||||
};
|
||||
|
||||
/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */
|
||||
export function collectGameTemplateTags(
|
||||
export function collectGameTemplateTagOptions(
|
||||
templates: readonly GameTemplateEntry[],
|
||||
): string[] {
|
||||
): GameTemplateTagOption[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const template of templates) {
|
||||
for (const tag of template.tags) {
|
||||
@@ -144,7 +150,13 @@ export function collectGameTemplateTags(
|
||||
([leftTag, leftCount], [rightTag, rightCount]) =>
|
||||
rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'),
|
||||
)
|
||||
.map(([tag]) => tag);
|
||||
.map(([tag, assetCount]) => ({ tag, assetCount }));
|
||||
}
|
||||
|
||||
export function collectGameTemplateTags(
|
||||
templates: readonly GameTemplateEntry[],
|
||||
): string[] {
|
||||
return collectGameTemplateTagOptions(templates).map((option) => option.tag);
|
||||
}
|
||||
|
||||
export function collectGameTemplateRuntimes(
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { readProjectCreationDirectory } from '../app-shell/model';
|
||||
import {
|
||||
collectGameTemplateRuntimes,
|
||||
collectGameTemplateTags,
|
||||
EMPTY_TEMPLATE_LIBRARY_FILTERS,
|
||||
filterGameTemplates,
|
||||
type GameTemplateEntry,
|
||||
@@ -298,10 +297,6 @@ export function useTemplateLibrary({
|
||||
() => filterGameTemplates(templates, filters),
|
||||
[templates, filters],
|
||||
);
|
||||
const tagOptions = useMemo(
|
||||
() => collectGameTemplateTags(templates),
|
||||
[templates],
|
||||
);
|
||||
const runtimeOptions = useMemo(
|
||||
() => collectGameTemplateRuntimes(templates),
|
||||
[templates],
|
||||
@@ -343,7 +338,6 @@ export function useTemplateLibrary({
|
||||
notice,
|
||||
templates,
|
||||
visibleTemplates,
|
||||
tagOptions,
|
||||
runtimeOptions,
|
||||
installedCount,
|
||||
filters,
|
||||
|
||||
@@ -82,6 +82,175 @@ export function createGameDistributionPublishKey() {
|
||||
return `agc-publish-${randomUuid}`;
|
||||
}
|
||||
|
||||
export type GameDistributionPublishMetadataSuggestion = {
|
||||
summary: string;
|
||||
category: GameDistributionCategory;
|
||||
};
|
||||
|
||||
export async function suggestGameDistributionPublishMetadata(args: {
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
context?: string | null;
|
||||
}): Promise<GameDistributionPublishMetadataSuggestion> {
|
||||
return requestClientApi<GameDistributionPublishMetadataSuggestion>(
|
||||
'/api/game-distribution/publish-metadata/suggestions',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: args.name.trim(),
|
||||
goal: args.goal?.trim() || null,
|
||||
context: args.context?.trim() || null,
|
||||
}),
|
||||
},
|
||||
'生成发布简介和分类失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function readGameCoverGenerationPrice(args: {
|
||||
model: string;
|
||||
imageSize: string;
|
||||
}): Promise<number> {
|
||||
const config = await requestClientApi<{
|
||||
models?: Record<
|
||||
string,
|
||||
{ price?: number; prices?: Record<string, number> } | undefined
|
||||
>;
|
||||
}>(
|
||||
'/api/editor/generation-pricing',
|
||||
{ method: 'GET' },
|
||||
'读取封面生成价格失败',
|
||||
{ skipAuth: true },
|
||||
);
|
||||
const model = config.models?.[args.model];
|
||||
const price = model?.prices?.[args.imageSize] ?? model?.price;
|
||||
if (typeof price !== 'number' || !Number.isFinite(price) || price < 0) {
|
||||
throw new Error('封面生成价格暂不可用');
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
export type GameDistributionCoverGenerationResult = {
|
||||
assetObjectId: string;
|
||||
previewUrl: string;
|
||||
taskId: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type GameDistributionCoverGenerationPayload = {
|
||||
imageSrc?: string;
|
||||
assetObjectId?: string | null;
|
||||
asset?: { assetObjectId?: string | null } | null;
|
||||
taskId?: string;
|
||||
model?: string;
|
||||
queueState?: {
|
||||
operationId?: string | null;
|
||||
status?: string | null;
|
||||
phaseDetail?: string | null;
|
||||
error?: string | null;
|
||||
result?: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const COVER_GENERATION_QUEUE_POLL_INTERVAL_MS = 1_600;
|
||||
const COVER_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
function waitForCoverGenerationQueue() {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, COVER_GENERATION_QUEUE_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveQueuedGameDistributionCover(
|
||||
payload: GameDistributionCoverGenerationPayload,
|
||||
): Promise<GameDistributionCoverGenerationPayload> {
|
||||
const queueState = payload.queueState;
|
||||
const operationId = queueState?.operationId?.trim();
|
||||
if (
|
||||
!operationId ||
|
||||
(queueState?.status !== 'queued' && queueState?.status !== 'running')
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
for (;;) {
|
||||
if (Date.now() - startedAt > COVER_GENERATION_QUEUE_TIMEOUT_MS) {
|
||||
throw new Error('生成游戏封面超时,请稍后重试');
|
||||
}
|
||||
await waitForCoverGenerationQueue();
|
||||
const status = await requestClientApi<{
|
||||
job: {
|
||||
status: string;
|
||||
phaseDetail?: string | null;
|
||||
error?: string | null;
|
||||
result?: unknown;
|
||||
};
|
||||
}>(
|
||||
`/api/runtime/external-generation/jobs/${encodeURIComponent(operationId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取封面生成任务失败',
|
||||
);
|
||||
if (status.job.status === 'failed') {
|
||||
throw new Error(status.job.error?.trim() || '生成游戏封面失败');
|
||||
}
|
||||
if (status.job.status === 'completed') {
|
||||
const result =
|
||||
status.job.result && typeof status.job.result === 'object'
|
||||
? (status.job.result as GameDistributionCoverGenerationPayload)
|
||||
: {};
|
||||
return { ...payload, ...result, queueState: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于项目上下文生成发行封面。
|
||||
*
|
||||
* 这里复用编辑器图片生成 API:服务端按模型定价计费,并在生成成功后返回已经登记的
|
||||
* 平台素材 ID。调用方不能把返回的预览地址当作发布封面,必须提交 assetObjectId。
|
||||
*/
|
||||
export async function generateGameDistributionCover(args: {
|
||||
prompt: string;
|
||||
model: string;
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
assetLabel?: string;
|
||||
}): Promise<GameDistributionCoverGenerationResult> {
|
||||
const initialPayload =
|
||||
await requestClientApi<GameDistributionCoverGenerationPayload>(
|
||||
'/api/editor/images/generations',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: args.prompt.trim(),
|
||||
kind: 'publication-material',
|
||||
assetKind: 'publication-material',
|
||||
model: args.model,
|
||||
aspectRatio: args.aspectRatio ?? '16:9',
|
||||
imageSize: args.imageSize ?? '2K',
|
||||
assetLabel: args.assetLabel?.trim() || '游戏封面',
|
||||
}),
|
||||
},
|
||||
'生成游戏封面失败',
|
||||
);
|
||||
const payload = await resolveQueuedGameDistributionCover(initialPayload);
|
||||
const assetObjectId = (
|
||||
payload.assetObjectId ??
|
||||
payload.asset?.assetObjectId ??
|
||||
''
|
||||
).trim();
|
||||
if (!assetObjectId) {
|
||||
throw new Error('生成游戏封面未返回平台素材 ID');
|
||||
}
|
||||
return {
|
||||
assetObjectId,
|
||||
previewUrl: payload.imageSrc?.trim() || '',
|
||||
taskId: payload.taskId?.trim() || '',
|
||||
model: payload.model?.trim() || args.model,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMetadata(
|
||||
manifest: GameCreationAppManifest,
|
||||
metadata?: Partial<GameDistributionPublishMetadata>,
|
||||
|
||||
@@ -3520,36 +3520,17 @@ textarea {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__intro {
|
||||
margin: 18px 0;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__package {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgb(168 102 61 / 16%);
|
||||
border-radius: 12px;
|
||||
background: rgb(168 102 61 / 6%);
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__package span:first-child {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-strong);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__metadata-hint {
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__fields label {
|
||||
@@ -3701,7 +3682,65 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button,
|
||||
.game-distribution-publish-panel__cover-editor {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-editor-label {
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-actions
|
||||
.game-distribution-publish-panel__picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-actions
|
||||
.game-distribution-publish-panel__pick,
|
||||
.game-distribution-publish-panel__cover-actions button {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
min-height: 32px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 76%);
|
||||
color: var(--platform-text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-actions
|
||||
.game-distribution-publish-panel__generate-cover {
|
||||
border-color: #a8663d;
|
||||
background: #a8663d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-actions button:disabled,
|
||||
.game-distribution-publish-panel__cover-actions
|
||||
input[type='file']:disabled
|
||||
+ .game-distribution-publish-panel__pick {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots button {
|
||||
justify-self: start;
|
||||
width: fit-content;
|
||||
@@ -3715,9 +3754,9 @@ textarea {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 移除封面按钮固定落在文字列,避免占掉预览列的第二行。 */
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 2;
|
||||
.game-publish-cover-confirm-dialog .game-distribution-publish-panel__actions {
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots {
|
||||
@@ -3735,34 +3774,138 @@ textarea {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img,
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
.game-distribution-publish-panel__shot-media {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(104 77 57 / 18%);
|
||||
border-radius: 10px;
|
||||
background: rgb(168 102 61 / 8%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots img {
|
||||
.game-distribution-publish-panel__shot-media img,
|
||||
.game-distribution-publish-panel__shot-media
|
||||
> span:not([class*='game-distribution-publish-panel__shot-']) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shots li > span {
|
||||
.game-distribution-publish-panel__shot-media
|
||||
> span:not([class*='game-distribution-publish-panel__shot-']) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-style: dashed;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-state,
|
||||
.game-distribution-publish-panel__shot-error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
padding: 8px 8px 34px;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-state {
|
||||
background: rgb(35 24 19 / 56%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-error {
|
||||
overflow: auto;
|
||||
background: rgb(125 30 20 / 82%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: rgb(35 24 19 / 62%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-media:hover
|
||||
.game-distribution-publish-panel__shot-actions,
|
||||
.game-distribution-publish-panel__shot-media:focus-within
|
||||
.game-distribution-publish-panel__shot-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-actions.is-failed {
|
||||
inset: auto 0 6px;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-actions button {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-actions button:hover:not(:disabled) {
|
||||
background: rgb(255 255 255 / 16%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__shot-actions > span {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgb(255 255 255 / 55%);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__preview-dialog {
|
||||
display: grid;
|
||||
width: min(860px, calc(100vw - 40px));
|
||||
max-height: calc(100vh - 40px);
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 80px rgb(15 10 8 / 48%);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__preview-dialog img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 90px);
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__preview-dialog p {
|
||||
margin: 0;
|
||||
color: #fffaf7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.game-distribution-publish-panel__cover {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover > button {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__error {
|
||||
@@ -8843,9 +8986,7 @@ iframe.preview-frame {
|
||||
background: var(--platform-warm-bg);
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface
|
||||
> .project-runtime-pending-command,
|
||||
.game-workbench-chat .project-chat-surface > .project-runtime-pending-command,
|
||||
.game-workbench-chat
|
||||
.project-chat-conversation
|
||||
.project-runtime-pending-command {
|
||||
@@ -10974,8 +11115,7 @@ button.design-workspace-tree__entry:hover,
|
||||
这样「消息内容 / 顶栏 / 输入盒」三者共用同一个基准值,不会再出现
|
||||
「消息内缩 16px、输入盒只有 10px」这种左右不齐,或不一致的上/下间距。
|
||||
(非 direct-codex 的面板仍走上面那条 `padding: 10px`。) */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex {
|
||||
.game-workbench-chat .project-chat-surface.is-direct-codex {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -12454,7 +12594,8 @@ button.design-workspace-tree__entry:hover,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
> * + * {
|
||||
> *
|
||||
+ * {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@@ -12511,14 +12652,16 @@ button.design-workspace-tree__entry:hover,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
details[data-testid='live-reasoning'] > summary {
|
||||
details[data-testid='live-reasoning']
|
||||
> summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
details[data-testid='live-reasoning'] pre {
|
||||
details[data-testid='live-reasoning']
|
||||
pre {
|
||||
margin: 6px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -12556,7 +12699,8 @@ button.design-workspace-tree__entry:hover,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
.message p {
|
||||
.message
|
||||
p {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -12580,19 +12724,23 @@ button.design-workspace-tree__entry:hover,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
.message p,
|
||||
.message
|
||||
p,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
.message ul,
|
||||
.message
|
||||
ul,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
.message ol,
|
||||
.message
|
||||
ol,
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
.message li {
|
||||
.message
|
||||
li {
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
@@ -12806,3 +12954,11 @@ details.design-agent-reasoning[open]
|
||||
) {
|
||||
color: var(--platform-button-danger-text, #a6402f);
|
||||
}
|
||||
|
||||
.game-publish-blocked-dialog p[role='alert'] {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
color: #a33f2a;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type RefObject, useEffect, useId, useRef } from 'react';
|
||||
|
||||
import { getPlatformCategoryChipClassName } from '../../../../../packages/shared/src/components/platformCategoryChipModel';
|
||||
import {
|
||||
PlatformFilterPanel,
|
||||
PlatformFilterPanelField,
|
||||
@@ -15,19 +16,6 @@ import {
|
||||
type ResourceFilterTagOption,
|
||||
} from './resourceCanvasFilterModel';
|
||||
|
||||
/**
|
||||
* 已选标签的 chip 用与既有筛选条同一套类名,保持「选中的标签长什么样」只有一处实现。
|
||||
* `PlatformResourceFilterBar` 自己渲染未选标签时用的是同一个类。
|
||||
*/
|
||||
function resourceFilterTagChipClassName(active: boolean) {
|
||||
return [
|
||||
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
|
||||
active ? 'platform-category-chip--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
type ResourceFilterPanelProps = {
|
||||
onClose: () => void;
|
||||
/** 关键词:右下角放大镜与 Ctrl/Cmd+F 叫出的就是这一个面板,状态由宿主持有。 */
|
||||
@@ -186,7 +174,7 @@ export function ResourceFilterPanel({
|
||||
key={option.tag}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={resourceFilterTagChipClassName(active)}
|
||||
className={getPlatformCategoryChipClassName(active)}
|
||||
onClick={() => onToggleTag(option.tag)}
|
||||
>
|
||||
<span>{option.tag}</span>
|
||||
|
||||
+6
-2
@@ -5,7 +5,11 @@ import {
|
||||
CONVERSATION_VISIBLE_STEP,
|
||||
} from '../../../../app/constants';
|
||||
import { resolveTauriInvoke } from '../../../../app/tauri';
|
||||
import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types';
|
||||
import type {
|
||||
ChatMessage,
|
||||
DirectTurnCancelView,
|
||||
TauriInvoke,
|
||||
} from '../../../../app/types';
|
||||
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
|
||||
import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation';
|
||||
import {
|
||||
@@ -547,7 +551,7 @@ export function useDirectProjectChatController({
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
|
||||
invoke: TauriInvoke,
|
||||
nextProjectPath: string,
|
||||
input: DirectProjectTurnInput,
|
||||
) {
|
||||
|
||||
@@ -18,6 +18,10 @@ export type TemplateCardActions = {
|
||||
/**
|
||||
* 虚拟列表里的单个模板卡片:高度由行高契约固定(封面 16:9 + 固定文字区),
|
||||
* 用 memo 包住,滚动时只重渲染可视区域内的少量卡片。
|
||||
*
|
||||
* 文字区每行都写死高度且不参与压缩(`shrink-0`):行高契约(`templateLibraryGrid.ts`
|
||||
* 的 `TEMPLATE_CARD_TEXT_HEIGHT`)按同样的口径算出卡片总高,两边一旦不一致,
|
||||
* 被截断的就是标题、简介和标签这些真实文字。
|
||||
*/
|
||||
function TemplateCardView({
|
||||
template,
|
||||
@@ -28,12 +32,8 @@ function TemplateCardView({
|
||||
}: { template: GameTemplateEntry } & TemplateCardActions) {
|
||||
const busy = busyTemplateId === template.id;
|
||||
const needsDownload = needsTemplateDownload(template);
|
||||
const busyLabel =
|
||||
busy && busyKind === 'download'
|
||||
? '正在下载模板'
|
||||
: busy && busyKind === 'create'
|
||||
? '正在创建项目'
|
||||
: '';
|
||||
const creating = busy && busyKind === 'create';
|
||||
const downloading = busy && busyKind === 'download';
|
||||
const meta = [
|
||||
templateRuntimeLabel(template.runtime),
|
||||
template.engine,
|
||||
@@ -56,6 +56,11 @@ function TemplateCardView({
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/* 封面读不到时留出中性的占位底色,而不是让浏览器画一个「裂图」图标。
|
||||
直接改样式、不进 state:卡片是被 memo 包住的纯展示组件。 */
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
{template.installed ? (
|
||||
<span
|
||||
@@ -67,23 +72,31 @@ function TemplateCardView({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 content-start gap-2 overflow-hidden p-3">
|
||||
<strong className="truncate text-[13px] text-(--platform-text-strong)">
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden p-3">
|
||||
<strong
|
||||
className="block h-5 shrink-0 truncate text-[13px] leading-5 text-(--platform-text-strong)"
|
||||
title={template.title}
|
||||
>
|
||||
{template.title}
|
||||
</strong>
|
||||
<span className="truncate text-[10px] text-(--platform-text-soft)">
|
||||
<span className="block h-4 shrink-0 truncate text-[10px] leading-4 text-(--platform-text-soft)">
|
||||
{meta}
|
||||
</span>
|
||||
{template.summary ? (
|
||||
<p className="m-0 line-clamp-2 text-[11px] leading-4 text-(--platform-neutral-text)">
|
||||
<p className="m-0 line-clamp-2 h-8 shrink-0 text-[11px] leading-4 text-(--platform-neutral-text)">
|
||||
{template.summary}
|
||||
</p>
|
||||
) : null}
|
||||
{template.tags.length > 0 ? (
|
||||
<div className="flex max-h-5.5 flex-wrap gap-1 overflow-hidden">
|
||||
// 卡片只给标签一行(行高契约固定 22px)。清单模板 ≤4 个标签时正好放得下;
|
||||
// 真出现超长标签集合时这一行会裁掉后半段,用 title 兜住完整列表。
|
||||
<div
|
||||
className="flex h-5.5 shrink-0 flex-wrap gap-1 overflow-hidden"
|
||||
title={template.tags.join('、')}
|
||||
>
|
||||
{template.tags.map((tag) => (
|
||||
<span
|
||||
className="rounded-full bg-(--platform-nav-item-hover-fill) px-2 py-0.5 text-[10px] text-(--platform-text-soft)"
|
||||
className="rounded-full bg-(--platform-nav-item-hover-fill) px-2 py-0.5 text-[10px] leading-4 text-(--platform-text-soft)"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
@@ -91,29 +104,32 @@ function TemplateCardView({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
{/* 动作行固定高度、按钮不换行:忙状态写在**触发它的那个按钮**上,不要再往这一行
|
||||
塞第三个元素 —— 卡片最小宽度只有 250px,多一段「正在下载模板」就会把两个
|
||||
按钮的文案挤成两行、顶出卡片。 */}
|
||||
<div className="mt-auto flex h-7 shrink-0 items-center gap-2 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-warm-text) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-warm-text) disabled:cursor-default disabled:opacity-50"
|
||||
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border border-(--platform-warm-text) bg-transparent px-2.5 text-[11px] leading-4 text-(--platform-warm-text) disabled:cursor-default disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
{busy && busyKind === 'create' ? (
|
||||
{creating ? (
|
||||
<Loader2 className="animate-spin" size={12} aria-hidden="true" />
|
||||
) : (
|
||||
<Play size={12} aria-hidden="true" />
|
||||
)}
|
||||
使用模板
|
||||
{creating ? '创建中' : '使用模板'}
|
||||
</button>
|
||||
{/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */}
|
||||
{needsDownload ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-text-soft) disabled:cursor-default disabled:opacity-50"
|
||||
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 text-[11px] leading-4 text-(--platform-text-soft) disabled:cursor-default disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => onDownload(template)}
|
||||
>
|
||||
{busy && busyKind === 'download' ? (
|
||||
{downloading ? (
|
||||
<Loader2
|
||||
className="animate-spin"
|
||||
size={12}
|
||||
@@ -122,14 +138,9 @@ function TemplateCardView({
|
||||
) : (
|
||||
<Download size={12} aria-hidden="true" />
|
||||
)}
|
||||
{template.installed ? '更新' : '下载'}
|
||||
{downloading ? '下载中' : template.installed ? '更新' : '下载'}
|
||||
</button>
|
||||
) : null}
|
||||
{busyLabel ? (
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{busyLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SearchX,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
getPlatformCategoryChipClassName,
|
||||
PlatformRuntimeStatusToast,
|
||||
} from '@genarrative/shared/components';
|
||||
import { ArrowLeft, Loader2, RefreshCw, SearchX } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FixedSizeGrid, type GridChildComponentProps } from 'react-window';
|
||||
|
||||
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import {
|
||||
buildTemplateRows,
|
||||
computeTemplateGridLayout,
|
||||
computeTemplateGridLayoutWithScrollbar,
|
||||
TEMPLATE_CARD_GAP,
|
||||
TEMPLATE_GRID_OVERSCAN_ROWS,
|
||||
templateGridItemKey,
|
||||
} from '../../features/template-library/templateLibraryGrid';
|
||||
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
|
||||
import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
|
||||
import {
|
||||
collectGameTemplateTagOptions,
|
||||
type GameTemplateEntry,
|
||||
templateRuntimeLabel,
|
||||
} from '../../features/template-library/templateLibraryModel';
|
||||
import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary';
|
||||
import { TemplateCard, type TemplateCardActions } from './TemplateCard';
|
||||
|
||||
@@ -73,15 +73,33 @@ function TemplateLibraryToast({
|
||||
);
|
||||
}
|
||||
|
||||
const chipClass =
|
||||
'cursor-pointer rounded-full border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-text-soft) transition hover:border-(--platform-warm-text) hover:text-(--platform-warm-text)';
|
||||
const activeChipClass =
|
||||
'cursor-pointer rounded-full border border-(--platform-warm-text) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-warm-text)';
|
||||
|
||||
type TemplateGridCellData = TemplateCardActions & {
|
||||
rows: Array<Array<GameTemplateEntry | null>>;
|
||||
};
|
||||
|
||||
let cachedScrollbarWidth: number | null = null;
|
||||
|
||||
/**
|
||||
* 量一次经典(非 overlay)竖滚动条的占宽:Windows / WebView2 上约 15–17px,会吃掉
|
||||
* grid 外层的可用宽度。overlay 滚动条平台测得 0(此时预留逻辑不生效)。
|
||||
*/
|
||||
function measureVerticalScrollbarWidth(): number {
|
||||
if (cachedScrollbarWidth !== null) {
|
||||
return cachedScrollbarWidth;
|
||||
}
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return 0;
|
||||
}
|
||||
const probe = document.createElement('div');
|
||||
probe.setAttribute('aria-hidden', 'true');
|
||||
probe.style.cssText =
|
||||
'position:absolute;top:-9999px;left:-9999px;width:100px;height:100px;overflow:scroll';
|
||||
document.body.appendChild(probe);
|
||||
cachedScrollbarWidth = Math.max(0, probe.offsetWidth - probe.clientWidth);
|
||||
probe.remove();
|
||||
return cachedScrollbarWidth;
|
||||
}
|
||||
|
||||
function TemplateGridCell({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
@@ -115,7 +133,6 @@ export default function TemplateLibraryView({
|
||||
notice,
|
||||
templates,
|
||||
visibleTemplates,
|
||||
tagOptions,
|
||||
runtimeOptions,
|
||||
installedCount,
|
||||
filters,
|
||||
@@ -201,16 +218,30 @@ export default function TemplateLibraryView({
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
computeTemplateGridLayout({
|
||||
computeTemplateGridLayoutWithScrollbar({
|
||||
containerWidth: viewportSize.width,
|
||||
itemCount: visibleTemplates.length,
|
||||
viewportHeight: viewportSize.height,
|
||||
scrollbarWidth: measureVerticalScrollbarWidth(),
|
||||
}),
|
||||
[viewportSize.width, visibleTemplates.length],
|
||||
[viewportSize.width, viewportSize.height, visibleTemplates.length],
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => buildTemplateRows(visibleTemplates, layout.columnCount),
|
||||
[visibleTemplates, layout.columnCount],
|
||||
);
|
||||
const tagItems = useMemo(
|
||||
() => collectGameTemplateTagOptions(templates),
|
||||
[templates],
|
||||
);
|
||||
const runtimeItems = useMemo(
|
||||
() =>
|
||||
runtimeOptions.map((runtime) => {
|
||||
const label = templateRuntimeLabel(runtime);
|
||||
return { id: runtime, label, ariaLabel: `运行时筛选 ${label}` };
|
||||
}),
|
||||
[runtimeOptions],
|
||||
);
|
||||
|
||||
// 换筛选条件回到列表顶部:否则筛选后条目变少会把视口留在空白处,看起来像“卡住”。
|
||||
useEffect(() => {
|
||||
@@ -285,83 +316,75 @@ export default function TemplateLibraryView({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */}
|
||||
<div className="grid max-h-[24vh] shrink-0 gap-3 overflow-y-auto rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex min-w-[220px] flex-1 items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5">
|
||||
<Search
|
||||
className="text-(--platform-icon-text)"
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
className="w-full border-0 bg-transparent p-0 text-[12px] text-(--platform-text-strong) outline-none"
|
||||
type="search"
|
||||
value={filters.query}
|
||||
placeholder="搜索模板名称、玩法、标签"
|
||||
aria-label="搜索模板"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{/* 搜索 + 运行时用共享筛选条(与资源画布、参考图弹窗同一套筛选 UI);
|
||||
「仅看已下载」「清除筛选」是模板库自己的口径,靠右跟在同一条上。 */}
|
||||
<div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="模板筛选"
|
||||
className="min-w-0 max-w-[560px] flex-1"
|
||||
search={{
|
||||
value: filters.query,
|
||||
label: '搜索模板',
|
||||
placeholder: '搜索模板名称、玩法、标签',
|
||||
onChange: setQuery,
|
||||
}}
|
||||
categoryItems={runtimeItems}
|
||||
activeCategoryId={filters.runtime}
|
||||
onCategoryChange={selectRuntime}
|
||||
/>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={filters.installedOnly ? activeChipClass : chipClass}
|
||||
className={getPlatformCategoryChipClassName(filters.installedOnly)}
|
||||
aria-pressed={filters.installedOnly}
|
||||
onClick={() => setInstalledOnly(!filters.installedOnly)}
|
||||
>
|
||||
仅看已下载
|
||||
</button>
|
||||
{filtersActive ? (
|
||||
<button type="button" className={chipClass} onClick={clearFilters}>
|
||||
<button
|
||||
type="button"
|
||||
className={getPlatformCategoryChipClassName(false)}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{runtimeOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-(--platform-text-soft)">
|
||||
<SlidersHorizontal size={12} aria-hidden="true" />
|
||||
运行时
|
||||
</span>
|
||||
{runtimeOptions.map((runtime) => (
|
||||
<button
|
||||
type="button"
|
||||
key={runtime}
|
||||
className={
|
||||
filters.runtime === runtime ? activeChipClass : chipClass
|
||||
}
|
||||
aria-pressed={filters.runtime === runtime}
|
||||
aria-label={`运行时筛选 ${templateRuntimeLabel(runtime)}`}
|
||||
onClick={() => selectRuntime(runtime)}
|
||||
>
|
||||
{templateRuntimeLabel(runtime)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tagOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] text-(--platform-text-soft)">
|
||||
标签
|
||||
</span>
|
||||
{tagOptions.map((tag) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tag}
|
||||
className={
|
||||
filters.tags.includes(tag) ? activeChipClass : chipClass
|
||||
}
|
||||
aria-pressed={filters.tags.includes(tag)}
|
||||
aria-label={`标签筛选 ${tag}`}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 标签单独一行并换行排布:标签数量随库量增长时优先换行,超过上限再滚动,
|
||||
不会把卡片区挤出窗口,也不会让用户只能看到横向滚动条切掉的后半截标签。 */}
|
||||
{tagItems.length > 0 ? (
|
||||
<div
|
||||
className="flex max-h-[20vh] min-w-0 shrink-0 flex-wrap items-center gap-1.5 overflow-y-auto"
|
||||
role="group"
|
||||
aria-label="模板筛选标签"
|
||||
>
|
||||
{tagItems.map((option) => {
|
||||
const active = filters.tags.includes(option.tag);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={option.tag}
|
||||
className={getPlatformCategoryChipClassName(active)}
|
||||
aria-pressed={active}
|
||||
aria-label={`标签筛选 ${option.tag}`}
|
||||
onClick={() => toggleTag(option.tag)}
|
||||
>
|
||||
{option.tag}
|
||||
<span
|
||||
className="platform-category-chip__count"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{option.assetCount}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TemplateLibraryToast message={notice} onDismiss={clearNotice} />
|
||||
{error ? (
|
||||
<div
|
||||
@@ -371,7 +394,7 @@ export default function TemplateLibraryView({
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={chipClass}
|
||||
className={getPlatformCategoryChipClassName(false)}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
重试
|
||||
@@ -393,7 +416,11 @@ export default function TemplateLibraryView({
|
||||
<div className="flex items-center gap-2 text-[12px] text-(--platform-text-soft)">
|
||||
<SearchX size={14} aria-hidden="true" />
|
||||
没有符合当前筛选的模板
|
||||
<button type="button" className={chipClass} onClick={clearFilters}>
|
||||
<button
|
||||
type="button"
|
||||
className={getPlatformCategoryChipClassName(false)}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1716,8 +1716,7 @@ export function registerHomeProjectCreationTests() {
|
||||
'卡住的自动建项',
|
||||
);
|
||||
let resolveAutomaticProject:
|
||||
| ((result: Record<string, unknown>) => void)
|
||||
| null = null;
|
||||
((result: Record<string, unknown>) => void) | null = null;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'preflight_web_game_creation') return { status: 'ready' };
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
@@ -2915,7 +2914,8 @@ export function registerRecentProjectsTests() {
|
||||
expect(screen.getByText('不是文件夹')).not.toBeNull();
|
||||
expect(screen.getAllByText('未初始化').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('无法读取')).not.toBeNull();
|
||||
expect(screen.getByText('检查失败')).not.toBeNull();
|
||||
// 目录检查失败会先就地重试一次(失败不进终态),因此这里等它落成最终的失败状态。
|
||||
expect(await screen.findByText('检查失败')).not.toBeNull();
|
||||
expect(screen.getByText('厨房突围')).not.toBeNull();
|
||||
expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const fetchClientHttp = vi.fn();
|
||||
|
||||
@@ -16,8 +16,11 @@ vi.mock('../src/services/errorReporting', () => ({
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
generateGameDistributionCover,
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGamePublishAvailability,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
|
||||
const MANIFEST = {
|
||||
@@ -46,6 +49,10 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('发布时携带本地项目标识,让重复发布复用同一个平台游戏', async () => {
|
||||
fetchClientHttp
|
||||
.mockResolvedValueOnce(
|
||||
@@ -191,3 +198,138 @@ test('发布灰度读取失败时抛出,由调用方按不开放处理', async
|
||||
fetchClientHttp.mockRejectedValueOnce(new Error('network down'));
|
||||
await expect(readGamePublishAvailability()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('免费发布资料建议只上传脱敏上下文并返回白名单分类', async () => {
|
||||
fetchClientHttp.mockResolvedValueOnce(
|
||||
jsonResponse({ summary: '驾驶炮台守住轨道城', category: '策略' }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
suggestGameDistributionPublishMetadata({
|
||||
name: '星轨防线',
|
||||
goal: '守住轨道城',
|
||||
context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
summary: '驾驶炮台守住轨道城',
|
||||
category: '策略',
|
||||
});
|
||||
|
||||
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
|
||||
'/api/game-distribution/publish-metadata/suggestions',
|
||||
);
|
||||
const init = fetchClientHttp.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
name: '星轨防线',
|
||||
goal: '守住轨道城',
|
||||
context: '当前任务与状态:原型已完成;现有素材:image:assets/hero.png',
|
||||
});
|
||||
});
|
||||
|
||||
test('封面生成直接使用返回的平台素材 ID,不再次上传', async () => {
|
||||
fetchClientHttp.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
imageSrc: 'https://assets.example.com/generated-cover.png',
|
||||
assetObjectId: 'asset_generated_cover',
|
||||
taskId: 'task_cover_1',
|
||||
model: 'gpt-image-2',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateGameDistributionCover({
|
||||
prompt: '为《星轨防线》生成游戏封面',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
assetObjectId: 'asset_generated_cover',
|
||||
previewUrl: 'https://assets.example.com/generated-cover.png',
|
||||
taskId: 'task_cover_1',
|
||||
model: 'gpt-image-2',
|
||||
});
|
||||
|
||||
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
|
||||
'/api/editor/images/generations',
|
||||
);
|
||||
const body = JSON.parse(
|
||||
String((fetchClientHttp.mock.calls[0]?.[1] as RequestInit).body),
|
||||
);
|
||||
expect(body).toEqual({
|
||||
prompt: '为《星轨防线》生成游戏封面',
|
||||
kind: 'publication-material',
|
||||
assetKind: 'publication-material',
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
assetLabel: '游戏封面',
|
||||
});
|
||||
});
|
||||
|
||||
test('封面生成进入队列后轮询完成结果并返回同一平台素材', async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchClientHttp
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
queueState: {
|
||||
operationId: 'external-job-1',
|
||||
status: 'queued',
|
||||
phaseDetail: '排队中。',
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
job: {
|
||||
operationId: 'external-job-1',
|
||||
status: 'completed',
|
||||
result: {
|
||||
imageSrc: 'https://assets.example.com/queued-cover.png',
|
||||
assetObjectId: 'asset_queued_cover',
|
||||
taskId: 'task_queued_cover',
|
||||
model: 'gpt-image-2',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const generation = generateGameDistributionCover({
|
||||
prompt: '为《星轨防线》生成游戏封面',
|
||||
model: 'gpt-image-2',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_600);
|
||||
|
||||
await expect(generation).resolves.toEqual({
|
||||
assetObjectId: 'asset_queued_cover',
|
||||
previewUrl: 'https://assets.example.com/queued-cover.png',
|
||||
taskId: 'task_queued_cover',
|
||||
model: 'gpt-image-2',
|
||||
});
|
||||
expect(fetchClientHttp.mock.calls[1]?.[0]).toBe(
|
||||
'/api/runtime/external-generation/jobs/external-job-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('封面价格读取后端运行时定价,不在前端硬编码', async () => {
|
||||
fetchClientHttp.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
models: {
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '2K': 5 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
readGameCoverGenerationPrice({
|
||||
model: 'gpt-image-2',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
).resolves.toBe(5);
|
||||
expect(fetchClientHttp.mock.calls[0]?.[0]).toBe(
|
||||
'/api/editor/generation-pricing',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,21 +11,33 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { LocalProjectExportPackageResult } from '../src/app/types';
|
||||
import { GameDistributionPublishPanel } from '../src/components/game-distribution/GameDistributionPublishPanel';
|
||||
import { uploadPlatformMediaAsset } from '../src/services/assetDirectUpload';
|
||||
import { publishLocalProjectGame } from '../src/services/gameDistributionPublish';
|
||||
import {
|
||||
generateGameDistributionCover,
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
|
||||
vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import('../src/services/gameDistributionPublish')
|
||||
>();
|
||||
return { ...actual, publishLocalProjectGame: vi.fn() };
|
||||
return {
|
||||
...actual,
|
||||
generateGameDistributionCover: vi.fn(),
|
||||
publishLocalProjectGame: vi.fn(),
|
||||
readGameCoverGenerationPrice: vi.fn(),
|
||||
suggestGameDistributionPublishMetadata: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// 面板只负责选图与调用上传;这里替换掉真实直传,避免测试触达 Tauri/OSS。
|
||||
@@ -101,16 +113,28 @@ function renderPanel(
|
||||
return { onClose, onPublished };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(readGameCoverGenerationPrice).mockImplementation(
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
vi.mocked(suggestGameDistributionPublishMetadata).mockImplementation(
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(generateGameDistributionCover).mockReset();
|
||||
vi.mocked(publishLocalProjectGame).mockReset();
|
||||
vi.mocked(readGameCoverGenerationPrice).mockReset();
|
||||
vi.mocked(suggestGameDistributionPublishMetadata).mockReset();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockReset();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('GameDistributionPublishPanel', () => {
|
||||
test('打开时预填游戏资料并展示发行包摘要', () => {
|
||||
test('打开时预填游戏资料且不展示发行包技术摘要', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
@@ -122,12 +146,88 @@ describe('GameDistributionPublishPanel', () => {
|
||||
'value',
|
||||
'守住轨道城',
|
||||
);
|
||||
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
|
||||
'exports/playtest-package-unit.zip',
|
||||
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(/exports\/playtest-package-unit\.zip/u),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText(/只上传已导出的 ZIP/u)).toBeNull();
|
||||
await screen.findByText(/简介和分类/u);
|
||||
});
|
||||
|
||||
test('打开时根据创作上下文补全简介和分类', async () => {
|
||||
vi.mocked(suggestGameDistributionPublishMetadata).mockResolvedValueOnce({
|
||||
summary: '驾驶星轨炮台守住轨道城',
|
||||
category: '策略',
|
||||
});
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('一句话简介')).toHaveProperty(
|
||||
'value',
|
||||
'驾驶星轨炮台守住轨道城',
|
||||
);
|
||||
});
|
||||
expect(screen.getByLabelText('分类')).toHaveProperty('value', '策略');
|
||||
expect(
|
||||
screen.getByText(
|
||||
'AI 已根据创作内容生成简介和分类,免费;你可以直接修改。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('确认后基于项目上下文生成封面并作为发布素材', async () => {
|
||||
vi.mocked(readGameCoverGenerationPrice).mockResolvedValueOnce(5);
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(generateGameDistributionCover).mockResolvedValue({
|
||||
assetObjectId: 'asset_generated_cover',
|
||||
previewUrl: 'https://assets.example.com/generated-cover.png',
|
||||
taskId: 'task_cover_1',
|
||||
model: 'gpt-image-2',
|
||||
});
|
||||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||||
gameId: 'game_1',
|
||||
versionId: 'gamever_1',
|
||||
versionNumber: 1,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
fileCount: 2,
|
||||
});
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'AI 生成封面(5 泥点)' }),
|
||||
);
|
||||
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
|
||||
'2 个文件',
|
||||
const confirm = await screen.findByRole('dialog', {
|
||||
name: '确认生成游戏封面',
|
||||
});
|
||||
expect(confirm.textContent ?? '').toContain('本次生成预计消耗 5 泥点');
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成封面' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(generateGameDistributionCover).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gpt-image-2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'封面已生成并自动设为发布封面;重新生成会再次消耗泥点。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].metadata
|
||||
?.coverAssetId,
|
||||
).toBe('asset_generated_cover');
|
||||
});
|
||||
|
||||
test('提交时带上项目路径、发行包与资料,成功后展示审核状态', async () => {
|
||||
@@ -157,7 +257,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: { files: [buildImageFile('shot-1.png')] },
|
||||
});
|
||||
await screen.findByText('移除截图 1');
|
||||
await screen.findByRole('button', { name: '删除截图 1' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -271,6 +371,104 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('截图并行上传,单张失败只标记该张并在发布时跳过', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
let resolveShot1: (() => void) | undefined;
|
||||
let resolveShot3: (() => void) | undefined;
|
||||
let rejectShot2: (() => void) | undefined;
|
||||
vi.mocked(uploadPlatformMediaAsset).mockImplementation((input) => {
|
||||
const file = input.file;
|
||||
if (file.name === 'cover.png') {
|
||||
return Promise.resolve({
|
||||
assetObjectId: 'asset_cover',
|
||||
objectKey: 'game-distribution/cover/cover.png',
|
||||
});
|
||||
}
|
||||
if (file.name === 'shot-1.png') {
|
||||
return new Promise((resolve) => {
|
||||
resolveShot1 = () =>
|
||||
resolve({
|
||||
assetObjectId: 'asset_shot_1',
|
||||
objectKey: 'game-distribution/screenshot/shot-1.png',
|
||||
});
|
||||
});
|
||||
}
|
||||
if (file.name === 'shot-2.png') {
|
||||
return new Promise((_, reject) => {
|
||||
rejectShot2 = () => reject(new Error('截图 2 上传失败'));
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolveShot3 = () =>
|
||||
resolve({
|
||||
assetObjectId: 'asset_shot_3',
|
||||
objectKey: 'game-distribution/screenshot/shot-3.png',
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||||
gameId: 'game_1',
|
||||
versionId: 'gamever_1',
|
||||
versionNumber: 1,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
fileCount: 2,
|
||||
});
|
||||
renderPanel();
|
||||
await selectCover();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/游戏截图/u), {
|
||||
target: {
|
||||
files: [
|
||||
buildImageFile('shot-1.png'),
|
||||
buildImageFile('shot-2.png'),
|
||||
buildImageFile('shot-3.png'),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(uploadPlatformMediaAsset).toHaveBeenCalledTimes(4),
|
||||
);
|
||||
resolveShot3?.();
|
||||
rejectShot2?.();
|
||||
resolveShot1?.();
|
||||
|
||||
const failedError = await screen.findByText('截图 2 上传失败');
|
||||
expect(
|
||||
within(
|
||||
failedError.closest(
|
||||
'.game-distribution-publish-panel__shot-media',
|
||||
) as HTMLElement,
|
||||
).getByRole('button', { name: '删除截图 2' }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '删除截图 1' }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '删除截图 3' }),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览截图 1' }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '截图预览' }),
|
||||
).not.toBeNull();
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '截图预览' })).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].metadata
|
||||
?.screenshots,
|
||||
).toEqual(['asset_shot_1', 'asset_shot_3']);
|
||||
});
|
||||
|
||||
test('截图超过 6 张时本地拦截且不上传', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
@@ -316,8 +514,6 @@ describe('GameDistributionPublishPanel', () => {
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
expect(screen.getByLabelText('发行包摘要').textContent).toContain(
|
||||
'未找到试玩包',
|
||||
);
|
||||
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ function createDeferred<T>() {
|
||||
}
|
||||
|
||||
describe('客户端发布入口的可见反馈', () => {
|
||||
it('首个可运行原型未完成时直接阻止发布,不触发用户项目构建', async () => {
|
||||
it('首个可运行原型未完成时显示独立阻断弹窗,不触发用户项目构建', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
const manifest = createPendingPrototypeManifest();
|
||||
installTauri({ exportPackage, manifest });
|
||||
@@ -174,13 +174,22 @@ describe('客户端发布入口的可见反馈', () => {
|
||||
renderPublishProject(manifest);
|
||||
|
||||
const surface = await clickPublish();
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'首个可运行原型尚未完成,暂不能发布',
|
||||
);
|
||||
const noticeDialog = await screen.findByRole('dialog', {
|
||||
name: '发布提示',
|
||||
});
|
||||
expect(noticeDialog.textContent ?? '').toContain(
|
||||
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
|
||||
);
|
||||
expect(surface.textContent ?? '').not.toContain(
|
||||
'首个可运行原型尚未完成,暂不能发布',
|
||||
);
|
||||
expect(surface.textContent ?? '').not.toContain('正在检查发布权限…');
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(noticeDialog).getByRole('button', { name: '关闭' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '发布提示' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => {
|
||||
|
||||
@@ -91,3 +91,118 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () =>
|
||||
await pendingInspection;
|
||||
});
|
||||
});
|
||||
|
||||
test('单次目录检查失败会就地重试,不会把整行钉成「检查失败」', async () => {
|
||||
let attempts = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'inspect_local_project_directory') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error('Tauri IPC 瞬时失败');
|
||||
}
|
||||
return READY_PROJECT;
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(['/tmp/ready-project']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]).toMatchObject({
|
||||
status: '本地项目',
|
||||
canOpen: true,
|
||||
});
|
||||
});
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.current.projectRows[0]?.status).not.toBe('检查失败');
|
||||
});
|
||||
|
||||
test('失败结果不跨轮保留:刷新时该项回到「检查中」并重新检查', async () => {
|
||||
const inspectCounts: Record<string, number> = {};
|
||||
let secondRoundPending: (() => void) | null = null;
|
||||
const secondRoundInspection = new Promise<typeof READY_PROJECT>((resolve) => {
|
||||
secondRoundPending = () => resolve(READY_PROJECT);
|
||||
});
|
||||
let brokenRound = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'inspect_local_project_directory') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1;
|
||||
if (projectPath === '/tmp/broken-project') {
|
||||
brokenRound += 1;
|
||||
if (brokenRound > 2) {
|
||||
return secondRoundInspection;
|
||||
}
|
||||
throw new Error('Tauri IPC 持续失败');
|
||||
}
|
||||
return { ...READY_PROJECT, projectPath };
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(['/tmp/broken-project']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||||
});
|
||||
expect(inspectCounts['/tmp/broken-project']).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.rememberRecentWorkspace('/tmp/ready-project');
|
||||
});
|
||||
|
||||
// 上一轮的失败结果不进新投影:第二轮在途时该项目显示「检查中」而不是沿用「检查失败」。
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
result.current.projectRows.find(
|
||||
(row) => row.path === '/tmp/broken-project',
|
||||
)?.status,
|
||||
).toBe('检查中');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
secondRoundPending?.();
|
||||
await secondRoundInspection;
|
||||
});
|
||||
});
|
||||
|
||||
test('提权类失败不重试:不放大 UAC 弹窗', async () => {
|
||||
let attempts = 0;
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== 'inspect_local_project_directory') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
attempts += 1;
|
||||
throw new Error(
|
||||
'读取待修复私有对象失败:C:\\p\\.agent(DACL 不包含当前用户)',
|
||||
);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
window.localStorage.setItem(
|
||||
'genarrative-ai-game-creator.recent-workspaces.v1',
|
||||
JSON.stringify(['/tmp/elevation-project']),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useRecentProjects(vi.fn()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.projectRows[0]?.status).toBe('检查失败');
|
||||
});
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
@@ -4,10 +4,18 @@ import {
|
||||
buildTemplateRows,
|
||||
computeTemplateGridColumns,
|
||||
computeTemplateGridLayout,
|
||||
computeTemplateGridLayoutWithScrollbar,
|
||||
computeTemplateRowHeight,
|
||||
TEMPLATE_CARD_ACTIONS_HEIGHT,
|
||||
TEMPLATE_CARD_GAP,
|
||||
TEMPLATE_CARD_META_HEIGHT,
|
||||
TEMPLATE_CARD_MIN_WIDTH,
|
||||
TEMPLATE_CARD_SUMMARY_HEIGHT,
|
||||
TEMPLATE_CARD_TAGS_HEIGHT,
|
||||
TEMPLATE_CARD_TEXT_GAP,
|
||||
TEMPLATE_CARD_TEXT_HEIGHT,
|
||||
TEMPLATE_CARD_TEXT_PADDING,
|
||||
TEMPLATE_CARD_TITLE_HEIGHT,
|
||||
} from '../src/features/template-library/templateLibraryGrid';
|
||||
import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
|
||||
|
||||
@@ -50,6 +58,19 @@ describe('computeTemplateGridColumns', () => {
|
||||
});
|
||||
|
||||
describe('computeTemplateRowHeight', () => {
|
||||
it('budgets the same height the card rows actually need', () => {
|
||||
// 这些数字对应 TemplateCard 的 `h-5`/`h-4`/`h-8`/`h-5.5`/`h-7` 与 `p-3`/`gap-2`:
|
||||
// 行高契约比真实内容小,被截断的就是卡片里的标题与简介。
|
||||
expect(TEMPLATE_CARD_TITLE_HEIGHT).toBe(20);
|
||||
expect(TEMPLATE_CARD_META_HEIGHT).toBe(16);
|
||||
expect(TEMPLATE_CARD_SUMMARY_HEIGHT).toBe(32);
|
||||
expect(TEMPLATE_CARD_TAGS_HEIGHT).toBe(22);
|
||||
expect(TEMPLATE_CARD_ACTIONS_HEIGHT).toBe(28);
|
||||
expect(TEMPLATE_CARD_TEXT_PADDING).toBe(12);
|
||||
expect(TEMPLATE_CARD_TEXT_GAP).toBe(8);
|
||||
expect(TEMPLATE_CARD_TEXT_HEIGHT).toBe(174);
|
||||
});
|
||||
|
||||
it('keeps cover ratio + fixed text block', () => {
|
||||
// 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
|
||||
expect(computeTemplateRowHeight(300)).toBe(
|
||||
@@ -94,6 +115,59 @@ describe('computeTemplateGridLayout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTemplateGridLayoutWithScrollbar', () => {
|
||||
const innerWidth = (layout: { columnCount: number; columnWidth: number }) =>
|
||||
layout.columnCount * layout.columnWidth;
|
||||
|
||||
it('reserves the classic scrollbar width once the grid scrolls vertically', () => {
|
||||
// 经典滚动条(Windows/WebView2 ≈ 17px)不在包裹层宽度里,不预留就会多出一条横向滚动条。
|
||||
const plain = computeTemplateGridLayout({
|
||||
containerWidth: 1184,
|
||||
itemCount: 13,
|
||||
});
|
||||
const layout = computeTemplateGridLayoutWithScrollbar({
|
||||
containerWidth: 1184,
|
||||
itemCount: 13,
|
||||
viewportHeight: 500,
|
||||
scrollbarWidth: 17,
|
||||
});
|
||||
|
||||
expect(layout.columnCount).toBe(4);
|
||||
// 内层宽度必须落在竖滚动条左侧的可用宽度里,否则又会出现横向滚动条。
|
||||
expect(innerWidth(layout)).toBeLessThanOrEqual(1184 - 17);
|
||||
expect(innerWidth(layout)).toBeLessThan(innerWidth(plain));
|
||||
// 行高仍按预留后的列宽算,卡片内容不会被压。
|
||||
expect(layout.rowHeight).toBe(computeTemplateRowHeight(layout.columnWidth));
|
||||
});
|
||||
|
||||
it('keeps the plain layout when nothing overflows or the scrollbar is an overlay', () => {
|
||||
const plain = computeTemplateGridLayout({
|
||||
containerWidth: 1184,
|
||||
itemCount: 4,
|
||||
});
|
||||
// 只有一行:不会竖向滚动,不需要预留,右侧不留白边。
|
||||
expect(
|
||||
computeTemplateGridLayoutWithScrollbar({
|
||||
containerWidth: 1184,
|
||||
itemCount: 4,
|
||||
viewportHeight: 900,
|
||||
scrollbarWidth: 17,
|
||||
}),
|
||||
).toEqual(plain);
|
||||
// overlay 滚动条占宽 0:与不预留完全一致。
|
||||
expect(
|
||||
computeTemplateGridLayoutWithScrollbar({
|
||||
containerWidth: 1184,
|
||||
itemCount: 13,
|
||||
viewportHeight: 500,
|
||||
scrollbarWidth: 0,
|
||||
}),
|
||||
).toEqual(
|
||||
computeTemplateGridLayout({ containerWidth: 1184, itemCount: 13 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateRows', () => {
|
||||
it('chunks entries per row and pads the tail with nulls', () => {
|
||||
const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectGameTemplateRuntimes,
|
||||
collectGameTemplateTagOptions,
|
||||
collectGameTemplateTags,
|
||||
EMPTY_TEMPLATE_LIBRARY_FILTERS,
|
||||
filterGameTemplates,
|
||||
@@ -145,6 +146,24 @@ describe('tag and runtime options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the same order while reporting how many templates carry each tag', () => {
|
||||
// 筛选条上的标签 chip 要显示命中数量,顺序必须与 `collectGameTemplateTags` 完全一致。
|
||||
const withBlank = [
|
||||
...templates,
|
||||
template({ id: 'blank-tag', tags: ['', ' ', '经营'] }),
|
||||
];
|
||||
const options = collectGameTemplateTagOptions(withBlank);
|
||||
expect(options).toEqual([
|
||||
{ tag: '经营', assetCount: 3 },
|
||||
{ tag: '三消', assetCount: 1 },
|
||||
{ tag: '射击', assetCount: 1 },
|
||||
{ tag: '像素', assetCount: 1 },
|
||||
]);
|
||||
expect(options.map((option) => option.tag)).toEqual(
|
||||
collectGameTemplateTags(withBlank),
|
||||
);
|
||||
});
|
||||
|
||||
it('collects distinct runtimes and labels them', () => {
|
||||
expect(collectGameTemplateRuntimes(templates)).toEqual([
|
||||
'godot',
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
TemplateLibraryFilters,
|
||||
} from '../src/features/template-library/templateLibraryModel';
|
||||
import {
|
||||
collectGameTemplateTags,
|
||||
EMPTY_TEMPLATE_LIBRARY_FILTERS,
|
||||
filterGameTemplates,
|
||||
} from '../src/features/template-library/templateLibraryModel';
|
||||
@@ -81,7 +80,6 @@ function controller(
|
||||
notice: '',
|
||||
templates,
|
||||
visibleTemplates: templates,
|
||||
tagOptions: ['空白', '2d', 'canvas', '网页'],
|
||||
runtimeOptions: ['html'],
|
||||
installedCount: 1,
|
||||
filters,
|
||||
@@ -190,6 +188,28 @@ describe('TemplateLibraryView', () => {
|
||||
expect(viewport?.querySelector('article')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('pins every card text row to the height the grid contract budgets for it', () => {
|
||||
// 回归点:文字区若是 `grid` 的 auto 行,行高会按 max-content 算成「一行」,
|
||||
// 标题 / 简介 / 标签会被逐行截断(现场表现为标题文字被切掉)。这里钉住
|
||||
// 卡片每一行的固定高度,改动必须同时改 `TEMPLATE_CARD_*_HEIGHT` 那组常量。
|
||||
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
|
||||
|
||||
const card = cardFor('空白网页工程');
|
||||
const textBlock = card.children[1] as HTMLElement;
|
||||
const rows = Array.from(textBlock.children) as HTMLElement[];
|
||||
|
||||
expect(textBlock.className).toContain('flex-col');
|
||||
expect(rows.map((row) => row.className)).toEqual([
|
||||
expect.stringContaining('h-5'),
|
||||
expect.stringContaining('h-4'),
|
||||
expect.stringContaining('h-8'),
|
||||
expect.stringContaining('h-5.5'),
|
||||
expect.stringContaining('h-7'),
|
||||
]);
|
||||
// 每行都不参与压缩,否则 flex 会把文字压回去。
|
||||
rows.forEach((row) => expect(row.className).toContain('shrink-0'));
|
||||
});
|
||||
|
||||
it('offers 更新 instead of 下载 when the installed version is stale', () => {
|
||||
const stale = template({
|
||||
id: 'blank-web',
|
||||
@@ -253,6 +273,17 @@ describe('TemplateLibraryView', () => {
|
||||
expect(clearFilters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides a cover that failed to load instead of showing a broken image', () => {
|
||||
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
|
||||
|
||||
const cover = cardFor('空白网页工程').querySelector(
|
||||
'img',
|
||||
) as HTMLImageElement;
|
||||
expect(cover.style.visibility).toBe('');
|
||||
fireEvent.error(cover);
|
||||
expect(cover.style.visibility).toBe('hidden');
|
||||
});
|
||||
|
||||
it('starts a download and a template project from the card actions', () => {
|
||||
const downloadTemplate = vi.fn(async () => undefined);
|
||||
const createProjectFromTemplate = vi.fn(async () => undefined);
|
||||
@@ -289,11 +320,16 @@ describe('TemplateLibraryView', () => {
|
||||
|
||||
const busyCard = cardFor('空白二维画布工程');
|
||||
const buttons = Array.from(busyCard.querySelectorAll('button'));
|
||||
// 忙状态写在触发它的按钮上(文案就地变成「创建中」),动作行里不额外塞第三个元素,
|
||||
// 否则最小卡宽(250px)下两个按钮的文案会被挤成两行、顶出卡片。
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(busyCard.textContent).toContain('正在创建项目');
|
||||
expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目');
|
||||
expect(busyCard.textContent).toContain('创建中');
|
||||
expect(busyCard.textContent).not.toContain('使用模板');
|
||||
expect(cardFor('空白网页工程').textContent).toContain('使用模板');
|
||||
expect(cardFor('空白网页工程').textContent).not.toContain('创建中');
|
||||
});
|
||||
|
||||
it('shows empty, no-match, error and notice states', () => {
|
||||
@@ -390,7 +426,6 @@ describe('大库量渲染(1000 条假数据)', () => {
|
||||
templates: bulk,
|
||||
visibleTemplates: bulk,
|
||||
installedCount: bulk.filter((entry) => entry.installed).length,
|
||||
tagOptions: collectGameTemplateTags(bulk),
|
||||
})}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
|
||||
@@ -98,7 +98,128 @@ function contrastRatio(first: Rgba, second: Rgba) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 取渐变里的色标(`linear-gradient(135deg, #b3542f, #8f3f22)` → 两个颜色)。 */
|
||||
function parseGradientStops(source: string): Rgba[] {
|
||||
const body = source.slice(source.indexOf('(') + 1, source.lastIndexOf(')'));
|
||||
return body
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.startsWith('#') || part.startsWith('rgb'))
|
||||
.map((part) => parseCssColor(part.split(/\s+/)[0] ?? part));
|
||||
}
|
||||
|
||||
/** 页面背景(`--platform-body-fill`)里的不透明色标:chip 实际落在这层之上。 */
|
||||
function parseBodyFillUnderlays(source: string): Rgba[] {
|
||||
return Array.from(source.matchAll(/#[\da-f]{6}/gi)).map((match) =>
|
||||
parseCssColor(match[0]),
|
||||
);
|
||||
}
|
||||
|
||||
describe('workbench theme contrast', () => {
|
||||
/**
|
||||
* 筛选 chip 的两态对比:用户反馈「选中和没选中的颜色看不出差别」,根因是选中态
|
||||
* 只换了低透明度的暖色底(两态对比 1.09:1)。这里把「选中 = 实心填充」这条口径
|
||||
* 钉死:反白文字在渐变两端都要过 AA,且与未选底色至少差 3:1。
|
||||
*/
|
||||
it('keeps the chip selected state legible and distinct in both themes', () => {
|
||||
const css = readFileSync(themePath, 'utf8');
|
||||
const themes = [
|
||||
{
|
||||
name: 'light',
|
||||
block: getCssBlock(css, '.platform-theme--light'),
|
||||
// 浅色主题下 chip 落在页面底色上,用页面渐变的最亮与最暗色标夹住两种情况。
|
||||
idleUnderlays: parseBodyFillUnderlays(
|
||||
getCssVariable(
|
||||
getCssBlock(css, '.platform-theme--light'),
|
||||
'--platform-body-fill',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'dark',
|
||||
block: getCssBlock(css, '.platform-theme--dark'),
|
||||
idleUnderlays: parseBodyFillUnderlays(
|
||||
getCssVariable(
|
||||
getCssBlock(css, '.platform-theme--dark'),
|
||||
'--platform-body-fill',
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
parseGradientStops('linear-gradient(135deg, #b3542f, #8f3f22)'),
|
||||
).toEqual([
|
||||
[179, 84, 47, 1],
|
||||
[143, 63, 34, 1],
|
||||
]);
|
||||
|
||||
for (const theme of themes) {
|
||||
const activeFill = parseGradientStops(
|
||||
getCssVariable(theme.block, '--platform-chip-active-fill'),
|
||||
);
|
||||
const activeText = parseCssColor(
|
||||
getCssVariable(theme.block, '--platform-chip-active-text'),
|
||||
);
|
||||
const idleFill = parseCssColor(
|
||||
getCssVariable(theme.block, '--platform-chip-idle-fill'),
|
||||
);
|
||||
expect(activeFill, `${theme.name} active gradient stops`).toHaveLength(2);
|
||||
expect(
|
||||
theme.idleUnderlays.length,
|
||||
`${theme.name} body fill stops`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// 反白文字:渐变两端都要过 AA,不能只保证深的那一端。
|
||||
for (const stop of activeFill) {
|
||||
expect(
|
||||
contrastRatio(activeText, stop),
|
||||
`${theme.name} label on fill ${stop.slice(0, 3).join(',')}`,
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
|
||||
// 两态可分辨:选中填充与任意页面底色上的未选 chip 至少差 3:1。
|
||||
for (const underlay of theme.idleUnderlays) {
|
||||
const idleChip = compositeColor(idleFill, underlay);
|
||||
for (const stop of activeFill) {
|
||||
expect(
|
||||
contrastRatio(stop, idleChip),
|
||||
`${theme.name} selected vs idle over ${underlay.slice(0, 3).join(',')}`,
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 焦点环可见性:键盘用户靠它找焦点。旧口径是 15% 透明度的暖色,合成到页面底色只有
|
||||
* 1.17:1——等于没有焦点提示。这里按 WCAG 非文本对比 3:1 钉住两套皮肤。
|
||||
*/
|
||||
it('keeps the keyboard focus ring visible in both themes', () => {
|
||||
const css = readFileSync(themePath, 'utf8');
|
||||
for (const selector of [
|
||||
'.platform-theme--light',
|
||||
'.platform-theme--dark',
|
||||
]) {
|
||||
const block = getCssBlock(css, selector);
|
||||
const ring = parseCssColor(
|
||||
getCssVariable(block, '--platform-input-focus-ring'),
|
||||
);
|
||||
const underlays = parseBodyFillUnderlays(
|
||||
getCssVariable(block, '--platform-body-fill'),
|
||||
);
|
||||
expect(underlays.length, `${selector} body fill stops`).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
for (const underlay of underlays) {
|
||||
expect(
|
||||
contrastRatio(ring, underlay),
|
||||
`${selector} focus ring over ${underlay.slice(0, 3).join(',')}`,
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps warm user bubbles above WCAG AA text contrast', () => {
|
||||
const css = readFileSync(themePath, 'utf8');
|
||||
const light = getCssBlock(css, '.platform-theme--light');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user