生成占位可见性、落点与音频提交身份修复

打开生成浮层时把占位与浮层整块带进安全区(避开顶栏底栏),只做最小视口平移不改缩放
落点意图改为按草稿 ID 的 Map,避免并发完成互相覆盖;坐标在落点时刻取占位最新位置
落点 section 改用资源正式归类后的 category,修复图片落待归类、规范落文档时静默跳过
音频与背景音乐提交绑定占位与 operationId:在途时拒绝再次提交,失败保留输入并可同 operation 重试
宿主记录收起浮层时的草稿与音频提交身份,重开占位接着编辑而不是回到空表单
生成任务新增入口栏目 targetCategory 走原生可选入参,前端不据此伪分类
补可见性平移的定向测试
This commit is contained in:
2026-09-17 18:40:29 +08:00
parent 7f80012d7f
commit b08ab6ee66
7 changed files with 606 additions and 112 deletions
@@ -94,7 +94,13 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
* 面板自己不持有任何在途状态。
*/
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
onClose: () => void;
/**
* 收起浮层。
*
* 参数是当前草稿:宿主保存它,用户再点开占位卡时接着编辑(关闭 ≠ 丢弃输入,不是空表单)。
* 提交后的关闭不带草稿——这次输入已经被任务接走,重试身份也在宿主的提交上下文里。
*/
onClose: (draft?: ResourceCanvasAssetGenerationPanelDraft) => void;
};
/**
@@ -189,6 +195,15 @@ export function ResourceCanvasAssetGenerationPanelView({
setPrompt(next.text);
setReferences(next.references);
};
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
const closeWithDraft = () =>
onClose({
prompt,
assetName,
aspectRatio,
imageSize,
references,
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -215,6 +230,7 @@ export function ResourceCanvasAssetGenerationPanelView({
imageSize,
references,
});
// 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。
onClose();
}
@@ -227,7 +243,7 @@ export function ResourceCanvasAssetGenerationPanelView({
<button
type="button"
aria-label={`关闭${action.label}`}
onClick={onClose}
onClick={closeWithDraft}
>
<X size={16} aria-hidden="true" />
</button>
@@ -341,7 +357,7 @@ export function ResourceCanvasAssetGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
onClick={onClose}
onClick={closeWithDraft}
>
</PlatformActionButton>
@@ -373,7 +389,7 @@ export function ResourceCanvasAssetGenerationPanelView({
<ThemedModal
open
ariaLabel={action.label}
onClose={onClose}
onClose={closeWithDraft}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
{panelBody}
@@ -45,8 +45,36 @@ export type ResourceCanvasGenerationPanelViewProps = {
*/
variant?: 'modal' | 'floating';
style?: CSSProperties | null;
/**
* 初始草稿(音频 / 背景音乐入口用)。
*
* 用户收起浮层后草稿由宿主保存,再点开占位卡时从这里灌回来——**不是**空表单,
* 用户不必重打一遍提示词。
*/
initialDraft?: {
kind: ResourceCanvasGenerationKind;
prompt: string;
assetName: string;
} | null;
/**
* 已绑定的提交身份。
*
* 同一次草稿的重试必须复用同一对 `operationId` / 幂等键:原生按 operation 记账,换一对就是
* 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。
*/
request?: ResourceEditRequestIdentity | null;
onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise<void>;
onClose: () => void;
/**
* 收起浮层。
*
* 参数是当前草稿:宿主把它存起来,用户再点开占位卡时能接着编辑(关闭 ≠ 丢弃输入)。
* 提交成功后的关闭不带草稿(这次输入已经被任务接走)。
*/
onClose: (draft?: {
kind: ResourceCanvasGenerationKind;
prompt: string;
assetName: string;
}) => void;
};
const RESOURCE_GENERATION_ALL_KIND_ITEMS =
@@ -77,6 +105,8 @@ export function ResourceCanvasGenerationPanelView({
initialKind,
variant = 'modal',
style,
initialDraft,
request: boundRequest,
onSubmit,
onClose,
}: ResourceCanvasGenerationPanelViewProps) {
@@ -94,16 +124,23 @@ export function ResourceCanvasGenerationPanelView({
const option = resourceCanvasGenerationOption(kind);
const panelTitle =
allowedOptions.length === 1 ? option.generationLabel : '生成素材';
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(option.assetName);
const [prompt, setPrompt] = useState(initialDraft?.prompt ?? '');
const [assetName, setAssetName] = useState(
initialDraft?.assetName ?? option.assetName,
);
const [attempted, setAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸
// Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定
// 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。
const requestRef = useRef<ResourceEditRequestIdentity | null>(null);
const requestRef = useRef<ResourceEditRequestIdentity | null>(
boundRequest ?? null,
);
const inputLocked = attempted || submitting;
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
const closeWithDraft = () =>
onClose({ kind, prompt, assetName: assetName.trim() || option.assetName });
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -145,7 +182,7 @@ export function ResourceCanvasGenerationPanelView({
<button
type="button"
aria-label={`关闭${panelTitle}`}
onClick={onClose}
onClick={closeWithDraft}
>
<X size={16} aria-hidden="true" />
</button>
@@ -206,7 +243,7 @@ export function ResourceCanvasGenerationPanelView({
<PlatformActionButton
type="button"
tone="secondary"
onClick={onClose}
onClick={closeWithDraft}
>
{submitting ? '后台运行并关闭' : '取消'}
</PlatformActionButton>
@@ -250,7 +287,7 @@ export function ResourceCanvasGenerationPanelView({
<ThemedModal
open
ariaLabel={panelTitle}
onClose={onClose}
onClose={closeWithDraft}
panelClassName="game-approval-dialog game-resource-generation-dialog"
>
{panelBody}
@@ -154,6 +154,10 @@ export function createResourceCanvasAssetGenerationQueue(
assetName: task.assetName,
referenceAssetIds: task.referenceAssetIds,
outputPath: task.outputPath,
// 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。
...(task.targetCategory
? { targetCategory: task.targetCategory }
: {}),
})) as LocalProjectAssetGenerationTaskRecord;
let started: LocalProjectAssetGenerationTaskRecord;
try {
@@ -3,6 +3,7 @@ import {
type ResourceCanvasAssetToolAction,
resourceCanvasBottomToolActions,
} from './resourceCanvasBottomToolbarModel';
import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
export type ResourceCanvasAssetGenerationTaskStatus =
@@ -56,6 +57,14 @@ export type ResourceCanvasAssetGenerationTask = {
* 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。
*/
referenceAssetIds: string[];
/**
* 这次的**入口栏目**:用户点工具时正在看的那个栏目。
*
* 原生侧按它把新素材直接登记进该栏目(`targetCategory`,可选入参);原生还没支持时它只是
* 一条提示,落点仍按正式归类后的 section 走(见宿主落点 effect)。前端**不做**伪分类:
* 归类真相只在 manifest 与原生命令里。
*/
targetCategory: ProjectResourceCanvasCategory | null;
outputPath: string | null;
projectId: string;
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
@@ -165,6 +174,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
aspectRatio: string;
imageSize: string;
referenceAssetIds?: readonly string[];
targetCategory?: ProjectResourceCanvasCategory | null;
outputPath: string | null;
projectId: string;
nowMillis: number;
@@ -188,6 +198,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
.filter((assetId) => assetId.length > 0),
),
],
targetCategory: input.targetCategory ?? null,
outputPath: input.outputPath,
projectId: input.projectId,
dispatched: false,
@@ -220,6 +231,7 @@ export function restoreResourceCanvasAssetGenerationTask(
aspectRatio: '',
imageSize: '',
referenceAssetIds: [],
targetCategory: null,
outputPath: null,
projectId: record.projectId,
dispatched: true,
@@ -0,0 +1,90 @@
import type { CanvasViewport } from '../../../../../packages/image-canvas-core/src/types';
import { normalizeResourceBookViewport } from '../../view/project-development/resourceBookViewport';
/**
* 生成浮层/占位的可见性:把「占位卡 + 它下沿的浮层」整块带进画布安全区。
*
* 只做**最小平移**、不改缩放:用户的缩放预期不能被一次工具点击改掉。与既有
* `ensureResourceBookContentVisible` / `centerResourceCanvasOnResource` 同一条口径,
* 区别只在于这里要求内容**完整**落在安全区里、并且知道顶栏与底栏要留出来多少。
*
* 安全区 = 画布减去顶栏(栏目标题栏)与底栏(底部工具栏)后的区域:占位本来就可能被排到
* 内容下方(真实浏览器 1280x720 上曾出现占位 y≈550、整块浮层落在可视区之外,用户只看到底栏)。
*/
export type ResourceCanvasGenerationSafeInsets = {
top: number;
bottom: number;
left?: number;
right?: number;
};
export function revealResourceCanvasGenerationContent({
viewport,
content,
canvasSize,
insets,
padding = 12,
}: {
viewport: CanvasViewport;
/** 需要完整可见的内容矩形(画布坐标):占位卡 + 浮层高度 + 两者之间的间隙。 */
content: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
insets: ResourceCanvasGenerationSafeInsets;
/** 安全区左右默认留白(顶/底由 insets 覆盖)。 */
padding?: number;
}): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
const finite =
Number.isFinite(content.x) &&
Number.isFinite(content.y) &&
Number.isFinite(content.width) &&
Number.isFinite(content.height) &&
Number.isFinite(canvasSize.width) &&
Number.isFinite(canvasSize.height) &&
canvasSize.width > 0 &&
canvasSize.height > 0;
if (!finite || content.width <= 0 || content.height <= 0) {
return current;
}
const safeLeft = Math.max(0, insets.left ?? padding);
const safeTop = Math.max(0, insets.top);
const safeRight = Math.max(
safeLeft + 1,
canvasSize.width - Math.max(0, insets.right ?? padding),
);
const safeBottom = Math.max(
safeTop + 1,
canvasSize.height - Math.max(0, insets.bottom),
);
const screenLeft = current.x + content.x * current.scale;
const screenTop = current.y + content.y * current.scale;
const screenWidth = Math.max(1, content.width * current.scale);
const screenHeight = Math.max(1, content.height * current.scale);
const screenRight = screenLeft + screenWidth;
const screenBottom = screenTop + screenHeight;
// 水平:先按左边界对齐,右边越界再整体左推;内容比安全区还宽时以左边界为准。
let dx = 0;
if (screenRight > safeRight) {
dx = safeRight - screenRight;
}
if (screenLeft + dx < safeLeft) {
dx = safeLeft - screenLeft;
}
// 垂直:同理。占位被排到内容下方时要往上带,浮层顶到顶栏时要往下带。
let dy = 0;
if (screenBottom > safeBottom) {
dy = safeBottom - screenBottom;
}
if (screenTop + dy < safeTop) {
dy = safeTop - screenTop;
}
if (dx === 0 && dy === 0) {
return current;
}
return {
scale: current.scale,
x: current.x + dx,
y: current.y + dy,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
import { describe, expect, test } from 'vitest';
import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
const canvasSize = { width: 800, height: 600 };
const insets = { top: 60, bottom: 90 };
describe('生成浮层与占位的可见性', () => {
test('内容已经在安全区内时坐标不变(宿主据此跳过写回)', () => {
const viewport = { x: 0, y: 0, scale: 1 };
const next = revealResourceCanvasGenerationContent({
viewport,
content: { x: 40, y: 80, width: 180, height: 200 },
canvasSize,
insets,
});
expect(next).toEqual(viewport);
});
test('内容落到视口下方时做最小上移,底边贴住底栏安全区', () => {
const viewport = { x: 0, y: 0, scale: 1 };
// 占位在 y=550、加上浮层后整块落到视口外——真实浏览器上复现过的那一档。
const next = revealResourceCanvasGenerationContent({
viewport,
content: { x: 40, y: 550, width: 180, height: 320 },
canvasSize,
insets,
});
const safeBottom = canvasSize.height - insets.bottom;
expect(viewport.y + (550 + 320)).toBeGreaterThan(safeBottom);
expect(next.y + 550 + 320).toBe(safeBottom);
expect(next.x).toBe(0);
expect(next.scale).toBe(1);
});
test('内容顶出顶栏时下移,内容越出左右边时做最小横移', () => {
const next = revealResourceCanvasGenerationContent({
viewport: { x: 0, y: 0, scale: 1 },
content: { x: -300, y: -40, width: 180, height: 120 },
canvasSize,
insets,
});
expect(next.y).toBe(insets.top + 40);
expect(next.x).toBe(12 + 300);
});
test('缩放参与换算:缩小后的越界按同一口径平移,比例保持不变', () => {
const next = revealResourceCanvasGenerationContent({
viewport: { x: 0, y: 0, scale: 0.5 },
content: { x: 40, y: 900, width: 180, height: 320 },
canvasSize,
insets,
});
expect(next.scale).toBe(0.5);
const safeBottom = canvasSize.height - insets.bottom;
expect(next.y + (900 + 320) * 0.5).toBe(safeBottom);
});
test('内容比安全区还大时以顶边 / 左边对齐,不来回抖', () => {
const next = revealResourceCanvasGenerationContent({
viewport: { x: 0, y: 0, scale: 1 },
content: { x: 0, y: 0, width: 2000, height: 1200 },
canvasSize,
insets,
});
expect(next).toEqual({ scale: 1, x: 12, y: insets.top });
});
test('尺寸非法或画布未就绪时原样返回', () => {
const viewport = { x: 5, y: 6, scale: 1 };
expect(
revealResourceCanvasGenerationContent({
viewport,
content: { x: 0, y: 0, width: 0, height: 0 },
canvasSize,
insets,
}),
).toEqual(viewport);
expect(
revealResourceCanvasGenerationContent({
viewport,
content: { x: 0, y: 0, width: 10, height: 10 },
canvasSize: { width: 0, height: 0 },
insets,
}),
).toEqual(viewport);
});
});