diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx
index 68886de45..e1e5f215d 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx
@@ -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 })}
)}
+ {referenceEnabled ? null : (
+ /*
+ 不给 `@` 的入口要**当场**说清为什么,否则用户只会读成「这里漏了一个按钮」
+ (验收现场那条「生成图标素材没法 @」就是这么来的)。
+
+ 原因在通道而不在 UI:图集走平台 `icon-spritesheets` 通道,提交体只有一张
+ `referenceId`(独立权威规范图)+ `iconDescriptions` + 切片参数,客户端这一侧对
+ 「图集带用户参考」是**显式拒绝**(`normalize_platform_art_reference_asset_ids`:
+ 「透明美术图集只接受规范图引用,不接受用户参考素材」),所以放开 UI 也提交不出去。
+ 判据与文案都由同一处给出,别的 reference-free 入口将来接入时不必各写一份。
+ */
+
+ 该入口走平台图集通道:参考只有一张权威规范图(图标规范),
+ 不支持另挑参考图——把需要哪些图标写进提示词。
+
+ )}
{referenceProblemNotice ? (
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 (
+
+ {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
+ resourceCanvasAssetGenerationTaskElapsedMillis(task, now),
+ )}`}
+
+ );
+}
+
+/**
+ * 一条派生/修改任务的「已耗时」。
+ *
+ * 同上:粒度与对话侧同一份 `useLiveNow`,只换资源编辑账本那一份文案入口。从原生账本恢复来的
+ * 收口记录没有结束时间(`elapsedMillis === null`)时整格不渲染——宁可不显示,也不拿创建时间
+ * 冒充耗时。
+ */
+function ResourceEditElapsedLabel({
+ task,
+}: {
+ task: ResourceCanvasResourceEditTask;
+}) {
+ const now = useLiveNow(!resourceCanvasResourceEditTaskIsTerminal(task));
+ const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(task, now);
+ if (elapsedMillis === null) {
+ return null;
+ }
+ return (
+
+ {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
+
+ );
+}
+
function assetGenerationTaskRow(
task: ResourceCanvasAssetGenerationTask,
- nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
const focusable = task.status === 'completed' && Boolean(task.assetId);
@@ -128,11 +183,7 @@ function assetGenerationTaskRow(
{task.phaseDetail}
-
- {`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
- resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
- )}`}
-
+
{task.error ? (
@@ -160,14 +211,7 @@ function assetGenerationTaskRow(
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
* 这行是哪一次修改。
*/
-function resourceEditTaskRow(
- task: ResourceCanvasResourceEditTask,
- nowMillis: number,
-) {
- const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
- task,
- nowMillis,
- );
+function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) {
return (
{task.phaseDetail}
- {elapsedMillis === null ? null : (
-
- {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
-
- )}
+
{task.error ? (
@@ -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({
) : (
{rendered.active.map((row) =>
- resourceCanvasGenerationTaskRowNode(
- row,
- nowMillis,
- onFocusTask,
- ),
+ resourceCanvasGenerationTaskRowNode(row, onFocusTask),
)}
)}
@@ -457,11 +488,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
<>
{rendered.visibleDone.map((row) =>
- resourceCanvasGenerationTaskRowNode(
- row,
- nowMillis,
- onFocusTask,
- ),
+ resourceCanvasGenerationTaskRowNode(row, onFocusTask),
)}
{rendered.done.length > rendered.visibleDone.length ? (
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx
index 2c0305d3b..503a6489a 100644
--- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx
+++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx
@@ -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 (
{
+ 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({