替换面板改为非模态浮层,支持在画布上点选替换目标
- 共享选择组件新增 opt-in nonModal:不铺遮罩、不做焦点陷阱、限高 + 内部滚动,Esc 在 document 阶段截断后取消,关掉即卸载;网页端美术画布不传,弹窗行为逐字不变。 - 新增 initialSelectionRevision:面板开着时宿主换了目标才重同步当前选择,面板里的搜索词与分类筛选不被重置。 - AGC 侧把候选面板锚到画布右上角(任务开关下方),壳样式放共享样式表、宿主只负责锚定;面板开着时画布照常可点,点中合法候选落成面板当前选择,写入仍只由「确认」发起。 - 退役旧的「点选替换」按钮、画布提示条与 resourceReplacementPickMode:同一个功能不留两条 UI 路径;合法性判据仍复用 resolveResourceReplacementPick,写入仍是同一个 confirm 函数。 - 确认时用画布点选结果兜底,避免点完当帧按确认误报「请选择一个替换素材」。 - 测试:替换用例 4 条点选路径改写为非模态 + 面板点选(含四类非法目标在面板内报因、Esc 只收面板),新增「画布点选不清掉面板搜索/分类」;共享组件用例新增 nonModal 形态与浮层壳声明级契约。 - 文档:PRD 5.3 / 7.8 第 8 条改写、验收用例 S15a 改写、decision-log 新增 2026-09-21 条目、待办文档登记三项并勾掉对应需求。
This commit is contained in:
@@ -1212,56 +1212,20 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* 「点选替换」提示条:点选态下贴在资源画布顶端居中浮出。
|
||||
* 「替换素材」面板的锚点:画布右上角、任务开关那一枚的下方。
|
||||
*
|
||||
* 与右下角缩放控件同一层语义(都在管理区之上,`position: absolute` 锚在
|
||||
* `.game-resource-book-manager` 上,而不是挂在会随视口缩放 / 平移的场景里),
|
||||
* 配色沿用工作台提示条那一套(`.game-resource-live-notice`)。
|
||||
* 面板本体(`.image-canvas-editor__project-asset-picker--floating`)是**非模态浮层**:
|
||||
* 面板开着的时候画布照常可点,在画布上点目标素材即落进面板的当前选择,写入只由面板「确认」发起。
|
||||
* 所以这里只负责锚定,壳样式(边框 / 底色 / 阴影 / 头部与滚动区)在共享样式表里,宿主不抄第二份。
|
||||
*
|
||||
* 它是**会话级**提示:会话期间常驻,不跟随某张卡,所以层级高于场景(20)与缩放(40);
|
||||
* 场景根是 `pointer-events: none`,这里必须显式收回指针事件,「取消」才点得动。
|
||||
* 位置取舍:右上角是画布上唯一不被栏目工具栏(左下)与缩放 / 撤销 Dock(右下)占用的稳定空位;
|
||||
* 顶部 3.5rem 起是任务开关,面板从 8.5rem 起排,两者同时开着也不重叠。
|
||||
*/
|
||||
.game-resource-canvas-pick-hint {
|
||||
.game-resource-replacement-panel {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 50%;
|
||||
z-index: 50;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: min(92vw, 34rem);
|
||||
padding: 8px 8px 8px 12px;
|
||||
border: 1px solid #edc7b5;
|
||||
border-radius: 10px;
|
||||
background: rgb(255 247 241 / 96%);
|
||||
box-shadow: 0 6px 18px rgb(112 70 52 / 14%);
|
||||
color: #8d5b45;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-resource-canvas-pick-hint-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-resource-canvas-pick-hint-error {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
color: #b3261e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-resource-canvas-pick-hint button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #dc9b7d;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #9b5537;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
top: 8.5rem;
|
||||
right: 0.85rem;
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
/* 栏目画布底部工具栏:栏目页(view=child)左下角的画布 chrome。
|
||||
|
||||
@@ -2272,13 +2272,21 @@ export default function ProjectDevelopmentView({
|
||||
useState<LocalProjectVersionReplacementCandidate[]>([]);
|
||||
const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false);
|
||||
/**
|
||||
* 「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),直接在资源画布上点目标素材。
|
||||
* 在画布上点选出来的替换目标(manifest 资产 id)+ 这次点选的序号。
|
||||
*
|
||||
* 与弹窗互斥,但**不重置** `source` / `candidates`:点选时的合法性判据(
|
||||
* `resolveResourceReplacementPick`)与写入载荷用的还是这一份冻结的源身份与权威候选。
|
||||
* 替换面板是**非模态**的:面板开着的时候画布照常可点,点中的合法候选落成这里的「本次目标」,
|
||||
* 面板里随之选中它;真正的写入仍然只由面板的「确认」发起。所以它既不是「点了就换」,
|
||||
* 也不是第二份候选——合法性判据(`resolveResourceReplacementPick`)与写入载荷用的还是
|
||||
* 那一份冻结的源身份与权威候选。
|
||||
*
|
||||
* `revision` 是给面板的「初值换了」信号:面板只在打开那一帧读 `selectedAssetIds`,
|
||||
* 而数组本身每次渲染都会重建、不能当信号(见组件里的注释)。连续点同一张卡时序号照增,
|
||||
* 保证每次都真的同步一次。
|
||||
*/
|
||||
const [resourceReplacementPickMode, setResourceReplacementPickMode] =
|
||||
useState(false);
|
||||
const [resourceReplacementPick, setResourceReplacementPick] = useState<{
|
||||
assetId: string;
|
||||
revision: number;
|
||||
} | null>(null);
|
||||
const [resourceReplacementLoading, setResourceReplacementLoading] =
|
||||
useState(false);
|
||||
const [resourceReplacementError, setResourceReplacementError] = useState<
|
||||
@@ -5885,9 +5893,9 @@ export default function ProjectDevelopmentView({
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (resourceReplacementPickMode) {
|
||||
// 点选态下左键按下就是「选这个目标」:不起拖拽、不换选中,直接把这次点选交给
|
||||
// 替换链路(合法即提交,非法把原因留在提示条上)。
|
||||
if (resourceReplacementOpen) {
|
||||
// 替换面板开着时左键按下就是「选这个目标」:不起拖拽、不换画布选中,直接把这次点选
|
||||
// 交给替换链路(合法即记为本次目标,非法把原因留在面板里)。
|
||||
pickResourceReplacementTargetRef.current(resource);
|
||||
return;
|
||||
}
|
||||
@@ -5953,7 +5961,7 @@ export default function ProjectDevelopmentView({
|
||||
resourceCategoryScopeKey,
|
||||
resourceBookOpensAllResources,
|
||||
resourceBookScenePlan,
|
||||
resourceReplacementPickMode,
|
||||
resourceReplacementOpen,
|
||||
selectedResourceIds,
|
||||
visibleResourceIds,
|
||||
],
|
||||
@@ -6156,16 +6164,6 @@ export default function ProjectDevelopmentView({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
|
||||
// 点空白既不退出点选,也不把用户正在替换的选中清掉。中键 / 空格平移照常,
|
||||
// 滚轮平移与缩放走 manager 上那条与指针无关的通路,不受影响。
|
||||
if (
|
||||
resourceReplacementPickMode &&
|
||||
event.button === 0 &&
|
||||
!resourceCanvasSpacePanRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
resourceBookTransitionControllerRef.current.settle();
|
||||
// 右键平移,保留中键/空格抓手;空白处左键继续框选。
|
||||
if (isPan) {
|
||||
@@ -6201,7 +6199,6 @@ export default function ProjectDevelopmentView({
|
||||
activePageCategory,
|
||||
clearResourceCanvasFocus,
|
||||
resourceBookOpensAllResources,
|
||||
resourceReplacementPickMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -7020,8 +7017,8 @@ export default function ProjectDevelopmentView({
|
||||
setResourceReplacementError(null);
|
||||
setResourceReplacementCandidates([]);
|
||||
setResourceReplacementSource(source);
|
||||
// 从工具条重新进入口时先退出上一次的点选会话(点选态下工具条仍可点)。
|
||||
setResourceReplacementPickMode(false);
|
||||
// 从工具条重新进入口时清掉上一次在画布上点出来的目标:面板里的选中必须与这次源素材一致。
|
||||
setResourceReplacementPick(null);
|
||||
setResourceReplacementLoading(true);
|
||||
try {
|
||||
const result = await readVersionResourceReplacementCandidates({
|
||||
@@ -7043,29 +7040,21 @@ export default function ProjectDevelopmentView({
|
||||
[activeVersionId, projectPath, projectVersions],
|
||||
);
|
||||
/**
|
||||
* 结束一次替换会话:弹窗、点选态与冻结的源身份 / 候选一起清干净。
|
||||
* 结束一次替换会话:面板、点选目标与冻结的源身份 / 候选一起清干净。
|
||||
*
|
||||
* 弹窗取消、点选态取消(Esc / 提示条「取消」)与替换成功都走这一处,避免出现
|
||||
* "弹窗关了但点选态还挂着"或"点选退出了但候选还留着"的半截状态。
|
||||
* 面板取消(× / Esc)、替换成功与切工作面都走这一处,避免出现「面板关了但目标还挂着」的
|
||||
* 半截状态。
|
||||
*/
|
||||
const closeResourceVersionReplacement = useCallback(() => {
|
||||
setResourceReplacementOpen(false);
|
||||
setResourceReplacementPickMode(false);
|
||||
setResourceReplacementPick(null);
|
||||
setResourceReplacementSource(null);
|
||||
setResourceReplacementCandidates([]);
|
||||
setResourceReplacementError(null);
|
||||
// 点选态给资源卡留的一次性点击抑制在这里收尾:不清干净的话,退出点选后下一次点
|
||||
// 点选给资源卡留的一次性点击抑制在这里收尾:不清干净的话,关掉面板后下一次点
|
||||
// 同一张卡会被吞掉一次(与拖拽结束后的收尾同一口径)。
|
||||
clearSkippedResourceCardClick();
|
||||
}, [clearSkippedResourceCardClick]);
|
||||
/**
|
||||
* 进入「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),冻结的源身份与权威候选原样留着。
|
||||
*/
|
||||
const enterResourceReplacementPickMode = useCallback(() => {
|
||||
setResourceReplacementOpen(false);
|
||||
setResourceReplacementError(null);
|
||||
setResourceReplacementPickMode(true);
|
||||
}, []);
|
||||
/**
|
||||
* 确认替换:直接改该版本的绑定(不建新版本),成功后重读 manifest。
|
||||
*
|
||||
@@ -7076,7 +7065,10 @@ export default function ProjectDevelopmentView({
|
||||
const confirmResourceVersionReplacement = useCallback(
|
||||
async (assetIds: string[]) => {
|
||||
const source = resourceReplacementSource;
|
||||
const replacementResourceId = assetIds[0];
|
||||
// 点选出来的目标优先兜底:面板的当前选择来自 `initialSelectionRevision` 同步,
|
||||
// 用户在点完画布那一帧立刻按确认(重渲染还没落地)时,不该给出「请选择一个替换素材」。
|
||||
const replacementResourceId =
|
||||
assetIds[0] ?? resourceReplacementPick?.assetId ?? null;
|
||||
if (!source || !replacementResourceId) {
|
||||
setResourceReplacementError('请选择一个替换素材');
|
||||
return;
|
||||
@@ -7130,6 +7122,7 @@ export default function ProjectDevelopmentView({
|
||||
projectPath,
|
||||
reloadManifestAfterAssetCommand,
|
||||
resourceReplacementLoading,
|
||||
resourceReplacementPick,
|
||||
resourceReplacementSource,
|
||||
],
|
||||
);
|
||||
@@ -7208,11 +7201,11 @@ export default function ProjectDevelopmentView({
|
||||
}, [resourceReplacementOpen, sweepResourceReplacementPreviews]);
|
||||
|
||||
/**
|
||||
* 点选态:把画布上点到的那张资源卡落成一次替换提交,或把「为什么不能替换」留在提示条上。
|
||||
* 画布点选:把点到的那张资源卡落成**本次替换目标**,或把「为什么不能替换」留在面板里。
|
||||
*
|
||||
* 卡片的单击不参与选中:这里先登记一次性抑制(与卡片拖拽后抑制误点同一机制),随后那次
|
||||
* `click` 会在 `handleResourceSelect` 里被吃掉 —— 只是这一下被消费,不是把单击全局关掉。
|
||||
* 合法目标直接走既有 `confirmResourceVersionReplacement`,写入路径与弹窗确认**完全同一条**。
|
||||
* 合法目标只记进面板(不写盘):写入路径与面板确认**完全同一条**,由用户按下确认才算数。
|
||||
*/
|
||||
const pickResourceReplacementTarget = useCallback(
|
||||
(resource: ProjectResource) => {
|
||||
@@ -7229,49 +7222,34 @@ export default function ProjectDevelopmentView({
|
||||
return;
|
||||
}
|
||||
setResourceReplacementError(null);
|
||||
void confirmResourceVersionReplacement([
|
||||
resolution.replacementResourceId,
|
||||
]);
|
||||
setResourceReplacementPick((current) => ({
|
||||
assetId: resolution.replacementResourceId,
|
||||
revision: (current?.revision ?? 0) + 1,
|
||||
}));
|
||||
},
|
||||
[
|
||||
confirmResourceVersionReplacement,
|
||||
resourceReplacementCandidates,
|
||||
resourceReplacementSource,
|
||||
],
|
||||
[resourceReplacementCandidates, resourceReplacementSource],
|
||||
);
|
||||
pickResourceReplacementTargetRef.current = pickResourceReplacementTarget;
|
||||
|
||||
/**
|
||||
* 点选态的退出:Esc、提示条「取消」、替换成功(`closeResourceVersionReplacement`)。
|
||||
* 替换面板只在资源画布上成立:切运行态 / 进 UI 编辑器就结束这次会话。
|
||||
*
|
||||
* Esc 挂在 **document** 上并 `stopPropagation`:资源画布自己的 Esc 挂在 window 上(清画布
|
||||
* 焦点 = 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到"Esc 只退出
|
||||
* 点选、不连带清选中"(与画布浮层「浮层打开时 Escape 归浮层所有」同一口径)。
|
||||
*
|
||||
* 点选会话只在资源画布可见时存在:切运行态 / 进 UI 编辑器就结束它,否则会留下一个既看不到
|
||||
* 提示条、也按不到 Esc 的会话。
|
||||
* 非模态面板与画布是同一屏的两半,离开这块工作面它既点不到目标素材、也说不清自己在替换谁;
|
||||
* Esc / × 关闭由面板自己管(见共享弹窗组件的 `nonModal`)。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!resourceReplacementPickMode) {
|
||||
if (!resourceReplacementOpen) {
|
||||
return undefined;
|
||||
}
|
||||
if (mode !== 'resources' || uiEditorRoute) {
|
||||
closeResourceVersionReplacement();
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
closeResourceVersionReplacement();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
return undefined;
|
||||
}, [
|
||||
closeResourceVersionReplacement,
|
||||
mode,
|
||||
resourceReplacementPickMode,
|
||||
resourceReplacementOpen,
|
||||
uiEditorRoute,
|
||||
]);
|
||||
|
||||
@@ -10331,27 +10309,6 @@ export default function ProjectDevelopmentView({
|
||||
onPointerCancel={stopResourceCanvasPan}
|
||||
onLostPointerCapture={handleResourceCanvasLostPointerCapture}
|
||||
/>
|
||||
{resourceReplacementPickMode ? (
|
||||
<div className="game-resource-canvas-pick-hint">
|
||||
<span className="game-resource-canvas-pick-hint-text">
|
||||
在画布上点选要替换成的素材;点击空白处不会退出
|
||||
</span>
|
||||
{resourceReplacementError ? (
|
||||
<span
|
||||
className="game-resource-canvas-pick-hint-error"
|
||||
role="alert"
|
||||
>
|
||||
{resourceReplacementError}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeResourceVersionReplacement}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{resourceCanvasBottomToolbarCategory ? (
|
||||
<ResourceCanvasBottomToolbarView
|
||||
category={resourceCanvasBottomToolbarCategory}
|
||||
@@ -10822,6 +10779,57 @@ export default function ProjectDevelopmentView({
|
||||
uiEditorRoute ? 'editor' : mode === 'run' ? 'run' : 'canvas'
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**——
|
||||
面板挂在画布右上角(在任务开关下方),面板开着的时候画布照常可点:在画布上点目标
|
||||
素材即落进面板的当前选择,写入仍然只由面板的「确认」发起。
|
||||
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把
|
||||
管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。
|
||||
*/}
|
||||
<div className="platform-theme platform-theme--light game-resource-replacement-panel">
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={resourceReplacementOpen}
|
||||
assets={resourceReplacementPickerEntries}
|
||||
selectedAssetIds={
|
||||
resourceReplacementPick ? [resourceReplacementPick.assetId] : []
|
||||
}
|
||||
initialSelectionRevision={resourceReplacementPick?.revision ?? 0}
|
||||
singleSelect
|
||||
nonModal
|
||||
selectionNoun="替换素材"
|
||||
assetBlockedReasons={resourceReplacementBlockedReasonMap}
|
||||
assetHints={resourceReplacementHintMap}
|
||||
errorMessage={resourceReplacementError}
|
||||
renderAssetMedia={(asset) => {
|
||||
const resource =
|
||||
resourceByManifestAssetId.get(asset.id) ?? null;
|
||||
const previewIdentity = resource
|
||||
? (resourceCardPreviews.identityByResourceId.get(
|
||||
resource.id,
|
||||
) ?? null)
|
||||
: null;
|
||||
return (
|
||||
<ResourcePreviewMedia
|
||||
resource={resource}
|
||||
previewIdentity={previewIdentity}
|
||||
preview={
|
||||
previewIdentity
|
||||
? (resourceCardPreviews.previews.get(previewIdentity) ??
|
||||
resourceCardPreviews.idlePreview)
|
||||
: resourceCardPreviews.idlePreview
|
||||
}
|
||||
onObservePreview={resourceCardPreviews.observePreview}
|
||||
onPreviewDecodeError={handleCardPreviewDecodeError}
|
||||
fallbackMediaType={asset.mediaType}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onCancel={closeResourceVersionReplacement}
|
||||
onConfirm={(assetIds) =>
|
||||
void confirmResourceVersionReplacement(assetIds)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!uiEditorRoute ? (
|
||||
@@ -11068,47 +11076,12 @@ export default function ProjectDevelopmentView({
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
版本级资源替换(直接替换):复用美术画布的参考图弹窗(单选 + 禁用原因 + 提示 + 失败原因)。
|
||||
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),弹窗只负责把
|
||||
版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**——
|
||||
面板挂在画布右上角,面板开着的时候画布照常可点:在画布上点目标素材即落进面板的当前选择,
|
||||
写入仍然只由面板的「确认」发起(`confirmResourceVersionReplacement` 与旧弹窗确认同一条)。
|
||||
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把
|
||||
管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。
|
||||
*/}
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={resourceReplacementOpen}
|
||||
assets={resourceReplacementPickerEntries}
|
||||
selectedAssetIds={[]}
|
||||
singleSelect
|
||||
selectionNoun="替换素材"
|
||||
assetBlockedReasons={resourceReplacementBlockedReasonMap}
|
||||
assetHints={resourceReplacementHintMap}
|
||||
errorMessage={resourceReplacementError}
|
||||
renderAssetMedia={(asset) => {
|
||||
const resource = resourceByManifestAssetId.get(asset.id) ?? null;
|
||||
const previewIdentity = resource
|
||||
? (resourceCardPreviews.identityByResourceId.get(resource.id) ??
|
||||
null)
|
||||
: null;
|
||||
return (
|
||||
<ResourcePreviewMedia
|
||||
resource={resource}
|
||||
previewIdentity={previewIdentity}
|
||||
preview={
|
||||
previewIdentity
|
||||
? (resourceCardPreviews.previews.get(previewIdentity) ??
|
||||
resourceCardPreviews.idlePreview)
|
||||
: resourceCardPreviews.idlePreview
|
||||
}
|
||||
onObservePreview={resourceCardPreviews.observePreview}
|
||||
onPreviewDecodeError={handleCardPreviewDecodeError}
|
||||
fallbackMediaType={asset.mediaType}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onCancel={closeResourceVersionReplacement}
|
||||
onPickFromCanvas={enterResourceReplacementPickMode}
|
||||
onConfirm={(assetIds) =>
|
||||
void confirmResourceVersionReplacement(assetIds)
|
||||
}
|
||||
/>
|
||||
{resourceRecoveryPanelOpen ? (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
|
||||
@@ -335,6 +335,80 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
|
||||
expect(overlay!.className).toMatch(/platform-theme--(light|dark)/u);
|
||||
});
|
||||
|
||||
it('nonModal 形态:只渲染浮层壳,不铺遮罩也不算模态面板', () => {
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open
|
||||
assets={[]}
|
||||
selectedAssetIds={[]}
|
||||
singleSelect
|
||||
nonModal
|
||||
selectionNoun="替换素材"
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '选择替换素材' });
|
||||
expect(dialog.className).toContain(
|
||||
'image-canvas-editor__project-asset-picker--floating',
|
||||
);
|
||||
expect(dialog.className).not.toContain(PLATFORM_MODAL_SHELL.slice(1));
|
||||
// 非模态的核心判据:没有全屏遮罩——宿主画布必须保持可点(在画布上点选目标素材)。
|
||||
expect(document.querySelector(PLATFORM_OVERLAY)).toBeNull();
|
||||
// 关掉就是卸载:宿主把 open 置回 false 后不该在画布角落留半块面板。
|
||||
cleanup();
|
||||
render(
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={false}
|
||||
assets={[]}
|
||||
selectedAssetIds={[]}
|
||||
singleSelect
|
||||
nonModal
|
||||
selectionNoun="替换素材"
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
|
||||
});
|
||||
|
||||
it('AGC 加载的样式表带非模态浮层的底色 / 边框 / 投影与滚动区', () => {
|
||||
const shell = findClassRule(
|
||||
loadedRules,
|
||||
'.image-canvas-editor__project-asset-picker--floating',
|
||||
);
|
||||
expect(
|
||||
shell,
|
||||
'非模态浮层壳不在 AGC 加载的样式表里:面板会变透明',
|
||||
).not.toBeNull();
|
||||
expect(shell!.declarations.get('background')).toBe(
|
||||
'var(--platform-modal-fill)',
|
||||
);
|
||||
expect(shell!.declarations.get('border')).toBe(
|
||||
'1px solid var(--platform-modal-border)',
|
||||
);
|
||||
expect(shell!.declarations.get('box-shadow')).toBe(
|
||||
'var(--platform-panel-shadow)',
|
||||
);
|
||||
|
||||
const header = findClassRule(
|
||||
loadedRules,
|
||||
'.image-canvas-editor__project-asset-picker-header',
|
||||
);
|
||||
expect(header).not.toBeNull();
|
||||
// 浮层自己限高 + 内部滚动:候选再多也不把画布整块盖住。
|
||||
const scroll = findClassRule(
|
||||
loadedRules,
|
||||
'.image-canvas-editor__project-asset-picker-scroll',
|
||||
);
|
||||
expect(scroll!.declarations.get('overflow-y')).toBe('auto');
|
||||
const actions = findClassRule(
|
||||
loadedRules,
|
||||
'.image-canvas-editor__project-asset-picker-actions',
|
||||
);
|
||||
expect(actions).not.toBeNull();
|
||||
});
|
||||
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
|
||||
/*
|
||||
这里必须**先转绝对路径再判断存在**:`filter(existsSync)` 拿到的是仓库相对路径,`existsSync`
|
||||
|
||||
@@ -297,7 +297,8 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
|
||||
}
|
||||
if (command === 'update_local_project_resource_classification') {
|
||||
const input = args?.input as
|
||||
{ assetId?: string; category?: string; tags?: string[] } | undefined;
|
||||
| { assetId?: string; category?: string; tags?: string[] }
|
||||
| undefined;
|
||||
return {
|
||||
asset: {
|
||||
id: input?.assetId,
|
||||
@@ -425,15 +426,10 @@ function replacementWrites(invoke: { mock: { calls: unknown[][] } }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 点选态的提示条;不在点选态时为 `null`。 */
|
||||
function pickHint() {
|
||||
return document.querySelector<HTMLElement>('.game-resource-canvas-pick-hint');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在点选态下点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
|
||||
* 在替换面板开着时点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
|
||||
*
|
||||
* 之所以把 `click` 也补上:点选态要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
|
||||
* 之所以把 `click` 也补上:点选要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
|
||||
* 的那一次 click** 实现的 —— 不补这一下,用例就漏掉了抑制残留这条最可能的回归。
|
||||
*/
|
||||
async function pickResourceCard(label: string) {
|
||||
@@ -1121,12 +1117,10 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 替换弹窗的「点选替换」入口(PRD §5.3):关掉弹窗、改在画布上点目标素材。
|
||||
*
|
||||
* 进入点选必须**卸载弹窗**:弹窗外壳是全屏遮罩,留着它画布上的卡根本点不到。
|
||||
* 入口本身不改数据:候选读取是打开弹窗时的事,这里一次替换写入都不该有。
|
||||
* 替换面板是**非模态浮层**(PRD §5.3):面板开着的时候画布照常可点,在画布上点目标素材即落进
|
||||
* 面板里的当前选择;写入仍然只由面板的「确认」发起,所以点选本身一次写入都不该有。
|
||||
*/
|
||||
it('点选替换:入口关掉弹窗进入点选态,提示条写明退出方式且不写盘', async () => {
|
||||
it('替换面板非模态:画布上点候选即落进面板的当前选择,零写入', async () => {
|
||||
const { invoke } = renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
@@ -1134,27 +1128,74 @@ describe('版本级资源替换', () => {
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
// 非模态判据:没有全屏遮罩,面板自己是画布上的浮层(否则画布上的卡根本点不到)。
|
||||
expect(dialog.className).toContain(
|
||||
'image-canvas-editor__project-asset-picker--floating',
|
||||
);
|
||||
expect(document.querySelector('.platform-overlay')).toBeNull();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
|
||||
act(() => {
|
||||
observer?.triggerVisible();
|
||||
});
|
||||
await pickResourceCard('final.png');
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
|
||||
const hint = pickHint();
|
||||
if (!hint) throw new Error('点选提示条未渲染');
|
||||
expect(hint.textContent).toContain('在画布上点选要替换成的素材');
|
||||
// 空白处点击不退出这件事必须写在提示条上:用户按直觉点空白才不会以为点坏了。
|
||||
expect(hint.textContent).toContain('点击空白处不会退出');
|
||||
expect(within(hint).getByRole('button', { name: '取消' })).not.toBeNull();
|
||||
const option = within(dialog).getByRole('option', {
|
||||
name: '选择替换素材final.png',
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(option.getAttribute('aria-selected')).toBe('true'),
|
||||
);
|
||||
// 点选只改面板里的当前选择:写入还是得等「确认」。
|
||||
expect(replacementWrites(invoke)).toHaveLength(0);
|
||||
// 点选那一下自带的 click 不得换画布选中:源素材仍是选中的那一个。
|
||||
expect(
|
||||
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
/**
|
||||
* 点选态下合法目标直接提交:写入路径与弹窗确认**完全同一条**(同一个 `confirm` 函数),
|
||||
* 载荷必须逐字一致,且成功后自动退出点选态。
|
||||
* 在画布上点目标素材只同步「当前选择」这一项:面板里的搜索词与分类筛选不能被重置 —— 用户
|
||||
* 常常是先筛到一半、才想起目标就在画布上(这正是这次的入口动机)。同步点由宿主的显式序号给
|
||||
* (`initialSelectionRevision`),不是「props 变了就重置」。
|
||||
*/
|
||||
it('画布点选同步当前选择,但不清掉面板里的搜索与分类', async () => {
|
||||
renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
const search = within(dialog).getByLabelText('搜索参考图素材');
|
||||
fireEvent.change(search, { target: { value: 'final' } });
|
||||
expect((search as HTMLInputElement).value).toBe('final');
|
||||
|
||||
act(() => {
|
||||
observer?.triggerVisible();
|
||||
});
|
||||
await pickResourceCard('final.png');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('option', { name: '选择替换素材final.png' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true'),
|
||||
);
|
||||
// 搜索词还在:同步选择没有顺手把面板筛回「全部 / 空关键词」。
|
||||
expect(
|
||||
(within(dialog).getByLabelText('搜索参考图素材') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('final');
|
||||
});
|
||||
/**
|
||||
* 面板里按下「确认」才写入:写入路径与旧的弹窗确认**完全同一条**(同一个 `confirm` 函数),
|
||||
* 载荷必须逐字一致,成功后关闭面板并标注血缘。
|
||||
*
|
||||
* 同时钉住"一次性抑制"的收尾:退出点选后点**同一张**刚被点选过的卡,单击语义必须完好
|
||||
* 同时钉住"一次性抑制"的收尾:关闭面板后点**同一张**刚被点选过的卡,单击语义必须完好
|
||||
* (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。
|
||||
*/
|
||||
it('点选替换:点中合法候选提交一次且载荷一致,成功后自动退出点选态', async () => {
|
||||
it('面板里确认才写入:载荷一致、成功后关闭面板并标注血缘', async () => {
|
||||
const { invoke, onManifestChange } = renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
@@ -1162,12 +1203,22 @@ describe('版本级资源替换', () => {
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
|
||||
|
||||
act(() => {
|
||||
observer?.triggerVisible();
|
||||
});
|
||||
await pickResourceCard('final.png');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('option', { name: '选择替换素材final.png' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true'),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1));
|
||||
expect(replacementWrites(invoke)[0]?.[1]).toEqual({
|
||||
@@ -1187,13 +1238,15 @@ describe('版本级资源替换', () => {
|
||||
expect.objectContaining({ revision: 6, source: 'asset-command' }),
|
||||
),
|
||||
);
|
||||
// 点选那一下自带的 click 不得换选中:源素材仍是选中的那一个。
|
||||
// 写入成功即结束这次会话:面板收掉,画布选中不动。
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
|
||||
);
|
||||
expect(
|
||||
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
await waitFor(() => expect(pickHint()).toBeNull());
|
||||
|
||||
// 退出点选后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
|
||||
// 会话结束后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
|
||||
const finalCard = (await findResourceSelectButton('final.png')).closest(
|
||||
'.game-resource-card',
|
||||
);
|
||||
@@ -1210,13 +1263,13 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 点选态下点非法目标:说明原因、**留在**点选态、零写入,且点空白既不退出也不清选中。
|
||||
* 非法目标:说明原因、**留在**面板里、零写入。
|
||||
*
|
||||
* 四种非法各点一次:源素材本身、不在权威候选里、分类不同的候选(跨栏目点)、
|
||||
* manifest 里没有的身份(未登记附件)。判据全部来自同一条 `resolveResourceReplacementPick`,
|
||||
* 文案与候选弹窗同源。
|
||||
* 文案与候选列表同源。
|
||||
*/
|
||||
it('点选替换:非法目标不提交、留在点选态并说明原因,点空白不退出', async () => {
|
||||
it('非法目标:面板里说明原因、面板留着、零写入', async () => {
|
||||
const { invoke } = renderReplacementWorkbench({
|
||||
attachments: [
|
||||
{
|
||||
@@ -1233,17 +1286,16 @@ describe('版本级资源替换', () => {
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
|
||||
const reason = () => within(dialog).getByRole('alert').textContent;
|
||||
|
||||
// ① 点的就是源素材本身。
|
||||
await pickResourceCard('legacy.png');
|
||||
expect(screen.getByRole('alert').textContent).toBe('替换素材与源素材相同');
|
||||
expect(pickHint()).not.toBeNull();
|
||||
await waitFor(() => expect(reason()).toBe('替换素材与源素材相同'));
|
||||
expect(
|
||||
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
// 空白处左键:既不退出点选,也不清画布选中(清选中会连带收起选中工具条)。
|
||||
// 空白处左键:面板不关(非模态面板不该被画布点击吃掉),也不写入。
|
||||
const canvas = screen.getByLabelText('资源依赖视图');
|
||||
// 指针捕获在 jsdom 里没有实现;这里补桩是为了让"这条按下到底走到哪一步"只由行为断言
|
||||
// 判定,而不是被一个缺失的 DOM API 提前打断。
|
||||
@@ -1258,31 +1310,23 @@ describe('版本级资源替换', () => {
|
||||
clientX: 5,
|
||||
clientY: 5,
|
||||
});
|
||||
expect(pickHint()).not.toBeNull();
|
||||
expect(
|
||||
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
screen.queryByRole('dialog', { name: '选择替换素材' }),
|
||||
).not.toBeNull();
|
||||
|
||||
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
|
||||
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
|
||||
await pickResourceCard('late.png');
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'替换素材未登记或已被删除',
|
||||
);
|
||||
expect(pickHint()).not.toBeNull();
|
||||
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
|
||||
|
||||
// ③ 分类不同的候选:目标在别的栏目,点选会话要跨栏目活着。
|
||||
// ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。
|
||||
await openResourceBookCategory('场景与环境');
|
||||
expect(pickHint()).not.toBeNull();
|
||||
act(() => {
|
||||
observer?.triggerVisible();
|
||||
});
|
||||
await pickResourceCard('scene.png');
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'替换素材不兼容:分类不同',
|
||||
);
|
||||
expect(pickHint()).not.toBeNull();
|
||||
// 非法目标同样不换选中:点选态里卡片单击只用来点选。
|
||||
await waitFor(() => expect(reason()).toBe('替换素材不兼容:分类不同'));
|
||||
// 非法目标同样不换选中:面板开着时卡片单击只用来点选。
|
||||
expect(
|
||||
getResourceSelectButton('scene.png').getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
@@ -1293,31 +1337,23 @@ describe('版本级资源替换', () => {
|
||||
observer?.triggerVisible();
|
||||
});
|
||||
await pickResourceCard('草稿.png');
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'替换素材未登记或已被删除',
|
||||
);
|
||||
expect(pickHint()).not.toBeNull();
|
||||
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
|
||||
expect(replacementWrites(invoke)).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 点选态的 Esc 只退出点选:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 +
|
||||
* 收浮层),提示条这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的
|
||||
* 选中一起丢。
|
||||
* 面板开着时的 Esc 只收面板:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 + 收浮层),
|
||||
* 面板这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的选中一起丢。
|
||||
*
|
||||
* 判据分两层:window 上的监听器收不到这次 Escape(截断生效),且选中工具条仍在。
|
||||
* 变异验证:去掉 `event.stopPropagation()`,本用例必须失败。
|
||||
*/
|
||||
it('点选替换:Esc 退出点选态且不清画布选中', async () => {
|
||||
it('替换面板:Esc 收面板且不清画布选中', async () => {
|
||||
renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
|
||||
expect(pickHint()).not.toBeNull();
|
||||
await screen.findByRole('dialog', { name: '选择替换素材' });
|
||||
|
||||
const windowEsc = vi.fn();
|
||||
window.addEventListener('keydown', windowEsc);
|
||||
@@ -1328,7 +1364,9 @@ describe('版本级资源替换', () => {
|
||||
}
|
||||
|
||||
expect(windowEsc).not.toHaveBeenCalled();
|
||||
expect(pickHint()).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
|
||||
);
|
||||
// 选中没有跟着被清掉:源素材仍是选中的那一个。
|
||||
expect(
|
||||
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**,见 §3.10 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
|
||||
更新时间:`2026-09-21`(2026-09-20 音频生成并入图片类那份后台任务账本、派生/修改类任务并入同一「生成任务」侧栏,2026-09-21 卡片浮层改为「提交即关」、**「生成任务」侧栏从画布左侧贴边改为画布右上角锚点(开关常驻)**、**资源卡可拖到对话栏批量 @ 引用**、**替换面板改为非模态浮层并支持在画布上点选目标**,见 §3.10 / §5.3 / §7.8 / §7.9 与 [`【功能说明】AGC聊天素材引用`](../【功能说明】AGC聊天素材引用-2026-09-08.md);2026-09-14 图片类生成后台化:入口 IPC 为 `start_local_project_asset_generation` + 项目内任务账本 + 本地排队 + 非模态「生成任务」面板;2026-09-13 新增的功能画布底部工具栏入口矩阵 §3.10 / §7.9,以及右侧 Supervisor 对话气泡、可访问对比度与过程卡布局收口,资源卡预览、分区布局、非破坏性资源编辑、资源替换与 Godot 双根合同保持不变)
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -448,7 +448,7 @@ type ProjectVersionResourceReplacement = {
|
||||
- **失败即拒绝**:硬门禁任一项不等、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` CAS 冲突,都必须在写入前拒绝并说明原因;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的版本卡或新的"当前使用"高亮。
|
||||
- **落盘与留痕**:改绑定属于 `versions` 变化,写入后推进一次 `projectRevision`;并追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`)。**没有可回溯的替换历史**:替换前的身份只有这条审计与 manifest 的 `.previous` 恢复副本。
|
||||
- **入口与可见效果**:入口在资源卡选中工具条,只对「manifest 资产 + 被当前版本绑定」的素材放行;候选弹窗列出全部候选,命中硬门禁的项渲染但禁用并给出原因,只有尺寸规格差异的项仍可选但带提示。成功后**不切换版本**(没有新版本可切),只重读 manifest;可见变化是资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。按 §7.4 的口径,改绑定不做运行时资源重映射,运行画面本身不会因改绑定而变化;也不自动重载 / 重启运行中的预览(§3.2 末条)。
|
||||
- **点选替换(2026-09-13 补充)**:候选弹窗 footer 提供「点选替换」,用于"目标就在画布上、不想在面板里筛"的场景。点它**必须关闭弹窗**(弹窗外壳是全屏遮罩,留着它画布上的卡点不到)并进入画布点选态,画布上出现提示条(「在画布上点选要替换成的素材;点击空白处不会退出」+「取消」)。点中合法候选直接走**同一条**写入链路(载荷与弹窗确认逐字一致,不产生第二套替换口径),成功后自动退出点选态;点中非法目标(分类 / 类型不同、不在后端给出的权威候选里、源素材本身、未登记资源)**不写入**,在提示条上说明原因(与候选弹窗同一条翻译口径)并**留在**点选态。退出只有三条路:Esc(在 `document` 阶段截断,画布全局 Esc 的清选中不随之触发)、提示条「取消」、替换成功;**空白点击不退出、也不清画布选中**。点选态下卡片左键只用于点选(不起拖拽、不换选中,靠一次性抑制而不是全局关掉单击),滚轮平移与 `Ctrl/Cmd` 缩放照常可用。
|
||||
- **替换面板在画布上点选目标(2026-09-21;2026-09-13 的「点选替换」由此取代)**:候选面板是画布右上角的**非模态浮层**(无全屏遮罩、不做焦点陷阱,面板自身限高 + 内部滚动),面板开着的时候画布照常可点——在画布上点目标素材即把它落成**面板里的当前选择**(面板随之高亮;用户在面板里已输入的搜索与分类筛选不被重置),写入仍然只由面板「确认」发起(载荷与旧弹窗确认逐字一致,不产生第二套替换口径)。点中非法目标(分类 / 类型不同、不在后端给出的权威候选里、源素材本身、未登记资源)**不写入**,在面板里说明原因(同一条翻译口径)并保留原选择。退出只有两条路:面板「取消」/ 头部 ×、Esc(在 `document` 阶段截断,画布全局 Esc 的清选中不随之触发);替换成功即关闭面板。面板开着时卡片左键只用于点选(不起拖拽、不换画布选中,靠一次性抑制而不是全局关掉单击),滚轮平移、`Ctrl/Cmd` 缩放与空白处框选照常可用。
|
||||
- **替换血缘标注(2026-09-13 补充)**:替换成功后只改绑定、光环本来就会从源素材移到替换素材,光靠光环说明不了"谁换成了谁",所以在卡面补一层**关系标注**:源素材卡标「已被 <替换素材显示名> 替换」,替换素材卡标「替换自 <源素材显示名>」,两处分别给出稳定 DOM 判据 `data-resource-replaced-by` / `data-resource-replacement-of`,值是对面资源的 **manifest 资产 id**(不是显示名)。这份关系是**宿主会话内状态**:同一会话里再次替换**整条覆盖**上一条(只保留当前有效的一条 A→B,不做历史链,与 §7.8「只展示当前有效关系」同口径);**切换项目 / 关闭工作台 / 重新加载 manifest 都不保留**——客户端没有替换关系的读侧事实源(写入结果只回 `versionId` / `committedProjectRevision`,审计 `asset.version_binding.replace` 只有写侧),manifest 的 `.previous` 只是原子安装的崩溃兜底副本、安装成功即删,不能当数据源,所以本标注**只在本次会话有效,不假装持久**。
|
||||
|
||||
### 5.4 游戏迭代版本(P1)
|
||||
@@ -657,7 +657,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
5. 硬门禁任一为 false、源版本不存在、源版本未绑定该素材、替换素材未登记、替换素材与源素材相同、`expectedProjectId` / `expectedProjectRevision` 冲突,都必须在写入前拒绝;拒绝时 manifest、`versions` 与项目 revision 都不变,投影里不得出现新的"当前使用"高亮或版本卡变化。
|
||||
6. 成功后重读 manifest 并**不切换版本**(没有新版本可切);**不**自动重载或重启运行中的预览(§3.2 末条),也不做运行时资源重映射。可见变化只有:资源卡"当前使用"高亮移到替换素材、`@` 面板"当前版本素材"更新。
|
||||
7. 替换成功后追加一条 `asset.version_binding.replace` 审计(`versionId / sourceResourceId / replacementResourceId / projectRevision`);**替换历史不可回溯**——替换前身份只有这条审计与 manifest 的 `.previous` 副本,不得声称能查到"某版本历史上换过什么"。
|
||||
8. 候选弹窗提供「点选替换」(2026-09-13):点它关闭弹窗进入画布点选态,并把「在画布上点选要替换成的素材;点击空白处不会退出」与「取消」放在画布提示条上;点中合法候选按第 3~7 条**同一条**链路提交并自动退出点选态,点中非法目标(分类 / 类型不同、不在权威候选里、源素材本身、未登记资源)零写入、留在点选态并在提示条上说明原因。退出只有 Esc(`document` 阶段截断,不清画布选中)/ 提示条「取消」/ 替换成功三条路,空白点击不退出;点选态下滚轮平移与缩放照常,卡片单击的既有语义在非点选态下不变。
|
||||
8. 替换面板在画布上点选目标(2026-09-21;取代 2026-09-13 的「点选替换」):候选面板是非模态浮层,面板开着时画布保持可点,点画布上的候选即落进面板的当前选择(搜索与分类筛选不被重置),写入仍由面板「确认」按第 3~7 条**同一条**链路发起;点中非法目标(分类 / 类型不同、不在权威候选里、源素材本身、未登记资源)零写入并在面板里说明原因。退出只有面板「取消」/ × 与 Esc(`document` 阶段截断,不清画布选中)两条路,成功后自动关闭;面板开着时滚轮平移、缩放与空白框选照常,卡片单击的既有语义在面板关闭后不变。
|
||||
9. 替换血缘标注(2026-09-13):替换成功后,源素材卡带「已被 <替换素材显示名> 替换」、替换素材卡带「替换自 <源素材显示名>」,稳定 DOM 判据 `data-resource-replaced-by` / `data-resource-replacement-of` 的值为对面资源的 manifest 资产 id;未参与替换的资源卡两个属性都不出现。同一会话内再次替换时**只保留当前有效的一条 A→B**(旧标注从卡上消失,不做历史链)。这份标注**只在本次会话有效**:切换项目 / 关闭工作台 / 重新加载 manifest 都不保留,也不得声称可回溯(第 7 条仍成立)。
|
||||
|
||||
### 7.9 P1 功能画布底部工具栏验收
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-21 AGC 替换面板改为非模态浮层,支持在画布上点选目标
|
||||
|
||||
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「在替换面板中支持画布点选目标」。2026-09-13 那版做的是「候选弹窗 footer 一个『点选替换』按钮 → 关掉弹窗 → 进画布点选态 → 点中即提交」:面板与画布互斥(弹窗外壳是全屏遮罩,留着它画布上的卡点不到),用户要么在面板里筛、要么离开面板。
|
||||
- 决策:候选面板改成**非模态浮层**(共享组件新增 opt-in `nonModal`:不铺遮罩、不做焦点陷阱、面板自身限高 + 内部滚动、Esc 在 `document` 阶段截断后取消;网页端美术画布不传,弹窗行为逐字不变)。AGC 把它锚在画布**右上角、任务开关下方**(`.game-resource-replacement-panel` → `top: 8.5rem; right: 0.85rem`,壳样式在共享样式表里,宿主只负责锚定)。**面板开着时画布照常可点**:点中合法候选即落成面板里的当前选择(面板里随之 `aria-selected`),写入仍然只由面板「确认」发起;点中非法目标在面板里说明原因、零写入。旧的「点选替换」入口、画布提示条 `.game-resource-canvas-pick-hint` 与 `resourceReplacementPickMode` 随之退役(同一个功能不留两条 UI 路径)。
|
||||
- 决策(同步口径):`selectedAssetIds` 的**数组引用不能当同步信号**(调用方每次渲染都会重建它,放进依赖会清掉用户在面板里的选择——组件里原本就为此写过一段注释),所以新增显式序号 `initialSelectionRevision`:只有宿主真的换了目标才重同步选择,搜索词与分类筛选保持原样。`confirmResourceVersionReplacement` 另外拿 `resourceReplacementPick` 兜底:点完画布当帧就按确认时不该报「请选择一个替换素材」。
|
||||
- 原因:面板与画布是同一屏的两半——目标本来就在画布上,「先把面板关掉再点」是弹窗外壳带来的妥协,不是产品意图。换成非模态之后,合法性判据仍是同一条 `resolveResourceReplacementPick`、写入仍是同一个 `confirm` 函数(载荷逐字一致),所以这是**外壳**的改动,不是第二套替换实现。
|
||||
- 已知取舍(相对旧口径的行为变化,均已随测试钉住):面板开着时空白处点击不再被吞——画布恢复正常的清焦点 / 框选 / 平移语义(源身份在打开面板时就已冻结,替换不依赖画布选中);「点中即提交」不再存在,提交一律由「确认」触发;关闭面板后卡片单击语义原样(一次性抑制照旧收尾)。
|
||||
- 验证:`resourceVersionReplacement.test.tsx` 19 passed(4 条旧点选用例改写为:非模态判据 + 点画布候选落进面板且零写入、面板确认才写入且载荷逐字一致+血缘、四类非法目标面板内报因零写入、Esc 只收面板不清选中;新增「画布点选不清掉面板搜索/分类」)、`projectAssetPickerDialogShellStyle.test.tsx` 7 passed(新增 nonModal 形态与浮层壳样式声明,含关掉即卸载)、`src/components/image-editor/ImageCanvasEditorView.test.tsx` 64 passed、`ImageCanvasEditorGenerationIntegration.test.tsx` 44 passed(网页端弹窗行为未变);连同 `appSurface.test.ts`、`projectResourceLiveIntegration.test.tsx` 共 6 个文件 694 passed / 17 skipped / 0 failed;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
|
||||
- 影响范围:`src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx`、`packages/shared/src/components/styles.css`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`.../features/resource-canvas/resourceCanvasChrome.css`、`apps/ai-game-creator-shell/tests/{resourceVersionReplacement.test.tsx,projectAssetPickerDialogShellStyle.test.tsx}`、PRD §5.3 / §7.8 第 8 条、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md` 的 S15a。未动 Rust、SpacetimeDB、external v1。
|
||||
- 已知未覆盖:真机观感(面板在画布右上角的落点、与任务开关同时打开时的间距)未在 Tauri 目视确认;面板里不显示「本次替换的是哪张源素材」,源身份只靠画布上那张卡的选中光环表达——点空白清掉选中后就只剩面板自己的候选列表。
|
||||
## 2026-09-21 AGC 资源卡拖到对话实现批量 @ 引用
|
||||
|
||||
- 背景:`docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md` 的「后续需求」要求「聊天拖拽批量引用」。改前只有两条入口:聊天输入框里输入 `@` 或点 `@` 按钮开素材选择面板,以及资源卡选中工具条上那枚「引用」按钮——都要先把素材找出来再点,多选批量引用没有一次成型的路径。
|
||||
|
||||
@@ -55,21 +55,29 @@
|
||||
- 范围核对:48 个改动文件全部落在 AGC 前端、3 个共享画布组件与 3 份文档;`server-rs` / `spacetime` / `docs/openapi` / `src-tauri` **零改动**(后端与契约无红线涉及),无 `.env`、密钥、构建产物或日志进入改动。
|
||||
- 合并后仍**未复验**的只有两类,且都不阻塞合并:① 真实客户端观感(本批动了动画面板样式、生图浮层紧凑排布与卡片基类 token——声明级与结构级都有守卫,真机要客户端验收确认);② 远程 CI(runner/镜像问题仍在处理,见 Issue #431;按用户要求本批不追)。
|
||||
|
||||
### 2026-09-21 第四批(「生成任务」侧栏移到画布右上角,待客户端验收)
|
||||
### 2026-09-21 第四批(画布三项:生成任务侧栏右上角 / 拖到对话批量引用 / 替换面板画布点选,待客户端验收)
|
||||
|
||||
需求原文:「生成任务列表移到画布右上角(照抄美术画布,但是保留当前的样式)」,对应本文件「后续需求」里那条。
|
||||
需求原文(本文件「后续需求」里那两条,三项一起做):
|
||||
|
||||
- 「生成任务列表移到画布右上角(照抄美术画布,但是保留当前的样式)」。
|
||||
- 「聊天拖拽批量引用」→ 拖动素材到对话实现批量 @ 引用。
|
||||
- 「画布点选替换」→ 在替换面板中支持画布点选目标。
|
||||
|
||||
- 改前:侧栏本体是 `position: fixed; top: 4rem; bottom: 6rem; left: 0.75rem` 的左侧贴边面板,开合口只有工具条上那一枚「生成任务 · N」按钮(资源 / 运行两个页签各渲染一次);分组、失败可见、限高滚动、提交受理后自动展开这四条本批之前已经具备,本轮只动**位置与开合形态**。
|
||||
- 改后:新增右上角锚点 `.game-resource-generation-tasks-anchor`(`position: absolute; top: 3.5rem; right: 0.85rem; z-index: 40`,挂在 `.game-workbench-stage` 里),**开关常驻右上角、面板在开关左侧展开、收起只剩那一枚开关**(与美术画布同一形态);工具条上那两处重复入口删除;面板改由 `max-height: min(30rem, calc(100vh - 12rem))` 封顶 + 内部滚动,自己不再定位。
|
||||
- 颜色 / 圆角 / 字重 / 阴影仍全部走 `--platform-*` token(开关沿用原工具条那枚次级胶囊的外观,只多一档面板投影),没有照搬网页端的固定色值。
|
||||
- 分档坐标:view 的 `placement`(`canvas | run | editor`)落成 `data-generation-tasks-placement`;运行表现层下移到 `top: 7rem`,避开它右上角 `game-run-version-picker`(`top: 20px; right: 20px`)。
|
||||
- 本机执行过:`resourceCanvasAssetGenerationTasksPanel.test.tsx` 11 passed、`resourceCanvasAssetGenerationTasksSidebarStyle.test.ts` 11 passed、`resourceCanvasGenerationTasksSidebarDismiss.test.tsx` 5 passed、`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 5 passed;仓库根 `tests/appSurface.test.ts` **538 passed / 17 skipped / 0 failed**(与既有基线逐条一致);`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit` 通过。
|
||||
- 仍未做:真实客户端目视(右上角落点、与版本入口的间距)与远程 CI 都没跑;窄屏(≤480px)只有声明级断言。
|
||||
- **本批第二项(拖到对话批量引用)**:资源卡按住拖到右侧 Agent 对话栏、松手即按这次拖动真正参与位移的那批(多选整批、单选只引用按住的那张)插入 `@` 引用;落点铺虚线框 + 「松手即可 @ 引用 N 项素材」,拖回画布内松手仍是排版语义;引用口径与工具条「引用」按钮逐字一致(只认已登记素材、`source: resource-card`),派发走新增的批量事件一次事务插入。
|
||||
- **本批第三项(替换面板画布点选)**:替换候选面板从全屏弹窗改为**画布右上角的非模态浮层**(共享组件新增 opt-in `nonModal`,网页端弹窗行为不变),面板开着时画布照常可点——点中合法候选即落成面板里的当前选择,写入仍只由「确认」发起;非法目标在面板里报因、零写入。旧的「点选替换」按钮、画布提示条与点选态随之退役(同一个功能不留两条 UI 路径)。
|
||||
- 本机执行过(三项合计):`resourceCanvasAssetGenerationTasksPanel` / `…SidebarStyle` / `…SidebarDismiss` / `…BackgroundClose`、`resourceCardReferenceDropModel`、`resourceCanvasChatReferenceDrop`、`resourceReferenceInput`、`resourceVersionReplacement`、`projectAssetPickerDialogShellStyle`、`ImageCanvasEditorView`、`ImageCanvasEditorGenerationIntegration`、`projectResourceLiveIntegration`、`appSurface` 全绿(定向批次多项合计 694 passed / 17 skipped);`typecheck`、`check:encoding`、`git diff --check`、prettier / eslint 通过。
|
||||
- 仍未做:真实客户端目视(三项的真机手感与落点)与远程 CI 都没跑;窄屏(≤480px)只有声明级断言;「复制对话保留有效引用」未做。
|
||||
|
||||
## 后续需求
|
||||
|
||||
- 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。
|
||||
- ~~生成任务列表移到画布右上角,进行中/已完成分组、失败可见、限高滚动及自动开合。~~ **2026-09-21 已处理**:侧栏改挂画布右上角锚点(开关常驻、面板在开关左侧展开,形态照抄美术画布,样式仍走 AGC 平台 token),工具条上的重复入口删除;`placement` 分档让运行表现层让开右上角版本入口。见下「2026-09-21 第四批」。
|
||||
- 素材替换关系与画布点选替换;聊天拖拽批量引用、复制对话保留有效引用。
|
||||
- ~~素材替换关系与画布点选替换;聊天拖拽批量引用~~ **2026-09-21 已处理**:替换面板改为画布右上角的非模态浮层、可直接在画布上点选目标素材;资源卡可拖到对话栏批量 @ 引用。见下「2026-09-21 第四批」。「复制对话保留有效引用」仍未做(同一条需求里的另一半)。
|
||||
|
||||
## 边界与验收
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
| **S13** 重命名 | 工具条「重命名」→ 新文件名 → 确认 | 磁盘文件名与 manifest `localPath` 更新;**asset id 不变**;写盘失败回滚文件 | L362(资源身份稳定)、L515 | 弹窗 `ariaLabel="重命名素材"`;改名后「选中资源:…」按钮的无障碍名同步变化;引用 chip 与 @ 候选列表显示名刷新 | ⚠️ **不改游戏源码里对旧 `assets/<name>` 的引用**,后果由用户自负 |
|
||||
| **S14** 下载 | 选中可下载卡 → 工具条「下载」 | 弹出**原生保存对话框**选目标路径 → 分块复制;取消即停止 | L507、飞书 §11.1.2 | 出现 `title="保存素材"` 的原生对话框;成功后状态提示含 `已保存 <路径>` | 语义是"另存为",不是静默直下;虚拟版本条目没有文件、不放行 |
|
||||
| **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` |
|
||||
| **S15a** 替换素材(含点选替换) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 候选弹窗(弹窗内可筛可选);再点 footer「点选替换」→ 弹窗关闭、进入画布点选态 → 在画布上直接点目标素材 | 弹窗确认与点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;点选态下合法目标直接提交并自动退出点选态,非法目标零写入、**留在**点选态并在提示条上说明原因 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 点选态判据:候选弹窗 `role="dialog"` 已卸载;画布出现 `.game-resource-canvas-pick-hint`,文案含「在画布上点选要替换成的素材」与「点击空白处不会退出」,并有「取消」按钮;非法目标后提示条仍在且出现 `role="alert"`(「替换素材不兼容:分类不同」/「替换素材与源素材相同」/「替换素材未登记或已被删除」);点选期间卡片 `aria-pressed` 不变(单击只用于点选)、源素材选中与工具条不因点空白或点非法目标而丢;Esc 退出点选(画布全局 Esc 的清选中不随之触发);点选态下滚轮平移与缩放照常可用 | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(点选会话跨栏目存活,不因「收起资源」或切栏目结束);空白点击不退出、也不清画布选中 |
|
||||
| **S15a** 替换素材(面板内选 + 画布点选目标) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 画布右上角出现非模态候选面板(面板内可筛可选)→ 直接在画布上点目标素材(或点面板里的候选)→ 面板「确认」 | 面板确认与画布点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;画布点选只把目标落成面板的当前选择,确认后才写入 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 面板判据:`role="dialog"` 名为「选择替换素材」、挂 `.image-canvas-editor__project-asset-picker--floating`、**没有** `.platform-overlay` 遮罩;点画布候选后面板里该候选 `aria-selected="true"`、面板里的搜索词与分类筛选保持原样、零写入;非法目标(源素材本身 / 不在权威候选里 / 分类不同 / 未登记资源)在面板里出现 `role="alert"`(「替换素材与源素材相同」/「替换素材未登记或已被删除」/「替换素材不兼容:分类不同」)且零写入;面板关闭 = 卸载;Esc 只收面板(画布全局 Esc 的清选中不随之触发) | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(替换会话跨栏目存活,不因「收起资源」或切栏目结束);面板开着时空白处仍可框选 / 平移,卡片单击语义的恢复发生在面板关闭之后 |
|
||||
|
||||
| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar="<category>"]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 |
|
||||
|
||||
@@ -271,5 +271,5 @@ api-server 是否本次重启:□ 是 □ 否
|
||||
|
||||
## 8. 验收边界
|
||||
|
||||
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(浮层视频 + 栏目工具栏图片类与音频)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览 / 弹窗与画布点选两条入口同一条写入链路)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
|
||||
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(浮层视频 + 栏目工具栏图片类与音频)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览 / 非模态面板 + 画布点选目标同一条写入链路)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
|
||||
- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs` 与 `apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`。
|
||||
|
||||
@@ -1006,6 +1006,62 @@ textarea.genarrative-ui-text-field__control {
|
||||
background: var(--platform-overlay-fill);
|
||||
}
|
||||
|
||||
/*
|
||||
* 参考图 / 替换素材选择组件的**非模态浮层**形态(`nonModal`)。
|
||||
*
|
||||
* 与上面的 `platform-modal-shell` 同一处理由:一个组件两种外壳,壳样式放在这里,凡是引入本文件
|
||||
* 的宿主一起命中;写进任一宿主的业务样式就会变成平行拷贝。落差只有外壳——底色 / 边框 / 阴影 /
|
||||
* 头部 / 滚动区 + 一列动作条,**不铺遮罩、不抢焦点**,宿主画布保持可点(AGC 的「替换素材」用它
|
||||
* 在画布上点选目标素材)。锚定位置由宿主给(AGC 是画布右上角),这里不写 `position`。
|
||||
*
|
||||
* 依赖 `theme.css` 的 token,由最近的主题祖先提供(AGC 的锚点容器自带
|
||||
* `.platform-theme--light`)。
|
||||
*/
|
||||
.image-canvas-editor__project-asset-picker--floating {
|
||||
display: flex;
|
||||
width: min(22rem, 34vw);
|
||||
max-height: min(30rem, 70vh);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--platform-modal-fill);
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
color: var(--platform-text-strong);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.image-canvas-editor__project-asset-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid var(--platform-line-soft);
|
||||
padding: 0.68rem 0.78rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__project-asset-picker-header strong {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__project-asset-picker-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem 0.8rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.image-canvas-editor__project-asset-picker-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-top: 1px solid var(--platform-line-soft);
|
||||
padding: 0.6rem 0.7rem;
|
||||
}
|
||||
|
||||
.genarrative-ui-empty-state {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
|
||||
import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
@@ -59,13 +66,24 @@ type ImageCanvasProjectAssetPickerDialogProps = {
|
||||
*/
|
||||
errorMessage?: string | null;
|
||||
/**
|
||||
* 「点选替换」:由宿主关闭本弹窗,改在画布上直接点选目标素材。
|
||||
* 非模态浮层:不铺全屏遮罩、不做焦点陷阱,宿主画布保持可点。
|
||||
*
|
||||
* 默认 `undefined` → **不渲染该入口**,弹窗行为与现在逐字不变(网页端美术画布的参考图
|
||||
* 选择不传它,多选与确认流程原样保留)。传了才多出一个按钮,点它不做选择、不回传 id ——
|
||||
* 弹窗只负责把"用户要走点选"这件事告诉宿主,进入与退出点选态都由宿主自己的状态机管。
|
||||
* AGC 的「替换素材」用它:面板开着的时候直接在资源画布上点目标素材,点中的候选落进面板的
|
||||
* 当前选择,写入仍然只由面板的「确认」发起。默认 `false` → 网页端美术画布的弹窗行为逐字不变。
|
||||
*
|
||||
* 打开期间 Esc 仍等于「取消」(document 阶段截断,宿主画布的全局 Esc 不随之触发);
|
||||
* 点外部不关闭——非模态面板与画布是同一屏的两半,点画布是要选目标,不是要关面板。
|
||||
*/
|
||||
onPickFromCanvas?: () => void;
|
||||
nonModal?: boolean;
|
||||
/**
|
||||
* 「初值换了」的信号:宿主在面板**开着**的时候又给了新的 `selectedAssetIds`(AGC 里是在
|
||||
* 画布上点选目标素材),序号一变就按新初值重同步当前选择。
|
||||
*
|
||||
* 不能用 `selectedAssetIds` 的引用当信号:调用方每次渲染都会重建那个数组,把它放进依赖会
|
||||
* 让「父级任何一次重渲染」都清掉用户的选择(组件里原本就是这么写的)。所以同步点交给这个
|
||||
* 显式序号:只有宿主真的换了目标才变化。缺省 `0`(网页端美术画布不传,行为逐字不变)。
|
||||
*/
|
||||
initialSelectionRevision?: number;
|
||||
};
|
||||
|
||||
function assetIcon(category: ProjectAssetPickerCategory) {
|
||||
@@ -121,7 +139,8 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
renderAssetMedia,
|
||||
selectionNoun = '参考图',
|
||||
errorMessage,
|
||||
onPickFromCanvas,
|
||||
nonModal = false,
|
||||
initialSelectionRevision = 0,
|
||||
}: ImageCanvasProjectAssetPickerDialogProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
|
||||
@@ -143,6 +162,18 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开的那一帧重置
|
||||
}, [open]);
|
||||
|
||||
/**
|
||||
* 面板开着时宿主换了初值(AGC 在画布上点选目标素材):只同步这一项,别的不动。
|
||||
*
|
||||
* 搜索词与分类保持原样——用户在面板里筛到一半、又去画布上点一张,回来不该被重置成「全部」。
|
||||
* 选择直接落成新初值(单选场景就是那一项)。
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
setSelection([...selectedAssetIds]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只认显式序号,不认数组引用
|
||||
}, [initialSelectionRevision, open]);
|
||||
|
||||
const visibleAssets = useMemo(
|
||||
() =>
|
||||
assets.filter((asset) =>
|
||||
@@ -171,59 +202,63 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={`选择${selectionNoun}`}
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel={`关闭选择${selectionNoun}`}
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
{onPickFromCanvas ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onPickFromCanvas}
|
||||
>
|
||||
点选替换
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label={`确认选择${selectionNoun}`}
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
/**
|
||||
* 非模态下的 Esc = 取消。
|
||||
*
|
||||
* 挂在 **document** 并 `stopPropagation`:宿主画布的全局 Esc 挂在 window 上(清画布焦点 =
|
||||
* 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到「Esc 只收替换面板、
|
||||
* 不连带清画布选中」(与「浮层打开时 Escape 归浮层所有」同一口径)。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open || !nonModal) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
>
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [nonModal, onCancel, open]);
|
||||
|
||||
const dialogLabel = `选择${selectionNoun}`;
|
||||
const pickerFooter = (
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label={`确认选择${selectionNoun}`}
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
);
|
||||
const pickerBody = (
|
||||
<>
|
||||
{errorMessage ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
@@ -359,6 +394,57 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
{visibleAssets.length} / {assets.length}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* 非模态浮层:不铺遮罩、不抢焦点,宿主画布保持可点(AGC 的「替换素材」用它——面板开着时
|
||||
* 直接在画布上点目标素材,点中的候选落进这里的当前选择,写入仍然只由「确认」发起)。
|
||||
*/
|
||||
if (nonModal) {
|
||||
// 关掉就是卸载(模态那条路由 `UnifiedModal` 自己按 `open` 返回空):与弹窗同一判据,
|
||||
// 否则宿主把 `open` 置回 false 之后画布右上角还留着半块面板。
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__project-asset-picker image-canvas-editor__project-asset-picker--floating"
|
||||
role="dialog"
|
||||
aria-label={dialogLabel}
|
||||
>
|
||||
<header className="image-canvas-editor__project-asset-picker-header">
|
||||
<strong>{dialogLabel}</strong>
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
placement="inline"
|
||||
label={`关闭选择${selectionNoun}`}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</header>
|
||||
<div className="image-canvas-editor__project-asset-picker-scroll">
|
||||
{pickerBody}
|
||||
</div>
|
||||
<footer className="image-canvas-editor__project-asset-picker-actions">
|
||||
{pickerFooter}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={dialogLabel}
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel={`关闭选择${selectionNoun}`}
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={pickerFooter}
|
||||
>
|
||||
{pickerBody}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user