Merge branch 'master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m42s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
Project CI / Backend tests (pull_request) Successful in 7m13s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 9m35s
Project CI / Repository checks (pull_request) Successful in 5m57s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m48s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m42s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m24s
Project CI / Backend tests (pull_request) Successful in 7m13s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 9m35s
Project CI / Repository checks (pull_request) Successful in 5m57s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m48s
This commit is contained in:
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
generateUpdateManifest,
|
||||
prepareReleaseVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
@@ -20,10 +21,13 @@ import {
|
||||
} from './verify-updater-signature.mjs';
|
||||
|
||||
/**
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
|
||||
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||
*
|
||||
* 边界:
|
||||
* - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
|
||||
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
|
||||
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
|
||||
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
||||
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
||||
* 未签名 + 未公证必须显式记录而非静默通过;
|
||||
@@ -94,11 +98,14 @@ process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
const dryRun = readReleaseDryRun();
|
||||
|
||||
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
|
||||
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。
|
||||
const firstInstallName = `${productName}_${version}_universal.dmg`;
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
|
||||
|
||||
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
||||
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
||||
@@ -117,7 +124,7 @@ for (const stale of [
|
||||
}
|
||||
|
||||
const args = [
|
||||
'--target=universal-apple-darwin',
|
||||
`--target=${macTarget}`,
|
||||
'--bundles',
|
||||
'app',
|
||||
'--ci',
|
||||
@@ -132,16 +139,13 @@ const command = (binary, argv, options = {}) =>
|
||||
runTauriBuild(args, context);
|
||||
|
||||
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
||||
for (const architecture of ['arm64', 'x86_64']) {
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
architecture,
|
||||
'--universal',
|
||||
]);
|
||||
}
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
'arm64',
|
||||
]);
|
||||
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
|
||||
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||
const dmg = path.join(dmgDirectory, firstInstallName);
|
||||
@@ -170,7 +174,7 @@ const release = await generateUpdateManifest(context);
|
||||
assert.equal(
|
||||
path.resolve(release.downloadArtifact),
|
||||
path.resolve(dmg),
|
||||
'首装包必须锁定本次生成的 universal DMG',
|
||||
'首装包必须锁定本次生成的 arm64 DMG',
|
||||
);
|
||||
|
||||
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
||||
@@ -266,8 +270,9 @@ fs.writeFileSync(
|
||||
firstInstallSha256: dmgHash,
|
||||
manifest: 'latest.json',
|
||||
},
|
||||
smokes: ['arm64', 'x86_64'],
|
||||
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
|
||||
// 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。
|
||||
smokes: ['arm64'],
|
||||
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -211,6 +211,9 @@ test('packaged renderer receives the same channel as the updater manifest', () =
|
||||
});
|
||||
let spawnOptions;
|
||||
runTauriBuild([], context, {
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
|
||||
@@ -178,8 +178,12 @@ test('macOS release entry and smoke script derive product names from config and
|
||||
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
|
||||
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
|
||||
assert.ok(
|
||||
entry.includes('_${version}_universal.dmg'),
|
||||
'首装包名必须保留清单侧唯一匹配所需的后缀',
|
||||
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
|
||||
'macOS 发布入口必须固定单架构目标',
|
||||
);
|
||||
assert.ok(
|
||||
entry.includes('_${version}_aarch64.dmg'),
|
||||
'首装包名必须保留清单侧单架构分支唯一匹配所需的后缀(Tauri 口径 aarch64)',
|
||||
);
|
||||
|
||||
const smoke = fs.readFileSync(
|
||||
|
||||
@@ -65,6 +65,17 @@ export function targetRuntime(target) {
|
||||
'aarch64-apple-darwin': ['darwin', 'arm64'],
|
||||
'x86_64-apple-darwin': ['darwin', 'x64'],
|
||||
};
|
||||
// 随包 Node 是**单架构**官方发行版:一份运行时只服务它自己的架构。
|
||||
// macOS 发布当前固定为 aarch64-apple-darwin 单架构包(Intel 未支持),
|
||||
// universal 目标没有正确的运行时来源,必须失败关闭——绝不能退化成
|
||||
// 「按宿主架构暂存一份 arm64」:那样通用包自检(按 process.arch)能过,
|
||||
// 但 Intel 机器上这份运行时不可执行,用户拿到的是坏包。
|
||||
if (target === 'universal-apple-darwin')
|
||||
throw new Error(
|
||||
'Node 运行时不支持 universal-apple-darwin:随包 Node 只有单架构发行版,' +
|
||||
'通用包需按架构各带一份(另行下载另一架构官方发行版)之后才能构建;' +
|
||||
'当前 macOS 发布固定为 aarch64-apple-darwin 单架构',
|
||||
);
|
||||
const value = targets[target];
|
||||
if (!value) throw new Error(`Node 运行时不支持发布目标:${target}`);
|
||||
return { platform: value[0], arch: value[1] };
|
||||
|
||||
@@ -281,7 +281,11 @@ test('native target and macOS dynamic dependency policy reject nonportable Node'
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
});
|
||||
assert.throws(() => targetRuntime('universal-apple-darwin'), /不支持/u);
|
||||
// universal 必须失败关闭:只带宿主架构那一份运行时,Intel 上不可执行。
|
||||
assert.throws(
|
||||
() => targetRuntime('universal-apple-darwin'),
|
||||
/universal-apple-darwin/u,
|
||||
);
|
||||
assertPortableMacNode(
|
||||
'/node:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1)\n',
|
||||
);
|
||||
|
||||
+27
-2
@@ -235,8 +235,14 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
/**
|
||||
* 参考选择只对有真实参考能力的入口呈现。
|
||||
*
|
||||
* 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器,
|
||||
* 原生侧同样拒绝额外参考(不是静默丢弃)。
|
||||
* `@` 引用输入区是**共享组件**(`ResourceReferenceInput`,聊天 / 快速编辑 / 这里同一份),
|
||||
* 这一处只是「要不要挂它」的唯一分支:判据在 `resourceCanvasAssetGenerationAcceptsReferences`,
|
||||
* 不在这里另写一套 kind 清单。
|
||||
*
|
||||
* 拒绝用户参考的只有**图集**(`icon-spritesheet`):按合同它只接受单张权威规范引用,
|
||||
* 原生侧对多余参考是**显式拒绝**(不是静默丢弃)。图标规范(`icon-spec`)虽然产出权威规范图,
|
||||
* 但生成时同样可以带参考图,上限与普通生成一致——它**有** `@` 选择器。
|
||||
* 两端必须同一个词:Rust 侧是 `platform_art_asset_kind_accepts_user_reference_assets`。
|
||||
*/
|
||||
const referenceEnabled =
|
||||
resourceCanvasAssetGenerationAcceptsReferences(action);
|
||||
@@ -494,6 +500,25 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
{resolveEditorImageSizeLabel({ aspectRatio, imageSize })}
|
||||
</span>
|
||||
)}
|
||||
{referenceEnabled ? null : (
|
||||
/*
|
||||
不给 `@` 的入口要**当场**说清为什么,否则用户只会读成「这里漏了一个按钮」
|
||||
(验收现场那条「生成图标素材没法 @」就是这么来的)。
|
||||
|
||||
原因在通道而不在 UI:图集走平台 `icon-spritesheets` 通道,提交体只有一张
|
||||
`referenceId`(独立权威规范图)+ `iconDescriptions` + 切片参数,客户端这一侧对
|
||||
「图集带用户参考」是**显式拒绝**(`normalize_platform_art_reference_asset_ids`:
|
||||
「透明美术图集只接受规范图引用,不接受用户参考素材」),所以放开 UI 也提交不出去。
|
||||
判据与文案都由同一处给出,别的 reference-free 入口将来接入时不必各写一份。
|
||||
*/
|
||||
<p
|
||||
className="resource-canvas-asset-generation-reference-free"
|
||||
data-resource-canvas-generation-reference-free={action.assetKind}
|
||||
>
|
||||
该入口走平台图集通道:参考只有一张权威规范图(图标规范),
|
||||
不支持另挑参考图——把需要哪些图标写进提示词。
|
||||
</p>
|
||||
)}
|
||||
{referenceProblemNotice ? (
|
||||
<p
|
||||
className="game-resource-generation-error"
|
||||
|
||||
+73
-46
@@ -3,6 +3,7 @@ import './resourceCanvasAssetGenerationTasksSidebar.css';
|
||||
import { ListChecks, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useLiveNow } from '../project-workspace/useLiveNow';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
|
||||
resourceCanvasAssetGenerationElapsedLabel,
|
||||
@@ -44,12 +45,18 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
|
||||
/**
|
||||
* 锚点落在哪个工作面:资源栏目画布 / 运行表现层 / UI 编辑器。
|
||||
* 锚点落在哪个工作面:资源总览页 / 资源栏目画布 / 运行表现层 / UI 编辑器。
|
||||
*
|
||||
* 三处右上角的既有 chrome 不同(运行表现层右上角是版本入口、UI 编辑器是整页编辑器壳),
|
||||
* 所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算。
|
||||
* 四处的既有 chrome 不同,所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算:
|
||||
* - `canvas-overview`(资源总览 / 画本):顶部没有钉死的栏目标题栏,锚点贴画布顶边内缩;
|
||||
* - `canvas`(资源栏目画布):顶部钉着**整宽**的栏目标题栏(含「返回资源总览」入口),
|
||||
* 锚点必须让开它,否则会压在那条标题栏上(验收现场那条「生成任务和标题栏重叠」);
|
||||
* - `run`:右上角被版本入口占着;
|
||||
* - `editor`:UI 编辑器是整页编辑器壳。
|
||||
*
|
||||
* 两页之间切换时锚点高度走样式里的 transition,不瞬移。
|
||||
*/
|
||||
placement?: 'canvas' | 'run' | 'editor';
|
||||
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,9 +105,57 @@ function sortResourceCanvasGenerationTaskRows(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条图片类生成任务的「已耗时」。
|
||||
*
|
||||
* 刷新粒度走对话侧同一份 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`):不足一分钟的耗时显示到
|
||||
* 0.1 秒,若按 1 秒一跳,用户看到的是一块「带小数却一格一格跳」的表,像卡住不动。
|
||||
*
|
||||
* 时钟只订在**这一行**上(叶子节点):面板上通常同时挂着多条任务,把 100ms 的 tick 放在整块
|
||||
* 面板上等于每 0.1 秒重建整个列表——`useLiveNow` 的注释写明「调用方必须是叶子节点」。任务收口
|
||||
* 后(终态)不再订阅,已耗时就定格在账本给的结束时间上。
|
||||
*/
|
||||
function AssetGenerationElapsedLabel({
|
||||
task,
|
||||
}: {
|
||||
task: ResourceCanvasAssetGenerationTask;
|
||||
}) {
|
||||
const now = useLiveNow(!resourceCanvasAssetGenerationTaskIsTerminal(task));
|
||||
return (
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, now),
|
||||
)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条派生/修改任务的「已耗时」。
|
||||
*
|
||||
* 同上:粒度与对话侧同一份 `useLiveNow`,只换资源编辑账本那一份文案入口。从原生账本恢复来的
|
||||
* 收口记录没有结束时间(`elapsedMillis === null`)时整格不渲染——宁可不显示,也不拿创建时间
|
||||
* 冒充耗时。
|
||||
*/
|
||||
function ResourceEditElapsedLabel({
|
||||
task,
|
||||
}: {
|
||||
task: ResourceCanvasResourceEditTask;
|
||||
}) {
|
||||
const now = useLiveNow(!resourceCanvasResourceEditTaskIsTerminal(task));
|
||||
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(task, now);
|
||||
if (elapsedMillis === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function assetGenerationTaskRow(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
nowMillis: number,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
const focusable = task.status === 'completed' && Boolean(task.assetId);
|
||||
@@ -128,11 +183,7 @@ function assetGenerationTaskRow(
|
||||
<span className="game-resource-generation-task-card-phase">
|
||||
{task.phaseDetail}
|
||||
</span>
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
|
||||
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
|
||||
)}`}
|
||||
</span>
|
||||
<AssetGenerationElapsedLabel task={task} />
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="game-resource-generation-task-card-error" role="alert">
|
||||
@@ -160,14 +211,7 @@ function assetGenerationTaskRow(
|
||||
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
|
||||
* 这行是哪一次修改。
|
||||
*/
|
||||
function resourceEditTaskRow(
|
||||
task: ResourceCanvasResourceEditTask,
|
||||
nowMillis: number,
|
||||
) {
|
||||
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
|
||||
task,
|
||||
nowMillis,
|
||||
);
|
||||
function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) {
|
||||
return (
|
||||
<li
|
||||
key={task.operationId}
|
||||
@@ -193,11 +237,7 @@ function resourceEditTaskRow(
|
||||
<span className="game-resource-generation-task-card-phase">
|
||||
{task.phaseDetail}
|
||||
</span>
|
||||
{elapsedMillis === null ? null : (
|
||||
<span className="game-resource-generation-task-card-elapsed">
|
||||
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
|
||||
</span>
|
||||
)}
|
||||
<ResourceEditElapsedLabel task={task} />
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="game-resource-generation-task-card-error" role="alert">
|
||||
@@ -218,12 +258,11 @@ function resourceEditTaskRow(
|
||||
|
||||
function resourceCanvasGenerationTaskRowNode(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
nowMillis: number,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
return row.source === 'asset-generation'
|
||||
? assetGenerationTaskRow(row.task, nowMillis, onFocusTask)
|
||||
: resourceEditTaskRow(row.task, nowMillis);
|
||||
? assetGenerationTaskRow(row.task, onFocusTask)
|
||||
: resourceEditTaskRow(row.task);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,7 +290,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
onFocusTask,
|
||||
placement = 'canvas',
|
||||
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
|
||||
const [nowMillis, setNowMillis] = useState(() => Date.now());
|
||||
/**
|
||||
* 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完
|
||||
* (`entering` 是刚打开时给根节点挂进场动画的那一档)。
|
||||
@@ -285,7 +323,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
);
|
||||
const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal);
|
||||
const inFlightCount = active.length;
|
||||
const hasLiveTask = inFlightCount > 0;
|
||||
const visibleDone = done.slice(
|
||||
0,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
@@ -314,14 +351,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
? { inFlightCount, ordered, active, visibleDone, done }
|
||||
: lastRenderedRef.current;
|
||||
|
||||
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
|
||||
useEffect(() => {
|
||||
if (!hasLiveTask) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLiveTask]);
|
||||
/*
|
||||
* 已耗时是前端计时(后端只给时间戳):秒表**不在这一层**——面板上通常同时挂着多条任务,
|
||||
* 在这里订阅 100ms 的 tick 等于每 0.1 秒重建整个侧栏。计时下沉到每一行的
|
||||
* `AssetGenerationElapsedLabel` / `ResourceEditElapsedLabel`,只在**还有未终态任务**时
|
||||
* 订阅(终态行定格在账本的结束时间上)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* open → 进场,close → 先留一帧播 `…-leave` 再卸载。
|
||||
@@ -430,11 +465,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
) : (
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{rendered.active.map((row) =>
|
||||
resourceCanvasGenerationTaskRowNode(
|
||||
row,
|
||||
nowMillis,
|
||||
onFocusTask,
|
||||
),
|
||||
resourceCanvasGenerationTaskRowNode(row, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
@@ -457,11 +488,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
<>
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{rendered.visibleDone.map((row) =>
|
||||
resourceCanvasGenerationTaskRowNode(
|
||||
row,
|
||||
nowMillis,
|
||||
onFocusTask,
|
||||
),
|
||||
resourceCanvasGenerationTaskRowNode(row, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
{rendered.done.length > rendered.visibleDone.length ? (
|
||||
|
||||
+24
-2
@@ -2,6 +2,7 @@ import './resourceCanvasGenerationPanel.css';
|
||||
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS,
|
||||
@@ -44,6 +45,14 @@ export function ResourceCanvasGenerationPlaceholderCardView({
|
||||
onTogglePanel,
|
||||
onRemove,
|
||||
}: ResourceCanvasGenerationPlaceholderCardViewProps) {
|
||||
/**
|
||||
* 这一次点击手势是从哪儿起手的。
|
||||
*
|
||||
* 删除按钮自己会 `stopPropagation`,所以「按在删除按钮上、松手落回卡片」这种手势里,
|
||||
* 浏览器把 `click` 派给两者的共同祖先(也就是这张卡)——删除按钮的删除没发生,卡片的
|
||||
* 「开合生成浮层」却执行了。判据不看移动距离:**从删除按钮起手的手势永远不是「点卡片」**。
|
||||
*/
|
||||
const gestureOriginRef = useRef<'card' | 'remove'>('card');
|
||||
return (
|
||||
<div
|
||||
className={`game-resource-generation-placeholder${active ? ' is-active' : ''}${dragging ? ' is-dragging' : ''}`}
|
||||
@@ -58,13 +67,21 @@ export function ResourceCanvasGenerationPlaceholderCardView({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${placeholder.assetName}(${RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[placeholder.status]})`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerDown={(event) => {
|
||||
gestureOriginRef.current = 'card';
|
||||
onPointerDown(event);
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onPointerCancel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const origin = gestureOriginRef.current;
|
||||
gestureOriginRef.current = 'card';
|
||||
if (origin === 'remove') {
|
||||
return;
|
||||
}
|
||||
onTogglePanel();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
@@ -87,7 +104,12 @@ export function ResourceCanvasGenerationPlaceholderCardView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除占位 ${placeholder.assetName}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => {
|
||||
// 卡片不接管这一次手势(不拖动),但把起手点记下来:松手落回卡片时那次 click
|
||||
// 不该被当成「点卡片开浮层」。
|
||||
gestureOriginRef.current = 'remove';
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
|
||||
+33
@@ -37,6 +37,34 @@
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
pointer-events: auto;
|
||||
/*
|
||||
* 资源总览 ↔ 资源栏目画布之间切换时,锚点要往下挪开那条钉死的标题栏:高度差走一段过渡,
|
||||
* 不瞬移(两页的坐标档见下面的 `[data-generation-tasks-placement]` 规则)。
|
||||
*
|
||||
* 只过渡 `margin-top`:锚点是 stage 网格里 `align-self: start` 的覆盖式条目,改它的外边距
|
||||
* 不会让工作面那一行 reflow,画布与资源卡排布一动不动。
|
||||
*/
|
||||
transition: margin-top 180ms ease-out;
|
||||
}
|
||||
|
||||
/*
|
||||
* 资源总览(画本)页:顶部没有钉死的标题栏——栏目名与计数都活在画本世界里跟着视口平移,
|
||||
* 所以锚点仍按「画布顶边内缩一小段」定位。这一档显式写出来,是为了让「两页高度不同」这件事
|
||||
* 在样式里看得见,而不是靠缺省值隐式成立。
|
||||
*/
|
||||
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas-overview'] {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* 资源栏目画布页:顶部钉着**整宽**的栏目标题栏(`.game-resource-book-scene-titlebar`,
|
||||
* 最小高 42px,`styles.css` 里定义),右端就是「返回资源总览」入口。锚点必须让开这一条,
|
||||
* 否则那枚开关会直接压在它上面(验收现场那条「生成任务和标题栏重叠」就是这么来的)。
|
||||
*
|
||||
* 2.625rem 就是那条标题栏的 42px:两处必须一起改(有声明级守卫用例钉住这个关系)。
|
||||
*/
|
||||
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas'] {
|
||||
margin-top: calc(2.625rem + 0.5rem);
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] {
|
||||
@@ -439,6 +467,11 @@
|
||||
|
||||
/* 降低动效偏好:进场 / 收起动画、悬停位移与呼吸全部关掉。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* 锚点高度过渡也关掉:两页切换时锚点直接落到目标高度。 */
|
||||
.game-resource-generation-tasks-anchor {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar,
|
||||
.game-resource-generation-tasks-sidebar.is-leaving {
|
||||
animation: none;
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { RESOURCE_CANVAS_DRAG_THRESHOLD } from '../../view/project-development/resourceCanvasLayoutModel';
|
||||
|
||||
/**
|
||||
* 画布上「这一次手势是拖动还是点击」的唯一判据。
|
||||
*
|
||||
* 画布上的卡片既是可点的控件、又是可拖的对象,而浏览器在 pointerdown 与 pointerup 落在同一个
|
||||
* 节点上时**一定会补一次 `click`**——中间移动了多少都不管。所以:
|
||||
*
|
||||
* 1. 「开始拖动」这件事只能由**移动距离**判,不能由「收到过 pointermove」判:按下时浏览器
|
||||
* 就可能补一次零位移的 move,用后者会让一次普通点击直接变成一次拖动;
|
||||
* 2. 拖动结束后紧跟的那次 `click` 是这次拖动的副产物,必须被**一次性吃掉**,否则拖完卡片会
|
||||
* 顺手触发卡片自己的点击语义(资源卡是选中、生成占位卡是开合生成浮层)。
|
||||
*
|
||||
* 资源卡(`view/project-development/index.tsx`)与生成占位卡
|
||||
* (`useResourceCanvasGenerationPlaceholders`)共用这里的阈值与判据:两处各写一份的结果就是
|
||||
* 一边修好、另一边继续漏(占位卡拖一下就弹生成浮层,就是这么来的)。
|
||||
*/
|
||||
export function resourceCanvasGestureExceededDragThreshold({
|
||||
startClientX,
|
||||
startClientY,
|
||||
clientX,
|
||||
clientY,
|
||||
threshold = RESOURCE_CANVAS_DRAG_THRESHOLD,
|
||||
}: {
|
||||
/** 按下时的屏幕坐标。 */
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
/** 当前屏幕坐标。 */
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
/** 阈值(屏幕 px);缺省用画布统一口径 `RESOURCE_CANVAS_DRAG_THRESHOLD`。 */
|
||||
threshold?: number;
|
||||
}): boolean {
|
||||
if (
|
||||
![startClientX, startClientY, clientX, clientY, threshold].every((value) =>
|
||||
Number.isFinite(value),
|
||||
)
|
||||
) {
|
||||
// 几何不成立时按「没有拖动」算:宁可把一次拖动当点击(用户再点一下即可),
|
||||
// 也不能把一次点击误判成拖动而吃掉它。
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Math.hypot(clientX - startClientX, clientY - startClientY) >= threshold
|
||||
);
|
||||
}
|
||||
+22
-1
@@ -78,8 +78,16 @@
|
||||
.game-approval-dialog.resource-canvas-generation-floating-panel {
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
/* 锚点给的是占位卡中心:不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。 */
|
||||
/*
|
||||
* 锚点给的是占位卡中心(宿主会把它收进「浮层左右两边各留 12px」的区间,卡片靠边时浮层仍
|
||||
* 完整落在画布内):不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。
|
||||
*/
|
||||
transform: translateX(-50%);
|
||||
/*
|
||||
* 宽度口径:`min(560px, 100% - 24px)`。这两个数在宿主侧算水平收口时要用同一份
|
||||
* (`resourceCanvasGenerationVisibilityModel` 的 `RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH`
|
||||
* 与 `..._VIEWPORT_INSET`),改一处必须改另一处,有声明级守卫用例钉住这个关系。
|
||||
*/
|
||||
width: min(560px, calc(100% - 24px));
|
||||
/* 外形走基类 token(= 网页端画布面板那一套),三块浮层因此长得一样。 */
|
||||
border: 1px solid var(--game-canvas-card-border, #e4c8ba);
|
||||
@@ -172,3 +180,16 @@
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 「这一档为什么不给 @ 选择器」的说明行:只出现在不走用户参考的入口(今天是图集)。
|
||||
*
|
||||
* 它替掉的选择器不是「省一个控件」,而是一条平台通道差异——不写出来,用户只会看到
|
||||
* 「提示词栏少了 @ 按钮」。允许折行(说明是句子,不是计数)。
|
||||
*/
|
||||
.resource-canvas-asset-generation-reference-free {
|
||||
margin: 0;
|
||||
color: #9a7d70;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
+103
-15
@@ -90,16 +90,86 @@ export function revealResourceCanvasGenerationContent({
|
||||
}
|
||||
|
||||
/**
|
||||
* 浮层该占多高、以及它挂在占位卡下面还是直接盖住占位卡。
|
||||
* 生成浮层的宽度口径:最大宽度,以及与画布可视边之间必须留出的内缩。
|
||||
*
|
||||
* 与 `resourceCanvasGenerationPanel.css` 里的 `width: min(560px, calc(100% - 24px))` 同口径
|
||||
* (24px = 两侧各 12px)。宿主按这对值算**水平收口**:浮层贴着占位卡,但恒定留在画布可视
|
||||
* 范围内,不被画布边界切掉。两处一起改(有声明级守卫用例钉住这个关系)。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH = 560;
|
||||
export const RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET = 12;
|
||||
|
||||
/** 浮层在这个画布宽下的实际宽度(CSS px)。画布未就绪时退回最大宽度。 */
|
||||
export function resourceCanvasGenerationPanelWidth(
|
||||
canvasWidth: number,
|
||||
): number {
|
||||
if (!Number.isFinite(canvasWidth) || canvasWidth <= 0) {
|
||||
return RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
|
||||
canvasWidth - RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浮层锚点(贴着占位卡下沿居中那条水平中线)的收口。
|
||||
*
|
||||
* 生成浮层比资源卡宽得多(560 vs 180),卡片靠近画布左右边缘时,居中展开的浮层会有一半落到
|
||||
* 画布之外被 `overflow: hidden` 切掉——用户看到的就是「生成窗口和画布边缘撞在一起」。这里把
|
||||
* 锚点收在「浮层左右两边各留 `inset`」的区间里:浮层仍然贴着卡片,但**恒定完整落在画布内**,
|
||||
* 平移、缩放、拖动卡片都不需要另做补偿。
|
||||
*
|
||||
* 画布比浮层还窄时不硬撑:居中交给 CSS 的宽度收口(`calc(100% - 24px)`)。
|
||||
*/
|
||||
export function clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX,
|
||||
canvasWidth,
|
||||
panelWidth,
|
||||
inset = RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
|
||||
}: {
|
||||
/** 未收口锚点:占位卡中心的屏幕 X(画布内坐标)。 */
|
||||
centerX: number;
|
||||
canvasWidth: number;
|
||||
panelWidth: number;
|
||||
inset?: number;
|
||||
}): number {
|
||||
if (
|
||||
![centerX, canvasWidth, panelWidth, inset].every((value) =>
|
||||
Number.isFinite(value),
|
||||
) ||
|
||||
canvasWidth <= 0 ||
|
||||
panelWidth <= 0
|
||||
) {
|
||||
return centerX;
|
||||
}
|
||||
const min = Math.max(0, inset) + panelWidth / 2;
|
||||
const max = canvasWidth - Math.max(0, inset) - panelWidth / 2;
|
||||
if (max <= min) {
|
||||
return canvasWidth / 2;
|
||||
}
|
||||
return Math.min(max, Math.max(min, centerX));
|
||||
}
|
||||
|
||||
/**
|
||||
* 浮层该占多高、顶边落在哪、以及它挂在占位卡下面还是直接盖住占位卡。
|
||||
*
|
||||
* 三版都踩过的坑,这里一次钉住:
|
||||
* 1. 用 `window.innerHeight` 当上界 → 面板垂到底栏下面,提交按钮点不到;
|
||||
* 2. 用「当前顶边到安全底边」当上界 → 打开瞬间只剩一百多像素,只见标题与提交行;
|
||||
* 3. 用固定最小高度硬顶 → 底边反过来被顶出画布(`overflow: hidden` 直接切掉)。
|
||||
*
|
||||
* 正确口径:只按**画布安全带**(画布高 − 顶栏 − 底栏)分配。装得下「占位卡 + 间隙 + 浮层」时
|
||||
* 浮层挂在卡下面;装不下时(例如缩放 1.5,卡就占 192px)浮层改为**与卡顶边对齐、盖住占位卡**,
|
||||
* 用整条安全带当编辑高度——占位允许被压到浮层后面,但全局缩放不变、提交按钮永远可见。
|
||||
* 正确口径:按**占位卡在画布上的真实屏幕位置**分配「卡下沿到安全底边」这一段。装得下可编辑
|
||||
* 高度时浮层挂在卡下面;装不下时(例如缩放 1.5,卡就占 192px;又或者占位被排到画布下半屏)
|
||||
* 浮层改为**与卡顶边对齐、盖住占位卡**,用「卡顶边到安全底边」当编辑高度——占位允许被压到
|
||||
* 浮层后面,但全局缩放不变、提交按钮永远可见。
|
||||
*
|
||||
* 第四版修的就是这里:早先只吃「安全带高度 − 卡高」,等于假设占位卡永远贴在安全带上沿。占位卡
|
||||
* 是追加在栏目内容最下方的,落到下半屏是常态,于是浮层底边越过画布下沿被切掉——验收现场那条
|
||||
* 「生成窗口和画布边缘碰撞」。现在顶边与可用高度都由真实位置算出来,浮层恒定完整落在画布内。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT = 280;
|
||||
export const RESOURCE_CANVAS_GENERATION_PANEL_FLOOR_HEIGHT = 160;
|
||||
@@ -108,6 +178,8 @@ export const RESOURCE_CANVAS_GENERATION_PANEL_MAX_HEIGHT = 520;
|
||||
export type ResourceCanvasGenerationPanelPlacement = {
|
||||
/** 浮层顶边是否与占位卡顶边对齐(盖住占位)而不是挂在卡下面。 */
|
||||
overlaysAnchor: boolean;
|
||||
/** 浮层顶边(CSS px,画布内坐标,已收进安全带)。 */
|
||||
top: number;
|
||||
/** 浮层可用的高度(CSS px)。 */
|
||||
availableHeight: number;
|
||||
};
|
||||
@@ -116,6 +188,7 @@ export function resolveResourceCanvasGenerationPanelPlacement({
|
||||
canvasHeight,
|
||||
topInset,
|
||||
bottomInset,
|
||||
anchorTop,
|
||||
anchorHeight,
|
||||
gap = 12,
|
||||
minHeight = RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT,
|
||||
@@ -126,6 +199,8 @@ export function resolveResourceCanvasGenerationPanelPlacement({
|
||||
canvasHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
/** 浮层锚点那一块(占位卡)的**屏幕**顶边(画布内坐标)。 */
|
||||
anchorTop: number;
|
||||
/** 浮层锚点那一块(占位卡)的**屏幕**高度(世界尺寸 × 当前缩放)。 */
|
||||
anchorHeight: number;
|
||||
gap?: number;
|
||||
@@ -133,28 +208,41 @@ export function resolveResourceCanvasGenerationPanelPlacement({
|
||||
floor?: number;
|
||||
maxHeight?: number;
|
||||
}): ResourceCanvasGenerationPanelPlacement {
|
||||
const safeTop = Math.max(0, topInset);
|
||||
const safeBottom = Math.max(
|
||||
safeTop + 1,
|
||||
canvasHeight - Math.max(0, bottomInset),
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(canvasHeight) ||
|
||||
!Number.isFinite(anchorTop) ||
|
||||
!Number.isFinite(anchorHeight) ||
|
||||
canvasHeight <= 0
|
||||
) {
|
||||
return { overlaysAnchor: false, availableHeight: minHeight };
|
||||
}
|
||||
const bandHeight = Math.max(
|
||||
0,
|
||||
canvasHeight - Math.max(0, topInset) - Math.max(0, bottomInset),
|
||||
);
|
||||
const below = bandHeight - Math.max(0, anchorHeight) - Math.max(0, gap);
|
||||
if (below >= minHeight) {
|
||||
return {
|
||||
overlaysAnchor: false,
|
||||
availableHeight: Math.min(maxHeight, Math.floor(below)),
|
||||
top: safeTop,
|
||||
availableHeight: minHeight,
|
||||
};
|
||||
}
|
||||
// 卡下面塞不下可编辑高度:改用「盖住占位卡」的整条安全带。
|
||||
const overlayBudget = Math.max(0, bandHeight - Math.max(0, gap));
|
||||
const belowTop = anchorTop + Math.max(0, anchorHeight) + Math.max(0, gap);
|
||||
const belowSpace = safeBottom - belowTop;
|
||||
if (belowSpace >= minHeight) {
|
||||
return {
|
||||
overlaysAnchor: false,
|
||||
top: belowTop,
|
||||
availableHeight: Math.min(maxHeight, Math.floor(belowSpace)),
|
||||
};
|
||||
}
|
||||
/*
|
||||
* 卡下面塞不下可编辑高度:改用「盖住占位卡」——顶边取卡顶边,但不能越出安全带
|
||||
* (卡自己贴着下沿时,至少给出一屏可编辑高度,而不是把浮层顶到画布外面去)。
|
||||
*/
|
||||
const overlayTop = Math.max(safeTop, Math.min(anchorTop, safeBottom - floor));
|
||||
const overlayBudget = Math.max(0, safeBottom - overlayTop);
|
||||
return {
|
||||
overlaysAnchor: true,
|
||||
top: overlayTop,
|
||||
availableHeight:
|
||||
overlayBudget >= floor
|
||||
? Math.min(maxHeight, Math.floor(overlayBudget))
|
||||
|
||||
+99
-4
@@ -2,6 +2,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resourceCanvasGestureExceededDragThreshold } from './resourceCanvasCardGestureModel';
|
||||
import {
|
||||
bindResourceCanvasGenerationPlaceholderTask,
|
||||
failResourceCanvasGenerationPlaceholder,
|
||||
@@ -33,6 +34,7 @@ type PlaceholderDragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
scale: number;
|
||||
/** 这次手势**已经越过拖动阈值**(不是「收到过 pointermove」)。 */
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
@@ -70,17 +72,34 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
const placeholdersRef = useRef<ResourceCanvasGenerationPlaceholder[]>([]);
|
||||
placeholdersRef.current = placeholders;
|
||||
const dragRef = useRef<PlaceholderDragState | null>(null);
|
||||
/**
|
||||
* 拖动收尾的那次 `click` 一次性抑制:拖动(down/up 落在同一张卡上)必然引出一次 click,
|
||||
* 不挡掉它,拖完占位就会顺手开合一次生成浮层。与资源卡那条链路同一口径
|
||||
* (`skipNextResourceCardClickRef` + `deferSkippedResourceCardClickCleanup`)。
|
||||
*/
|
||||
const suppressedClickDraftIdRef = useRef<string | null>(null);
|
||||
const suppressedClickTimerRef = useRef<number | null>(null);
|
||||
const projectIdRef = useRef(projectId);
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
/** 取消抑制(这一次手势不会有收尾 click,或者手势被取消时用)。 */
|
||||
const clearDragClickSuppression = useCallback(() => {
|
||||
if (suppressedClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressedClickTimerRef.current);
|
||||
suppressedClickTimerRef.current = null;
|
||||
}
|
||||
suppressedClickDraftIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
// 切项目:未提交草稿与界面位置一并作废,拖动中也要收掉,避免把上一份会话的占位写进新项目。
|
||||
useEffect(() => {
|
||||
dragRef.current = null;
|
||||
setDraggingDraftId(null);
|
||||
clearDragClickSuppression();
|
||||
setPlaceholders((current) =>
|
||||
resourceCanvasGenerationPlaceholdersForProject(current, projectId),
|
||||
);
|
||||
}, [projectId]);
|
||||
}, [clearDragClickSuppression, projectId]);
|
||||
|
||||
const createPlaceholder = useCallback(
|
||||
(input: ResourceCanvasGenerationPlaceholderDraftInput) => {
|
||||
@@ -166,6 +185,47 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
return drag;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 松手之后把抑制再留**一个宏任务**,然后按 draftId 收干净。
|
||||
*
|
||||
* 计时起点是这条链路的关键:抑制必须在**手势越过阈值那一刻**就登记(那时还不知道会不会
|
||||
* 出现收尾 click),但只能在**松手那一刻**才开始计时——`click` 是浏览器紧接着 `pointerup`
|
||||
* 补发的(同一轮任务内),而一次真实拖动里 pointermove 会横跨很多轮宏任务。若在越过阈值时
|
||||
* 就起 0ms 计时器,计时器早在松手之前就把抑制清掉了,收尾 click 照样生效;jsdom 里同步发
|
||||
* move / up 看不见这一档,真实鼠标一定看得见(验收现场「拖一下就触发点击」仍在)。
|
||||
*
|
||||
* 与资源卡那条链路同口径(`deferSkippedResourceCardClickCleanup`)。
|
||||
*/
|
||||
const deferDragClickSuppressionCleanup = useCallback((draftId: string) => {
|
||||
if (suppressedClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressedClickTimerRef.current);
|
||||
}
|
||||
suppressedClickTimerRef.current = window.setTimeout(() => {
|
||||
if (suppressedClickDraftIdRef.current === draftId) {
|
||||
suppressedClickDraftIdRef.current = null;
|
||||
}
|
||||
suppressedClickTimerRef.current = null;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 消费一次「这次 click 是拖动收尾的副产物」:宿主在开合浮层前问一次,返回 true 就跳过。
|
||||
*
|
||||
* 判定要写在手势那一侧(这里),不能写进卡片组件:拖动状态、阈值与坐标都在这一层,
|
||||
* 卡片只该拿到「点我一下」这个语义。
|
||||
*/
|
||||
const consumeDragClick = useCallback((draftId: string) => {
|
||||
if (suppressedClickDraftIdRef.current !== draftId) {
|
||||
return false;
|
||||
}
|
||||
suppressedClickDraftIdRef.current = null;
|
||||
if (suppressedClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressedClickTimerRef.current);
|
||||
suppressedClickTimerRef.current = null;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 占位卡自己的指针拖动。
|
||||
*
|
||||
@@ -200,7 +260,7 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
scale: Number.isFinite(scale) && scale > 0 ? scale : 1,
|
||||
changed: false,
|
||||
};
|
||||
setDraggingDraftId(placeholder.draftId);
|
||||
// 「拖动中」不在这里置位:按下还不一定是拖动(见 onPointerMove 的阈值判据)。
|
||||
},
|
||||
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
@@ -208,11 +268,31 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
if (!drag.changed) {
|
||||
/*
|
||||
* 只有真的移过阈值才算拖动:按下时浏览器就可能补一次零位移的 pointermove,把「收到过
|
||||
* move」当拖动信号,会让一次普通点击直接吞掉自己的 click(点占位卡打不开浮层)。
|
||||
*/
|
||||
if (
|
||||
!resourceCanvasGestureExceededDragThreshold({
|
||||
startClientX: drag.startClientX,
|
||||
startClientY: drag.startClientY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
drag.changed = true;
|
||||
// 只登记,不起计时器:计时起点必须是松手那一刻(见 deferDragClickSuppressionCleanup)。
|
||||
suppressedClickDraftIdRef.current = drag.draftId;
|
||||
// 拖动样式也从越过阈值这一刻起才亮:点一下不该闪一次「拖动中」。
|
||||
setDraggingDraftId(drag.draftId);
|
||||
}
|
||||
const nextX =
|
||||
drag.startX + (event.clientX - drag.startClientX) / drag.scale;
|
||||
const nextY =
|
||||
drag.startY + (event.clientY - drag.startClientY) / drag.scale;
|
||||
drag.changed = true;
|
||||
move(drag.draftId, nextX, nextY);
|
||||
},
|
||||
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -221,6 +301,12 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
// 收尾那次 click 就在这一轮任务里跟着来:抑制从现在起再活一个宏任务。
|
||||
if (drag.changed) {
|
||||
deferDragClickSuppressionCleanup(drag.draftId);
|
||||
} else {
|
||||
clearDragClickSuppression();
|
||||
}
|
||||
},
|
||||
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = clearDrag(event.pointerId);
|
||||
@@ -229,9 +315,17 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
}
|
||||
// 取消手势(指针捕获丢失 / 窗口失焦):回到拖动前的位置,不留下半截坐标。
|
||||
move(drag.draftId, drag.startX, drag.startY);
|
||||
// 取消不会有收尾 click:抑制立刻收干净,别留到下一次真点击上。
|
||||
clearDragClickSuppression();
|
||||
},
|
||||
}),
|
||||
[clearDrag, move, viewportScale],
|
||||
[
|
||||
clearDrag,
|
||||
clearDragClickSuppression,
|
||||
deferDragClickSuppressionCleanup,
|
||||
move,
|
||||
viewportScale,
|
||||
],
|
||||
);
|
||||
|
||||
const projectPlaceholders = useMemo(
|
||||
@@ -250,6 +344,7 @@ export function useResourceCanvasGenerationPlaceholders({
|
||||
remove,
|
||||
move,
|
||||
dragHandlersFor,
|
||||
consumeDragClick,
|
||||
placeholderByTaskId: (taskId: string) =>
|
||||
resourceCanvasGenerationPlaceholderByTaskId(
|
||||
placeholdersRef.current,
|
||||
|
||||
@@ -168,6 +168,7 @@ import {
|
||||
type ResourceCanvasBottomToolAction,
|
||||
} from '../../features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
import { ResourceCanvasBottomToolbarView } from '../../features/resource-canvas/ResourceCanvasBottomToolbarView';
|
||||
import { resourceCanvasGestureExceededDragThreshold } from '../../features/resource-canvas/resourceCanvasCardGestureModel';
|
||||
import {
|
||||
isResourceCanvasInteractionTarget,
|
||||
isResourceCanvasPanTarget,
|
||||
@@ -188,7 +189,9 @@ import {
|
||||
type ResourceCanvasGenerationPlaceholder,
|
||||
} from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel';
|
||||
import {
|
||||
clampResourceCanvasGenerationPanelAnchorX,
|
||||
resolveResourceCanvasGenerationPanelPlacement,
|
||||
resourceCanvasGenerationPanelWidth,
|
||||
revealResourceCanvasGenerationContent,
|
||||
} from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel';
|
||||
import {
|
||||
@@ -326,7 +329,6 @@ import {
|
||||
normalizeInfiniteResourceCanvasViewport,
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_DRAG_THRESHOLD,
|
||||
RESOURCE_CANVAS_FIT_PADDING,
|
||||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||||
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
|
||||
@@ -717,8 +719,6 @@ const RESOURCE_GENERATION_SAFE_INSET_FALLBACK = {
|
||||
};
|
||||
/** 面板高度量不到时的兜底(与浮层的 `max-height` 同量级),宁多留不贴栏。 */
|
||||
const RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK = 320;
|
||||
/** 面板宽度量不到时的兜底:与浮层 CSS 的 `min(560px, 100% - 24px)` 同口径。 */
|
||||
const RESOURCE_CANVAS_GENERATION_PANEL_WIDTH_FALLBACK = 560;
|
||||
/** 占位卡与它下沿浮层之间的间隙:与浮层锚点用同一个数。 */
|
||||
const RESOURCE_CANVAS_GENERATION_PANEL_GAP = 12;
|
||||
function resourceGenerationOverlaySafeInsets(element: HTMLElement | null) {
|
||||
@@ -5979,7 +5979,14 @@ export default function ProjectDevelopmentView({
|
||||
const deltaY = event.clientY - drag.startClientY;
|
||||
if (
|
||||
!drag.changed &&
|
||||
Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD
|
||||
// 「拖动还是点击」与生成占位卡共用同一条判据(`resourceCanvasCardGestureModel`):
|
||||
// 卡类手势的阈值只该有一份,两边各写一套的结果是占位卡那条链路漏掉抑制。
|
||||
!resourceCanvasGestureExceededDragThreshold({
|
||||
startClientX: drag.startClientX,
|
||||
startClientY: drag.startClientY,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -9194,44 +9201,61 @@ export default function ProjectDevelopmentView({
|
||||
})
|
||||
: null;
|
||||
/**
|
||||
* 浮层高度上界:按**画布安全带**算(画布可视高 − 顶栏 − 底栏 − 占位卡 − 间隙)。
|
||||
* 画布几何只量一次,浮层的竖向与横向收口共用同一份数:
|
||||
* 这里每读一次都会触发一次布局(`getBoundingClientRect` / `clientHeight`),
|
||||
* 分开量既多花时间,也可能读到两个不同帧的值。
|
||||
*/
|
||||
const resourceGenerationPanelCanvasSize = resourceCanvasElementSize(
|
||||
resourceCanvasRef.current,
|
||||
);
|
||||
const resourceGenerationPanelInsets = resourceGenerationOverlaySafeInsets(
|
||||
resourceCanvasRef.current,
|
||||
);
|
||||
/**
|
||||
* 浮层顶边与高度:按**占位卡在画布上的真实屏幕位置**算(卡下沿 → 安全底边那一段)。
|
||||
*
|
||||
* 三条实测教训:用 `window.innerHeight` 会让面板垂到底栏下面;用「当前顶边到安全底边」会在
|
||||
* 打开瞬间只给一百多像素(只见标题与提交行);用固定的最小高度兜底又会反过来把底边顶出去。
|
||||
* 所以上界只看安全带:平移由 reveal 负责,浮层自己必须有完整可编辑高度。
|
||||
* 第四条:只吃「安全带高度 − 卡高」等于假设占位卡永远贴在安全带上沿——占位是追加在栏目内容
|
||||
* 最下方的,落到下半屏是常态,于是浮层底边越过画布下沿被 `overflow: hidden` 切掉(验收现场
|
||||
* 那条「生成窗口和画布边缘碰撞」)。所以这里把卡的**屏幕顶边**一起给进去,顶边与可用高度都由
|
||||
* 真实位置算出来;平移仍由 reveal 负责,浮层自己必须有完整可编辑高度。
|
||||
*/
|
||||
const resourceGenerationPanelPlacement = resourceGenerationPanelStyle
|
||||
? resolveResourceCanvasGenerationPanelPlacement({
|
||||
canvasHeight: resourceCanvasElementSize(resourceCanvasRef.current)
|
||||
.height,
|
||||
topInset: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current)
|
||||
.top,
|
||||
bottomInset: resourceGenerationOverlaySafeInsets(
|
||||
resourceCanvasRef.current,
|
||||
).bottom,
|
||||
canvasHeight: resourceGenerationPanelCanvasSize.height,
|
||||
topInset: resourceGenerationPanelInsets.top,
|
||||
bottomInset: resourceGenerationPanelInsets.bottom,
|
||||
// 占位卡的坐标是**世界坐标**,安全带是 CSS px:按当前缩放与视口位移换算到画布内坐标。
|
||||
anchorTop:
|
||||
resourceCanvasSceneViewportRef.current.y +
|
||||
(resourceGenerationPanelPlaceholder?.y ?? 0) *
|
||||
(resourceCanvasSceneViewportRef.current.scale || 1),
|
||||
// 占位卡高度是**世界坐标**,安全带是 CSS px:按当前缩放换算成屏幕高度再扣。
|
||||
anchorHeight:
|
||||
(resourceGenerationPanelPlaceholder?.height ?? 0) *
|
||||
(resourceCanvasSceneViewportRef.current.scale || 1),
|
||||
// 卡与浮层之间的间隙:与 reveal 算「整块要带进可视区」时用同一个数。
|
||||
gap: RESOURCE_CANVAS_GENERATION_PANEL_GAP,
|
||||
})
|
||||
: null;
|
||||
/**
|
||||
* 浮层位置与高度:位置口径仍与快速编辑 / 信息浮层一致(贴着占位下沿居中,CSS 负责
|
||||
* `translateX(-50%)`);只有「卡下面塞不下可编辑高度」时才改为与卡顶边对齐、盖住占位。
|
||||
* 浮层位置与高度:竖向由上面那档给(贴着占位下沿居中,塞不下就改为盖住占位);
|
||||
* 横向把锚点收进画布可视范围——浮层比资源卡宽得多,不收口时卡片靠边就会有一半落到画布之外
|
||||
* 被切掉(CSS 负责 `translateX(-50%)`,所以这里收的是那条中线)。
|
||||
*/
|
||||
const resourceGenerationPanelFloatingStyle =
|
||||
resourceGenerationPanelStyle && resourceGenerationPanelPlacement
|
||||
? {
|
||||
...resourceGenerationPanelStyle,
|
||||
...(resourceGenerationPanelPlacement.overlaysAnchor &&
|
||||
resourceGenerationPanelPlaceholder
|
||||
? {
|
||||
top:
|
||||
resourceCanvasSceneViewportRef.current.y +
|
||||
resourceGenerationPanelPlaceholder.y *
|
||||
(resourceCanvasSceneViewportRef.current.scale || 1),
|
||||
}
|
||||
: {}),
|
||||
left: clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: resourceGenerationPanelStyle.left,
|
||||
canvasWidth: resourceGenerationPanelCanvasSize.width,
|
||||
panelWidth: resourceCanvasGenerationPanelWidth(
|
||||
resourceGenerationPanelCanvasSize.width,
|
||||
),
|
||||
}),
|
||||
top: resourceGenerationPanelPlacement.top,
|
||||
maxHeight: `${resourceGenerationPanelPlacement.availableHeight}px`,
|
||||
}
|
||||
: null;
|
||||
@@ -9276,7 +9300,8 @@ export default function ProjectDevelopmentView({
|
||||
const panelWidth = Math.max(
|
||||
0,
|
||||
panelElement?.getBoundingClientRect().width ??
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_WIDTH_FALLBACK,
|
||||
// 量不到时按同一套宽度口径估:`min(560, 画布宽 - 24)`。
|
||||
resourceCanvasGenerationPanelWidth(canvasSize.width),
|
||||
);
|
||||
const viewport = normalizeResourceBookViewport(
|
||||
category === RESOURCE_BOOK_ALL_TARGET
|
||||
@@ -9794,9 +9819,16 @@ export default function ProjectDevelopmentView({
|
||||
placeholder,
|
||||
)}
|
||||
onTogglePanel={() =>
|
||||
toggleResourceGenerationPlaceholderPanel(
|
||||
placeholder,
|
||||
// 拖动收尾那一次 click 是拖动的副产物(down/up 落在同一张卡上
|
||||
// 浏览器必补一次 click):吃掉它,否则拖一下占位就会顺手把生成
|
||||
// 浮层弹开。与资源卡拖拽后的抑制同一口径。
|
||||
resourceGenerationPlaceholders.consumeDragClick(
|
||||
placeholder.draftId,
|
||||
)
|
||||
? undefined
|
||||
: toggleResourceGenerationPlaceholderPanel(
|
||||
placeholder,
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
removeResourceGenerationPlaceholderCard(
|
||||
@@ -10875,8 +10907,19 @@ export default function ProjectDevelopmentView({
|
||||
setResourceAssetGenerationTasksPanelOpen((current) => !current)
|
||||
}
|
||||
onFocusTask={focusResourceAssetGenerationTask}
|
||||
/*
|
||||
资源总览页与资源栏目画布页的顶部 chrome 不同:画布页钉着整宽的栏目标题栏
|
||||
(含「返回资源总览」),总览页没有。两页因此各有一档坐标(见样式里的
|
||||
`[data-generation-tasks-placement]`),切换时走锚点高度过渡。
|
||||
*/
|
||||
placement={
|
||||
uiEditorRoute ? 'editor' : mode === 'run' ? 'run' : 'canvas'
|
||||
uiEditorRoute
|
||||
? 'editor'
|
||||
: mode === 'run'
|
||||
? 'run'
|
||||
: resourceBookView === 'child'
|
||||
? 'canvas'
|
||||
: 'canvas-overview'
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
resourceCanvasAssetGenerationReferenceIssue,
|
||||
resourceCanvasAssetGenerationUserReferenceLimit,
|
||||
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
|
||||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
import {
|
||||
resolveResourceCanvasBottomTools,
|
||||
type ResourceCanvasAssetToolAction,
|
||||
resourceCanvasBottomToolActions,
|
||||
} from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -265,6 +269,126 @@ describe('生成面板的参考接线', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 图标规范(`icon-spec`)是验收现场被问到的那一条:「生成图标规范时没法 @」。
|
||||
*
|
||||
* 面板里那段注释曾写成「图集与图标规范这两类入口不给选择器」,与判据不一致,容易被读成
|
||||
* 「图标规范被有意排除」。这里把它钉成可执行的事实:图标规范与普通生成一致地呈现 `@`
|
||||
* 与参考计数,并且提交时真的把引用带出去。
|
||||
*/
|
||||
test('图标规范入口呈现参考计数与引用输入区,提交带回引用', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit =
|
||||
vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>();
|
||||
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
|
||||
const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')];
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={iconSpecAction}
|
||||
assets={[imageAsset]}
|
||||
projectPath="/tmp/project"
|
||||
draft={{
|
||||
prompt: '按这套界面风格出图标规范 @素材-a',
|
||||
assetName: '图标规范',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
references,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '图标规范' });
|
||||
expect(panel.textContent).toContain('参考图 1/5');
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '插入素材引用' }),
|
||||
).not.toBeNull();
|
||||
|
||||
await user.click(within(panel).getByRole('button', { name: '图标规范' }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
kind: 'icon-spec',
|
||||
references: [expect.objectContaining({ resourceId: 'asset-a' })],
|
||||
});
|
||||
});
|
||||
|
||||
test('图标规范的 @ 真的能开出素材选择器(不是只画了一枚按钮)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={iconSpecAction}
|
||||
assets={[imageAsset]}
|
||||
projectPath="/tmp/project"
|
||||
onSubmit={vi.fn()}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '选择素材' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* 反向守卫:`@` 的有无只由**一条**判据决定,不是每条入口各自决定。
|
||||
*
|
||||
* 遍历底部工具栏里真实存在的生成入口逐条渲染面板,断言「面板里有没有 @」与
|
||||
* `resourceCanvasAssetGenerationAcceptsReferences` 完全一致;并钉住唯一的例外是图集。
|
||||
* 这条用例的价值在增量:以后新增一条入口忘了接 `@`,或者有人把某条入口按类型硬编码成
|
||||
* 没有选择器,这里立刻会红。
|
||||
*/
|
||||
test('底部工具栏每条生成入口的 @ 与判据完全一致,唯一例外是图集', () => {
|
||||
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
|
||||
const actions = (
|
||||
['ui-interaction', 'character', 'scene', 'audio'] as const
|
||||
).flatMap((category) =>
|
||||
resolveResourceCanvasBottomTools(category).flatMap((tool) =>
|
||||
resourceCanvasBottomToolActions(tool),
|
||||
),
|
||||
);
|
||||
const generationActions = actions.filter(
|
||||
(candidate) => candidate.route === 'asset',
|
||||
);
|
||||
expect(generationActions.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
generationActions
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!resourceCanvasAssetGenerationAcceptsReferences(candidate),
|
||||
)
|
||||
.map((candidate) => candidate.id),
|
||||
).toEqual(['generate-icon-spritesheet']);
|
||||
// 图标规范就在这一批入口里:这条断言同时挡住「入口被改名 / 被摘掉」的静默漂移。
|
||||
expect(generationActions.map((candidate) => candidate.id)).toContain(
|
||||
'generate-spec-icon',
|
||||
);
|
||||
|
||||
for (const candidate of generationActions) {
|
||||
const view = render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={candidate}
|
||||
assets={[imageAsset]}
|
||||
projectPath="/tmp/project"
|
||||
onSubmit={vi.fn()}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '插入素材引用' }) !== null,
|
||||
`${candidate.id}(${candidate.label})`,
|
||||
).toBe(resourceCanvasAssetGenerationAcceptsReferences(candidate));
|
||||
// 说明行与选择器互补:有 @ 就不该出现「为什么不给 @」,反之必须在场。
|
||||
expect(
|
||||
screen.queryByText(/不支持另挑参考图/u) !== null,
|
||||
`${candidate.id}(${candidate.label})的参考口径说明`,
|
||||
).toBe(!resourceCanvasAssetGenerationAcceptsReferences(candidate));
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
test('图集入口不呈现参考选择,只提交纯提示词', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit =
|
||||
@@ -286,10 +410,28 @@ describe('生成面板的参考接线', () => {
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(panel.textContent).not.toContain('参考图');
|
||||
/*
|
||||
* 「不呈现参考选择」的判据落在**计数与选择器**上,不落在「正文里出现过『参考图』三个字」上:
|
||||
* 这一档现在会有一段说明行解释为什么不给 `@`(「…不支持另挑参考图…」),按词匹配会把
|
||||
* 说明行也一起判失败。
|
||||
*/
|
||||
expect(
|
||||
panel.querySelector('[data-resource-canvas-generation-reference-count]'),
|
||||
).toBeNull();
|
||||
expect(panel.textContent).not.toMatch(/参考图\s*\d+\s*\/\s*\d+/u);
|
||||
const prompt = screen.getByLabelText('生成提示词');
|
||||
expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA');
|
||||
|
||||
/*
|
||||
* 不给 `@` 的入口必须当场说清为什么:验收现场那条「生成图标素材没法 @」读起来像漏了一个
|
||||
* 按钮,真实原因却在那条平台通道上(只有一张权威规范图 `referenceId` + `iconDescriptions`,
|
||||
* 客户端对图集的用户参考是显式拒绝)。说明行带 `data-*` 标记,便于逐条入口对照判据。
|
||||
*/
|
||||
expect(
|
||||
panel.querySelector('[data-resource-canvas-generation-reference-free]')
|
||||
?.textContent,
|
||||
).toContain('权威规范图');
|
||||
|
||||
await user.click(
|
||||
within(panel).getByRole('button', { name: '生成图标素材' }),
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ function renderSidebar(
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[],
|
||||
options: {
|
||||
open?: boolean;
|
||||
placement?: 'canvas' | 'run' | 'editor';
|
||||
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
|
||||
} = {},
|
||||
) {
|
||||
const onToggleOpen = vi.fn();
|
||||
@@ -133,6 +133,100 @@ describe('「生成任务」侧栏', () => {
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 读秒粒度:与对话侧同一份 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`)。
|
||||
*
|
||||
* 不足一分钟的耗时显示到 0.1 秒,刷新就必须是 100ms——按 1 秒一跳时,用户看到的是一块
|
||||
* 「带小数却一格一格跳」的表,像卡住不动(验收现场「生成任务里的读秒更新频率应为 0.1s」)。
|
||||
*/
|
||||
test('读秒按 100ms 刷新:不足一分钟的耗时以 0.1 秒递增', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// 假时钟与任务的创建时间对齐(夹具的 `nowMillis` 是 1000)。
|
||||
vi.setSystemTime(1_000);
|
||||
renderSidebar([
|
||||
task({
|
||||
taskId: 't1',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
}),
|
||||
]);
|
||||
const elapsed = () => screen.getByText(/^已耗时 /u).textContent;
|
||||
expect(elapsed()).toBe('已耗时 0.0秒');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(elapsed()).toBe('已耗时 0.1秒');
|
||||
|
||||
// 1 秒一跳的实现会停在上面的 0.1 秒:再走 400ms 必须继续往上加。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
expect(elapsed()).toBe('已耗时 0.5秒');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('任务收口后不再走表:已耗时定格在账本给的结束时间上', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(1_000);
|
||||
renderSidebar([
|
||||
task({
|
||||
taskId: 't1',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-1',
|
||||
finishedAtMillis: 2_500,
|
||||
}),
|
||||
]);
|
||||
const elapsed = () => screen.getByText(/^已耗时 /u).textContent;
|
||||
expect(elapsed()).toBe('已耗时 1.5秒');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
});
|
||||
expect(elapsed()).toBe('已耗时 1.5秒');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 秒表只订在**运行中的那一行**上:终态行不订阅,面板本身也不订阅。
|
||||
*
|
||||
* 这条是性能约束的可执行版本——100ms 的 tick 若落在整块面板或终态行上,每 0.1 秒都会重建
|
||||
* 整个侧栏(`useLiveNow` 的注释写明「调用方必须是叶子节点」)。
|
||||
*/
|
||||
test('秒表只订在运行中的那一行上,终态行与面板本身都不订阅时钟', () => {
|
||||
const spy = vi.spyOn(globalThis, 'setInterval');
|
||||
try {
|
||||
renderSidebar([
|
||||
task({
|
||||
taskId: 'live',
|
||||
dispatched: true,
|
||||
status: 'running',
|
||||
phaseDetail: '正在生成。',
|
||||
}),
|
||||
task({
|
||||
taskId: 'done',
|
||||
dispatched: true,
|
||||
status: 'completed',
|
||||
phaseDetail: '生成已完成。',
|
||||
assetId: 'asset-done',
|
||||
finishedAtMillis: 2_000,
|
||||
}),
|
||||
]);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('收起只有一个入口:头部那枚关闭按钮,收起即整块让出画布', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]);
|
||||
@@ -283,6 +377,18 @@ describe('「生成任务」侧栏', () => {
|
||||
/>,
|
||||
);
|
||||
expect(placementOf()).toBe('canvas');
|
||||
|
||||
// 资源总览(画本)页单独一档:那一页顶部没有钉死的栏目标题栏。
|
||||
rerender(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
placement="canvas-overview"
|
||||
/>,
|
||||
);
|
||||
expect(placementOf()).toBe('canvas-overview');
|
||||
});
|
||||
|
||||
test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => {
|
||||
|
||||
+82
@@ -22,6 +22,38 @@ const SIDEBAR_CSS_PATH = repoPath(
|
||||
);
|
||||
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));
|
||||
|
||||
/**
|
||||
* 锚点在「资源栏目画布」那一档要让开的、钉死的栏目标题栏(`.game-resource-book-scene-titlebar`)。
|
||||
* 它的高度定义在全局样式表里,而让开多少写在侧栏样式里——两个文件,必须一起改,
|
||||
* 所以这里读真实声明把关系钉住。
|
||||
*/
|
||||
const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
|
||||
const globalRules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8'));
|
||||
// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。
|
||||
const globalWidthRules = globalRules.filter(
|
||||
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
|
||||
);
|
||||
|
||||
/** 声明里允许的形状就这两种:单个长度,或「几个长度相加」的 `calc`(本文件不解析别的函数)。 */
|
||||
function cssLengthToPx(value: string): number {
|
||||
const raw = value.trim();
|
||||
const calc = /^calc\((.+)\)$/u.exec(raw);
|
||||
if (calc) {
|
||||
return calc[1]
|
||||
.split('+')
|
||||
.map((term) => cssLengthToPx(term))
|
||||
.reduce((total, term) => total + term, 0);
|
||||
}
|
||||
const match = raw.match(/^(-?\d+(?:\.\d+)?)(px|rem)$/u);
|
||||
if (!match) {
|
||||
throw new Error(`不是可换算的长度:${value}`);
|
||||
}
|
||||
const [, amount, unit] = match;
|
||||
// 根字号取浏览器 / WebView 的默认 16px(全局样式表没有改写 `html` 的字号),
|
||||
// 这个换算只用在那条「让开多少 ≥ 标题栏多高」的守卫上。
|
||||
return Number(amount) * (unit === 'rem' ? 16 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `styleCascade` 的求值器只认识宽度媒体查询,遇到 `prefers-reduced-motion` 会主动报错;
|
||||
* 那条规则的生效与否单独在下面按声明断言,所以求值时先把它排除掉。
|
||||
@@ -229,6 +261,40 @@ describe('「生成任务」侧栏样式', () => {
|
||||
expect(declaration(editorAnchor, 'grid-row')).toBe('2');
|
||||
expect(declaration(editorAnchor, 'margin')).toBe('0.85rem');
|
||||
|
||||
/*
|
||||
* 资源总览页与资源栏目画布页的**锚点高度不同**:画布页顶部钉着整宽的栏目标题栏
|
||||
* (右端就是「返回资源总览」入口),锚点必须让开它,否则那枚开关会压在标题栏上
|
||||
* (验收现场那条「生成任务和标题栏重叠」)。让开的高度与标题栏真实高度是跨文件关系,
|
||||
* 所以这里读全局样式表里的 min-height 断言「让开得比它高」。
|
||||
*/
|
||||
const titlebarMinHeight = cssLengthToPx(
|
||||
declaration(
|
||||
resolveDeclarations(globalWidthRules, [
|
||||
'.game-resource-book-scene-titlebar',
|
||||
]),
|
||||
'min-height',
|
||||
),
|
||||
);
|
||||
const canvasAnchor = resolved([
|
||||
'.game-resource-generation-tasks-anchor',
|
||||
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas']",
|
||||
]);
|
||||
const canvasAnchorTop = cssLengthToPx(
|
||||
declaration(canvasAnchor, 'margin-top'),
|
||||
);
|
||||
expect(canvasAnchorTop).toBeGreaterThan(titlebarMinHeight);
|
||||
|
||||
const overviewAnchor = resolved([
|
||||
'.game-resource-generation-tasks-anchor',
|
||||
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas-overview']",
|
||||
]);
|
||||
const overviewAnchorTop = cssLengthToPx(
|
||||
declaration(overviewAnchor, 'margin-top'),
|
||||
);
|
||||
// 总览页没有那条标题栏:仍贴画布顶边内缩,而且确实比画布页高一档。
|
||||
expect(overviewAnchorTop).toBe(cssLengthToPx('0.85rem'));
|
||||
expect(overviewAnchorTop).toBeLessThan(canvasAnchorTop);
|
||||
|
||||
// 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影;
|
||||
// 取值仍然全部走平台 token,不引入第二套色板。
|
||||
const toggle = resolved(['.game-resource-generation-tasks-toggle']);
|
||||
@@ -286,6 +352,22 @@ describe('「生成任务」侧栏样式', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('资源总览 ↔ 资源画布切换时锚点高度有过渡,减动效下关掉', () => {
|
||||
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
|
||||
expect(declaration(anchor, 'transition')).toContain('margin-top');
|
||||
|
||||
const reduced = rules.filter((rule) =>
|
||||
(rule.media ?? '').includes('prefers-reduced-motion'),
|
||||
);
|
||||
expect(
|
||||
reduced.some(
|
||||
(rule) =>
|
||||
rule.selectors.includes('.game-resource-generation-tasks-anchor') &&
|
||||
rule.declarations.get('transition') === 'none',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('焦点环可见(键盘可见焦点用平台 token)', () => {
|
||||
const focusRule = rules.find((rule) =>
|
||||
rule.selectors.includes(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { resourceCanvasGestureExceededDragThreshold } from '../src/features/resource-canvas/resourceCanvasCardGestureModel';
|
||||
import { RESOURCE_CANVAS_DRAG_THRESHOLD } from '../src/view/project-development/resourceCanvasLayoutModel';
|
||||
|
||||
describe('画布卡片手势:拖动 / 点击的唯一判据', () => {
|
||||
const gesture = (deltaX: number, deltaY: number) =>
|
||||
resourceCanvasGestureExceededDragThreshold({
|
||||
startClientX: 100,
|
||||
startClientY: 100,
|
||||
clientX: 100 + deltaX,
|
||||
clientY: 100 + deltaY,
|
||||
});
|
||||
|
||||
test('正好到阈值算拖动,差一点点仍算点击(与资源卡同一个阈值)', () => {
|
||||
expect(RESOURCE_CANVAS_DRAG_THRESHOLD).toBe(5);
|
||||
expect(gesture(RESOURCE_CANVAS_DRAG_THRESHOLD, 0)).toBe(true);
|
||||
expect(gesture(RESOURCE_CANVAS_DRAG_THRESHOLD - 0.1, 0)).toBe(false);
|
||||
expect(gesture(0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
test('位移按欧氏距离算:斜向 3/4 就是 5', () => {
|
||||
expect(gesture(3, 4)).toBe(true);
|
||||
expect(gesture(2, 3)).toBe(false);
|
||||
});
|
||||
|
||||
test('阈值可覆盖(各档手势需要更紧或更松时只改参数,不改判据)', () => {
|
||||
expect(
|
||||
resourceCanvasGestureExceededDragThreshold({
|
||||
startClientX: 0,
|
||||
startClientY: 0,
|
||||
clientX: 1,
|
||||
clientY: 0,
|
||||
threshold: 1,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('几何非法时按「没有拖动」算:宁可把拖动当点击,也不能把点击误判成拖动', () => {
|
||||
expect(
|
||||
resourceCanvasGestureExceededDragThreshold({
|
||||
startClientX: Number.NaN,
|
||||
startClientY: 100,
|
||||
clientX: 200,
|
||||
clientY: 100,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
|
||||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
|
||||
import {
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
|
||||
} from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
|
||||
import { repoPath } from './repoPath';
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -142,4 +146,16 @@ describe('生成浮层样式结构', () => {
|
||||
/\.game-resource-generation-actions \{[\s\S]*?bottom: 0;[\s\S]*?background:/u,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 宿主算「水平收口」用的是模型里那对常量(浮层最大宽 + 左右内缩),CSS 这里是同一对数的另一份
|
||||
* 写法:两边必须一致,否则收口算的是 560 宽、画出来的却是另一个宽度,卡片靠边时又会露出去。
|
||||
*/
|
||||
test('宽度口径与宿主算水平收口的常量同源', () => {
|
||||
expect(panelCss()).toContain(
|
||||
`width: min(${RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH}px, calc(100% - ${
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2
|
||||
}px));`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1012,3 +1012,67 @@ describe('生成浮层按占位隔离(宿主回归)', () => {
|
||||
expect(tauri.assetGenerationStarts[1]!.prompt).toBe(originalPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 验收现场那条「拖动未生成的资源卡片会触发点击事件」(至少背景音乐与音效会)。
|
||||
*
|
||||
* 浏览器在 pointerdown 与 pointerup 落在同一个节点上时**一定会补一次 `click`**——拖完卡片
|
||||
* 松手,紧跟着的那次 click 会把卡片自己的点击语义再执行一遍。占位卡的点击语义就是开合生成浮层,
|
||||
* 所以表现为「拖一下就把浮层弹开 / 收起」。这里从真实宿主上钉住两件事:拖动收尾的 click 被吃掉,
|
||||
* 而一次真正的点击仍然照常开合(抑制只活过一次手势)。
|
||||
*/
|
||||
describe('拖动生成占位卡不触发卡片的点击(宿主回归)', () => {
|
||||
test('拖动收尾的那次 click 被吃掉,普通点击仍然照常开合浮层', async () => {
|
||||
const tauri = installHostTauri({
|
||||
assets: [seedBgmAsset('seed-bgm')],
|
||||
});
|
||||
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
|
||||
await settle();
|
||||
|
||||
const { draftId } = await openBgmEntry();
|
||||
// 点入口时浮层是开着的(占位与浮层由同一个动作产生)。
|
||||
expect(floatingPanel()).not.toBeNull();
|
||||
|
||||
const placeholder = placeholderElement(draftId)!;
|
||||
fireEvent.pointerDown(placeholder, {
|
||||
pointerId: 7,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
});
|
||||
fireEvent.pointerMove(placeholder, {
|
||||
pointerId: 7,
|
||||
clientX: 260,
|
||||
clientY: 230,
|
||||
});
|
||||
/*
|
||||
* 真实鼠标的拖动会横跨很多轮宏任务(pointermove 一次次派发):这里让时钟真的走一轮再松手。
|
||||
* 抑制的计时起点必须在松手那一刻;若实现改回「越过阈值时就起 0ms 计时器」,这一轮之后抑制
|
||||
* 就已经被清掉,紧随其后的那次 click 会把浮层关掉,下面的断言立刻红。
|
||||
*/
|
||||
await settle();
|
||||
fireEvent.pointerUp(placeholder, {
|
||||
pointerId: 7,
|
||||
clientX: 260,
|
||||
clientY: 230,
|
||||
});
|
||||
/*
|
||||
* 真实浏览器在 pointerup 之后会自动释放指针捕获并补一个 `lostpointercapture`(卡片把它接到
|
||||
* 取消手势上)。它必须**不能**把这次拖动的点击抑制一起清掉——否则补上来的 click 又会漏过去。
|
||||
*/
|
||||
fireEvent.lostPointerCapture(placeholder, { pointerId: 7 });
|
||||
// 浏览器补的那一次 click:抑制没生效时这里会立刻把浮层关掉。
|
||||
fireEvent.click(placeholder);
|
||||
await settle();
|
||||
expect(floatingPanel()).not.toBeNull();
|
||||
|
||||
// 对照:没移动的点击是「点击」,仍然开合浮层(抑制不是把点击永久关掉)。
|
||||
fireEvent.click(placeholder);
|
||||
await settle();
|
||||
expect(floatingPanel()).toBeNull();
|
||||
// 拖动本身照常生效(拖动只改坐标,不取消这次生成)。
|
||||
expect(placeholderElement(draftId)).not.toBeNull();
|
||||
expect(tauri.assetGenerationStarts).toEqual([]);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -190,6 +190,58 @@ describe('占位卡组件', () => {
|
||||
// 删除按钮不能把点击透到卡片上(否则删完立刻又开一次浮层)。
|
||||
expect(onTogglePanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* 同一类缺陷的相邻一档:删除按钮自己 `stopPropagation`,「按在删除按钮上、松手落回卡片」
|
||||
* 那次 click 会被派给共同祖先(卡片)。判据按**起手点**判,不看移动距离——从删除按钮起手的
|
||||
* 手势永远不是「点卡片」。
|
||||
*/
|
||||
test('从删除按钮起手、松手落回卡片时不当作点卡片', () => {
|
||||
const onTogglePanel = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
render(
|
||||
<ResourceCanvasGenerationPlaceholderCardView
|
||||
placeholder={placeholderFixture()}
|
||||
active={false}
|
||||
dragging={false}
|
||||
onPointerDown={() => undefined}
|
||||
onPointerMove={() => undefined}
|
||||
onPointerUp={() => undefined}
|
||||
onPointerCancel={() => undefined}
|
||||
onTogglePanel={onTogglePanel}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByRole('button', {
|
||||
name: 'AI 生成图片(待提交)',
|
||||
});
|
||||
const remove = screen.getByRole('button', {
|
||||
name: '删除占位 AI 生成图片',
|
||||
});
|
||||
fireEvent.pointerDown(remove, {
|
||||
pointerId: 3,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
// 松手落回卡片:浏览器把 click 派给共同祖先(卡片)。
|
||||
fireEvent.click(card);
|
||||
expect(onTogglePanel).not.toHaveBeenCalled();
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
|
||||
// 之后正常点卡片仍然开合浮层(起手标记不残留到下一手势)。
|
||||
fireEvent.pointerDown(card, {
|
||||
pointerId: 4,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
});
|
||||
fireEvent.click(card);
|
||||
expect(onTogglePanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('占位宿主 Hook', () => {
|
||||
@@ -262,7 +314,16 @@ describe('占位宿主 Hook', () => {
|
||||
expect(hook.result.current.placeholders).toEqual([]);
|
||||
});
|
||||
|
||||
test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => {
|
||||
/**
|
||||
* 把 hook 的返回值也带出来:阈值、拖动中标记与点击抑制都在 hook 这一层,
|
||||
* 只渲染卡片看不到它们。
|
||||
*/
|
||||
function renderDragHarness() {
|
||||
const hookRef: {
|
||||
current: ReturnType<
|
||||
typeof useResourceCanvasGenerationPlaceholders
|
||||
> | null;
|
||||
} = { current: null };
|
||||
function Harness() {
|
||||
const [created, setCreated] = useState(false);
|
||||
const placeholders = useResourceCanvasGenerationPlaceholders({
|
||||
@@ -270,6 +331,7 @@ describe('占位宿主 Hook', () => {
|
||||
resolvePlacement: () => ({ x: 10, y: 20 }),
|
||||
viewportScale: () => 2,
|
||||
});
|
||||
hookRef.current = placeholders;
|
||||
useEffect(() => {
|
||||
if (created) {
|
||||
return;
|
||||
@@ -300,6 +362,17 @@ describe('占位宿主 Hook', () => {
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
const hook = () => {
|
||||
if (!hookRef.current) {
|
||||
throw new Error('hook 还没挂上');
|
||||
}
|
||||
return hookRef.current;
|
||||
};
|
||||
return { hook };
|
||||
}
|
||||
|
||||
test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => {
|
||||
renderDragHarness();
|
||||
const card = screen.getByRole('button', {
|
||||
name: 'AI 生成图片(待提交)',
|
||||
});
|
||||
@@ -341,4 +414,73 @@ describe('占位宿主 Hook', () => {
|
||||
expect(card.style.left).toBe('30px');
|
||||
expect(card.style.top).toBe('35px');
|
||||
});
|
||||
|
||||
/**
|
||||
* 验收现场那条「拖动未生成的资源卡片会触发点击事件」在单元层的钉子:
|
||||
* 拖动与点击的判据是**移动距离**(与资源卡共用 `RESOURCE_CANVAS_DRAG_THRESHOLD`),
|
||||
* 而且拖动收尾那一次 click 要被吃掉一次——只有这一次。
|
||||
*/
|
||||
test('位移没越过阈值不算拖动:卡片不动、也不吃掉后面的点击', () => {
|
||||
const { hook } = renderDragHarness();
|
||||
const card = screen.getByRole('button', {
|
||||
name: 'AI 生成图片(待提交)',
|
||||
});
|
||||
const draftId = hook().placeholders[0]!.draftId;
|
||||
|
||||
fireEvent.pointerDown(card, {
|
||||
pointerId: 11,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
// 按下还不算拖动:拖动样式不该在点一下的时候闪。
|
||||
expect(hook().draggingDraftId).toBeNull();
|
||||
// 抖动 2px(阈值 5px)不构成拖动。
|
||||
fireEvent.pointerMove(card, { pointerId: 11, clientX: 102, clientY: 101 });
|
||||
expect(card.style.left).toBe('10px');
|
||||
expect(card.style.top).toBe('20px');
|
||||
expect(hook().draggingDraftId).toBeNull();
|
||||
|
||||
fireEvent.pointerUp(card, { pointerId: 11, clientX: 102, clientY: 101 });
|
||||
// 这一次是点击而不是拖动:后面的 click 必须照常生效。
|
||||
expect(hook().consumeDragClick(draftId)).toBe(false);
|
||||
});
|
||||
|
||||
test('越过阈值才算拖动:抑制活到松手那一刻,下一次点击照常生效', async () => {
|
||||
const { hook } = renderDragHarness();
|
||||
const card = screen.getByRole('button', {
|
||||
name: 'AI 生成图片(待提交)',
|
||||
});
|
||||
const draftId = hook().placeholders[0]!.draftId;
|
||||
|
||||
fireEvent.pointerDown(card, {
|
||||
pointerId: 12,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
// 视口缩放 2:屏幕 6px > 阈值 5px,越过阈值这一刻才算拖动的开始。
|
||||
fireEvent.pointerMove(card, { pointerId: 12, clientX: 106, clientY: 100 });
|
||||
expect(hook().draggingDraftId).toBe(draftId);
|
||||
expect(card.style.left).toBe('13px');
|
||||
|
||||
/*
|
||||
* 真实鼠标的拖动会横跨很多轮宏任务:这里显式让时钟走一轮再继续。
|
||||
*
|
||||
* 这一行是这条用例的关键——抑制的**计时起点**必须是松手那一刻;如果实现改回「越过阈值时
|
||||
* 就起 0ms 计时器」,抑制会在松手之前被清掉,下面那次 `consumeDragClick` 就会变成 false,
|
||||
* 而真实浏览器里表现为「拖一下还是触发了点击」(jsdom 同步发 move / up 时看不见这一档)。
|
||||
*/
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
fireEvent.pointerUp(card, { pointerId: 12, clientX: 106, clientY: 100 });
|
||||
expect(hook().draggingDraftId).toBeNull();
|
||||
// 拖动收尾那次 click 被吃掉一次,之后恢复正常。
|
||||
expect(hook().consumeDragClick(draftId)).toBe(true);
|
||||
expect(hook().consumeDragClick(draftId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -154,8 +154,14 @@ describe('「生成任务」侧栏的开合与入口位置', () => {
|
||||
'.game-resource-generation-tasks-anchor',
|
||||
);
|
||||
expect(anchor).not.toBeNull();
|
||||
/*
|
||||
* 这一份夹具停在**资源总览**(没有打开任何栏目页),所以锚点用的是总览那一档:
|
||||
* 总览页顶部没有钉死的栏目标题栏,锚点贴画布顶边内缩即可;栏目画布页会因为那条整宽的
|
||||
* 标题栏(右端是「返回资源总览」)而多让开一段高度,两档的坐标与过渡见
|
||||
* `resourceCanvasAssetGenerationTasksSidebarStyle.test.ts`。
|
||||
*/
|
||||
expect(anchor?.getAttribute('data-generation-tasks-placement')).toBe(
|
||||
'canvas',
|
||||
'canvas-overview',
|
||||
);
|
||||
expect(
|
||||
document
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
|
||||
import { resolveResourceCanvasGenerationPanelPlacement } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
|
||||
import {
|
||||
clampResourceCanvasGenerationPanelAnchorX,
|
||||
resolveResourceCanvasGenerationPanelPlacement,
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
|
||||
resourceCanvasGenerationPanelWidth,
|
||||
revealResourceCanvasGenerationContent,
|
||||
} from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
|
||||
|
||||
const canvasSize = { width: 800, height: 600 };
|
||||
const insets = { top: 60, bottom: 90 };
|
||||
@@ -88,46 +94,84 @@ describe('生成浮层与占位的可见性', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('浮层高度与「盖住占位」判定按画布安全带算', () => {
|
||||
describe('浮层顶边与高度按占位卡的真实屏幕位置算', () => {
|
||||
const placement = (input: {
|
||||
canvasHeight: number;
|
||||
topInset: number;
|
||||
bottomInset: number;
|
||||
anchorTop: number;
|
||||
anchorHeight: number;
|
||||
}) => resolveResourceCanvasGenerationPanelPlacement(input);
|
||||
|
||||
test('空间够时挂在卡下面,高度 = 安全带 - 卡 - 间隙', () => {
|
||||
test('空间够时挂在卡下面:顶边贴卡下沿,高度 = 卡下沿到安全底边', () => {
|
||||
const result = placement({
|
||||
canvasHeight: 568,
|
||||
topInset: 54,
|
||||
bottomInset: 84,
|
||||
anchorTop: 54,
|
||||
anchorHeight: 128,
|
||||
});
|
||||
expect(result.overlaysAnchor).toBe(false);
|
||||
// 安全带 430 - 卡 128 - 间隙 12 = 290:落在「至少 260 可编辑」的区间里。
|
||||
// 安全底边 484 - 卡下沿(54 + 128) - 间隙 12 = 290。
|
||||
expect(result.top).toBe(54 + 128 + 12);
|
||||
expect(result.availableHeight).toBe(290);
|
||||
expect(result.top + result.availableHeight).toBe(568 - 84);
|
||||
});
|
||||
|
||||
test('安全带给不出可编辑高度时改为盖住占位,拿整条安全带', () => {
|
||||
// 缩放 1.5:占位卡视觉 192px,卡下面只剩 224 (< 280) → 盖住占位。
|
||||
test('安全带给不出可编辑高度时改为盖住占位,拿「卡顶边到安全底边」', () => {
|
||||
// 缩放 1.5:占位卡视觉 192px,卡下面只剩 234 (< 280) → 盖住占位。
|
||||
const result = placement({
|
||||
canvasHeight: 568,
|
||||
topInset: 58,
|
||||
bottomInset: 72,
|
||||
anchorTop: 58,
|
||||
anchorHeight: 192,
|
||||
});
|
||||
expect(result.overlaysAnchor).toBe(true);
|
||||
// 安全带 438 - 间隙 12 = 426:远大于「一百多像素」的旧表现。
|
||||
expect(result.availableHeight).toBe(426);
|
||||
expect(result.top).toBe(58);
|
||||
// 568 - 72 - 58 = 438:远大于「一百多像素」的旧表现。
|
||||
expect(result.availableHeight).toBe(438);
|
||||
expect(result.availableHeight).toBeGreaterThanOrEqual(260);
|
||||
});
|
||||
|
||||
/**
|
||||
* 验收现场那条「生成窗口和画布边缘碰撞」的回归:占位卡是追加在栏目内容最下方的,
|
||||
* 落到画布下半屏是常态。旧口径只吃「安全带高度 − 卡高」,等于假设卡永远贴在安全带上沿,
|
||||
* 于是浮层底边越过画布下沿被 `overflow: hidden` 切掉。
|
||||
*/
|
||||
test('占位卡落到下半屏时浮层底边仍收在安全底边以内', () => {
|
||||
const result = placement({
|
||||
canvasHeight: 600,
|
||||
topInset: 60,
|
||||
bottomInset: 90,
|
||||
anchorTop: 470,
|
||||
anchorHeight: 128,
|
||||
});
|
||||
expect(result.overlaysAnchor).toBe(true);
|
||||
// 卡顶边 470 会顶出安全底边(510 - 160 = 350),所以顶边先收到 350,再给一整段可编辑高度。
|
||||
expect(result.top).toBe(350);
|
||||
expect(result.top + result.availableHeight).toBeLessThanOrEqual(600 - 90);
|
||||
|
||||
// 中间位置同理:顶边贴卡顶,底边贴安全底边。
|
||||
const middle = placement({
|
||||
canvasHeight: 600,
|
||||
topInset: 60,
|
||||
bottomInset: 90,
|
||||
anchorTop: 260,
|
||||
anchorHeight: 128,
|
||||
});
|
||||
expect(middle.overlaysAnchor).toBe(true);
|
||||
expect(middle.top).toBe(260);
|
||||
expect(middle.top + middle.availableHeight).toBe(600 - 90);
|
||||
});
|
||||
|
||||
test('空间充足时收到上限,几何非法时回退到最小编辑高度', () => {
|
||||
expect(
|
||||
placement({
|
||||
canvasHeight: 1400,
|
||||
topInset: 54,
|
||||
bottomInset: 84,
|
||||
anchorTop: 54,
|
||||
anchorHeight: 128,
|
||||
}).availableHeight,
|
||||
).toBe(520);
|
||||
@@ -136,17 +180,92 @@ describe('浮层高度与「盖住占位」判定按画布安全带算', () => {
|
||||
canvasHeight: 0,
|
||||
topInset: 54,
|
||||
bottomInset: 84,
|
||||
anchorTop: 54,
|
||||
anchorHeight: 128,
|
||||
}),
|
||||
).toEqual({ overlaysAnchor: false, availableHeight: 280 });
|
||||
).toEqual({ overlaysAnchor: false, top: 54, availableHeight: 280 });
|
||||
// 画布小到连安全带都装不下:退到安全带能给的量,但仍有可编辑高度。
|
||||
const tiny = placement({
|
||||
canvasHeight: 240,
|
||||
topInset: 40,
|
||||
bottomInset: 40,
|
||||
anchorTop: 40,
|
||||
anchorHeight: 128,
|
||||
});
|
||||
expect(tiny.overlaysAnchor).toBe(true);
|
||||
expect(tiny.availableHeight).toBe(160);
|
||||
expect(tiny.top + tiny.availableHeight).toBeLessThanOrEqual(240 - 40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('浮层水平收口:贴着卡片,但恒定留在画布内', () => {
|
||||
const canvasWidth = 900;
|
||||
const panelWidth = resourceCanvasGenerationPanelWidth(canvasWidth);
|
||||
|
||||
test('宽度口径与浮层 CSS 同源:min(560, 画布宽 - 24)', () => {
|
||||
expect(panelWidth).toBe(RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH);
|
||||
expect(resourceCanvasGenerationPanelWidth(500)).toBe(
|
||||
500 - RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2,
|
||||
);
|
||||
// 画布未就绪(量不到宽)时退回最大宽度,交给 CSS 收。
|
||||
expect(resourceCanvasGenerationPanelWidth(0)).toBe(
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
|
||||
);
|
||||
});
|
||||
|
||||
test('卡片贴左边 / 贴右边时锚点收进来,浮层两边各留 12px', () => {
|
||||
const left = clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: 20,
|
||||
canvasWidth,
|
||||
panelWidth,
|
||||
});
|
||||
expect(left - panelWidth / 2).toBe(
|
||||
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
|
||||
);
|
||||
|
||||
const right = clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: canvasWidth - 5,
|
||||
canvasWidth,
|
||||
panelWidth,
|
||||
});
|
||||
expect(right + panelWidth / 2).toBe(
|
||||
canvasWidth - RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
|
||||
);
|
||||
|
||||
// 卡片在中间时不动:收口只在真的会越界时才生效。
|
||||
expect(
|
||||
clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: canvasWidth / 2,
|
||||
canvasWidth,
|
||||
panelWidth,
|
||||
}),
|
||||
).toBe(canvasWidth / 2);
|
||||
});
|
||||
|
||||
test('画布比浮层还窄时居中(宽度收口交给 CSS),几何非法时原样返回', () => {
|
||||
const narrowCanvas = 400;
|
||||
const narrowPanel = resourceCanvasGenerationPanelWidth(narrowCanvas);
|
||||
expect(
|
||||
clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: 10,
|
||||
canvasWidth: narrowCanvas,
|
||||
panelWidth: narrowPanel,
|
||||
}),
|
||||
).toBe(narrowCanvas / 2);
|
||||
|
||||
expect(
|
||||
clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: 33,
|
||||
canvasWidth: Number.NaN,
|
||||
panelWidth: 560,
|
||||
}),
|
||||
).toBe(33);
|
||||
expect(
|
||||
clampResourceCanvasGenerationPanelAnchorX({
|
||||
centerX: 33,
|
||||
canvasWidth: 800,
|
||||
panelWidth: 0,
|
||||
}),
|
||||
).toBe(33);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[Service]
|
||||
Environment=GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY=4
|
||||
Environment=GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND=20971520
|
||||
ExecStart=
|
||||
ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --storage-format files --mode full --minimal --retain-snapshots 2 --freeze-dir /var/lib/genarrative/database-backups/files-minimal-freeze --data-dir /stdb --work-dir /var/lib/genarrative/database-backups/files-minimal
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 范围
|
||||
|
||||
- 构建期按渠道生成清单:版本按渠道独立递增,清单包含该渠道平台的下载地址与签名;`dev-mac` 的 universal 包按同一地址与签名同时写入 `darwin-aarch64` 与 `darwin-x86_64`。
|
||||
- 构建期按渠道生成清单:版本按渠道独立递增,清单包含该渠道平台的下载地址与签名;macOS 自 2026-09-21 起只出 arm64 单架构,`dev-mac` 只登记 `darwin-aarch64`(原 universal 双键口径已作废:随包 Node 只有单架构官方发行版,需先实现双架构运行时才能恢复 Intel,见 `docs/project-memory/shared-memory/decision-log.md`)。
|
||||
- 构建期生成更新产物签名,并在缺少签名私钥或私钥不可用时失败关闭。
|
||||
- 渠道参数与目标平台绑定校验:Windows 目标只能发布 `dev-win`,macOS 目标只能发布 `dev-mac`;未显式指定时按目标平台取默认渠道。
|
||||
- 上传按渠道落位:安装包与签名进版本目录,清单覆盖写渠道路径的 latest 指针。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user