画布验收遗留问题与后续需求 #419
+24
-2
@@ -759,8 +759,15 @@ function ResourceReferenceEditor({
|
||||
const reminderDisabledRef = useRef(reminderDisabled);
|
||||
reminderDisabledRef.current = reminderDisabled;
|
||||
|
||||
/**
|
||||
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
|
||||
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
|
||||
* 否则刚显示出来的提示会被自己的回填立刻清掉。
|
||||
*/
|
||||
const appliedPromptRef = useRef<string | null>(null);
|
||||
const applyPromptText = useCallback(
|
||||
(text: string) => {
|
||||
appliedPromptRef.current = text;
|
||||
flushSync(() => {
|
||||
onChange({ text, references: liveDraftRef.current.references });
|
||||
});
|
||||
@@ -782,13 +789,25 @@ function ResourceReferenceEditor({
|
||||
const {
|
||||
polishing,
|
||||
error: polishError,
|
||||
notice: polishNotice,
|
||||
originalText: polishedOriginalText,
|
||||
polish: runPolish,
|
||||
restoreOriginal: restoreOriginalPrompt,
|
||||
clearError: clearPolishError,
|
||||
clearNotice: clearPolishNotice,
|
||||
reset: resetPromptPolish,
|
||||
} = promptPolishState;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
appliedPromptRef.current !== null &&
|
||||
value === appliedPromptRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearPolishNotice();
|
||||
}, [clearPolishNotice, value]);
|
||||
|
||||
const polishPrompt = useCallback(async () => {
|
||||
await runPolish();
|
||||
}, [runPolish]);
|
||||
@@ -1083,13 +1102,16 @@ function ResourceReferenceEditor({
|
||||
) : null}
|
||||
</div>
|
||||
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
|
||||
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
|
||||
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
|
||||
{showPolishAction &&
|
||||
!reminderOpen &&
|
||||
(polishing || polishError || polishNotice) ? (
|
||||
<span
|
||||
className="resource-reference-input-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{polishing ? '润色中…' : polishError}
|
||||
{polishing ? '润色中…' : (polishError ?? polishNotice)}
|
||||
</span>
|
||||
) : null}
|
||||
<LexicalTypeaheadMenuPlugin<ResourceMentionOption>
|
||||
|
||||
@@ -2,6 +2,15 @@ import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { requestChatPromptPolish } from './chatPromptPolish';
|
||||
|
||||
/**
|
||||
* 回包与原文一字不差时的提示语。
|
||||
*
|
||||
* 平台侧有时会把同一句话原样还回来(AGC-004:快速编辑里点「AI 润色」后文案毫无变化,
|
||||
* 用户以为按钮没反应或已经改过)。这种情况必须给一条说得清的提示,而且**不能**落下
|
||||
* 原文快照——没有可回退的变化,就不该冒出「恢复原文」这种假入口。
|
||||
*/
|
||||
export const PROMPT_POLISH_UNCHANGED_NOTICE = 'AI 润色结果与原文相同,未做修改';
|
||||
|
||||
/**
|
||||
* 润色回填前的规范化结果。
|
||||
*
|
||||
@@ -41,14 +50,19 @@ export type UsePromptPolishOptions = {
|
||||
export type UsePromptPolishResult = {
|
||||
polishing: boolean;
|
||||
error: string | null;
|
||||
/** 最近一次成功回填的截断提示;没有截断时为 null。 */
|
||||
/** 最近一次成功回包的提示:截断说明,或「与原文相同,未做修改」;两者都没有时为 null。 */
|
||||
notice: string | null;
|
||||
/** 首次成功润色时落下的原文快照;非空时宿主渲染「恢复原文」。 */
|
||||
/**
|
||||
* 首次**真的改动了文本**的润色时落下的原文快照;非空时宿主渲染「恢复原文」。
|
||||
* 回包与原文相同的那些次不落快照(没什么可恢复的)。
|
||||
*/
|
||||
originalText: string | null;
|
||||
/** 润色并在成功时回填,返回回填后的文本;失败返回 null 并保留原文。 */
|
||||
polish: (options?: PromptPolishRunOptions) => Promise<string | null>;
|
||||
restoreOriginal: () => void;
|
||||
clearError: () => void;
|
||||
/** 用户自己改了提示词:把上一轮往返留下的提示(截断 / 与原文相同)收掉。 */
|
||||
clearNotice: () => void;
|
||||
/** 清掉往返状态(草稿清空、面板换资源时用)。 */
|
||||
reset: () => void;
|
||||
};
|
||||
@@ -56,6 +70,10 @@ export type UsePromptPolishResult = {
|
||||
/**
|
||||
* 提示词润色的状态机:失败保留原文、首次成功落原文快照、反复润色只覆盖结果。
|
||||
*
|
||||
* 成功但回包与原文逐字相同(规范化前后都没变)时不算「润色出了新东西」:不落原文快照、
|
||||
* 不回填宿主状态,只给 {@link PROMPT_POLISH_UNCHANGED_NOTICE} 这条明确反馈,
|
||||
* 免得用户把「按钮点了没反应」误当成功能坏了、或者以为文案已经被改过。
|
||||
*
|
||||
* 聊天输入区与资源侧(生成素材 / 快速编辑)共用这一份;它与界面无关,也不碰共享组件,
|
||||
* 宿主自己决定文本存在哪里、怎么回填。Tauri 调用固定在 AGC 侧
|
||||
* (`requestChatPromptPolish`),共享 composer 只接一个可选的注入位。
|
||||
@@ -108,6 +126,18 @@ export function usePromptPolish({
|
||||
const normalized = normalizeResult
|
||||
? normalizeResult(polished)
|
||||
: { text: polished };
|
||||
if (normalized.text === prompt) {
|
||||
// 最终要写回宿主的文本与当前文本逐字相同:既没有新内容可回填,也没有可回退的
|
||||
// 变化,因此不落原文快照、不写宿主状态——`applyPrompt` 在宿主侧还有「提示词变了
|
||||
// 就重铸请求身份」这类副作用,回填一份没变的文本会平白作废一次请求身份。
|
||||
//
|
||||
// 判据必须是**规范化之后**的文本,不能只看回包:润色结果被按长度上限截回原文时
|
||||
// (`truncateResourceEditPrompt` 是切片不是 trim),回包与原文不同、写回去却一字
|
||||
// 未变,那会渲染出一枚点了等于没点的「恢复原文」,正是要消灭的那种假入口。
|
||||
// 截断提示优先——它解释的是「这次为什么没变」。
|
||||
setNotice(normalized.notice ?? PROMPT_POLISH_UNCHANGED_NOTICE);
|
||||
return normalized.text;
|
||||
}
|
||||
// 原文快照只在第一次成功润色时落下,因此「恢复原文」永远回到最初原文。
|
||||
setOriginalText((current) => current ?? prompt);
|
||||
setNotice(normalized.notice ?? null);
|
||||
@@ -150,6 +180,10 @@ export function usePromptPolish({
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const clearNotice = useCallback(() => {
|
||||
setNotice(null);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
// 作废在飞请求:宿主清空草稿 / 换资源后,迟到的润色结果不许再回填。
|
||||
requestIdRef.current += 1;
|
||||
@@ -166,6 +200,7 @@ export function usePromptPolish({
|
||||
polish,
|
||||
restoreOriginal,
|
||||
clearError,
|
||||
clearNotice,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
+46
-31
@@ -414,7 +414,13 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
onChange={(event) => setAssetName(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{/*
|
||||
这一格**不能**用 `<label>` 包:`<label>` 会把点击转发给内部第一个可标注控件,而这一格里
|
||||
第一个可标注控件是引用输入区的「插入素材引用」按钮——于是点输入框任意位置都会弹出素材
|
||||
选择框(客户端验收现场那条)。两边的可访问名都由控件自身的 `aria-label` 提供(引用输入区
|
||||
的 `ariaLabel` 与 `PlatformTextField` 的 `aria-label`),不依赖 label 关联。
|
||||
*/}
|
||||
<div className="resource-canvas-asset-generation-prompt-field">
|
||||
<span>生成提示词</span>
|
||||
{referenceEnabled ? (
|
||||
/*
|
||||
@@ -433,7 +439,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
versions={versions}
|
||||
activeVersionId={activeVersionId}
|
||||
multiline
|
||||
rows={6}
|
||||
rows={3}
|
||||
placeholder={`${action.promptPlaceholder}(可用 @ 选择参考图)`}
|
||||
showPolishAction={false}
|
||||
/>
|
||||
@@ -442,7 +448,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
rows={6}
|
||||
rows={3}
|
||||
autoFocus
|
||||
maxLength={promptMaxLength}
|
||||
placeholder={action.promptPlaceholder}
|
||||
@@ -450,7 +456,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
onChange={(event) => setPrompt(event.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{action.adjustableDimensions ? (
|
||||
<div className="resource-canvas-asset-generation-dimensions">
|
||||
<PlatformSegmentedTabs
|
||||
@@ -488,22 +494,6 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
{resolveEditorImageSizeLabel({ aspectRatio, imageSize })}
|
||||
</span>
|
||||
)}
|
||||
<ResourcePromptPolishSlot
|
||||
subject={`素材生成提示词(${action.label})`}
|
||||
editKind="image-reference"
|
||||
prompt={prompt}
|
||||
applyPrompt={setPrompt}
|
||||
/>
|
||||
{referenceEnabled && referenceLimit > 0 ? (
|
||||
<p
|
||||
className="resource-canvas-asset-generation-reference-hint"
|
||||
data-resource-canvas-generation-reference-count={
|
||||
referenceAssetIds.length
|
||||
}
|
||||
>
|
||||
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
|
||||
</p>
|
||||
) : null}
|
||||
{referenceProblemNotice ? (
|
||||
<p
|
||||
className="game-resource-generation-error"
|
||||
@@ -525,18 +515,43 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
{shownError}
|
||||
</p>
|
||||
) : null}
|
||||
{/*
|
||||
动作行收口:润色入口、参考图计数与两个按钮挤在同一行。
|
||||
|
||||
原先「润色一行、参考图计数一行、按钮一行」把面板顶到必须滚动(验收现场截图里那条
|
||||
滚动条),而这三块内容都没有独占一行的必要:润色作用在提示词上、计数是提示词的从属
|
||||
信息、按钮是收尾动作。放一行之后面板高度回落到几何上界以内,滚动条物理上不再出现。
|
||||
*/}
|
||||
<div className="game-resource-generation-actions">
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton type="submit" disabled={!canSubmit}>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
{action.label}
|
||||
</PlatformActionButton>
|
||||
<ResourcePromptPolishSlot
|
||||
subject={`素材生成提示词(${action.label})`}
|
||||
editKind="image-reference"
|
||||
prompt={prompt}
|
||||
applyPrompt={setPrompt}
|
||||
/>
|
||||
{referenceEnabled && referenceLimit > 0 ? (
|
||||
<p
|
||||
className="resource-canvas-asset-generation-reference-hint"
|
||||
data-resource-canvas-generation-reference-count={
|
||||
referenceAssetIds.length
|
||||
}
|
||||
>
|
||||
{`参考图 ${referenceAssetIds.length}/${referenceLimit}`}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="game-resource-generation-actions-buttons">
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton type="submit" disabled={!canSubmit}>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
{action.label}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
+171
-16
@@ -12,6 +12,12 @@ import {
|
||||
resourceCanvasAssetGenerationTaskTone,
|
||||
sortResourceCanvasAssetGenerationTasks,
|
||||
} from './resourceCanvasAssetGenerationTaskModel';
|
||||
import {
|
||||
resourceCanvasResourceEditElapsedLabel,
|
||||
type ResourceCanvasResourceEditTask,
|
||||
resourceCanvasResourceEditTaskElapsedMillis,
|
||||
resourceCanvasResourceEditTaskIsTerminal,
|
||||
} from './resourceCanvasResourceEditTaskModel';
|
||||
|
||||
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
|
||||
@@ -24,6 +30,14 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160;
|
||||
|
||||
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[];
|
||||
/**
|
||||
* 派生/修改类任务(快速编辑、生成动画、抠图…)。
|
||||
*
|
||||
* 它们不在图片类生成任务的账本里(原生资源编辑账本按 `operationId` 记),但用户眼里都是
|
||||
* 「我交出去、等着出结果的那件事」,所以进同一个侧栏、同一套分栏;状态与阶段文案各按自己的
|
||||
* 账本渲染。缺省为空数组:老调用方不传就没有这一段。
|
||||
*/
|
||||
resourceEditTasks?: readonly ResourceCanvasResourceEditTask[];
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
@@ -31,7 +45,53 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
|
||||
};
|
||||
|
||||
function taskRow(
|
||||
/**
|
||||
* 侧栏的一行。
|
||||
*
|
||||
* 两种来源合并成同一条列表:图片类生成任务按后端账本推进,派生/修改任务按原生资源编辑账本
|
||||
* 推进;排序与分栏只认「提交时间」和「是否终态」,用户不需要知道它们来自两套账本。
|
||||
*/
|
||||
type ResourceCanvasGenerationTaskRow =
|
||||
| {
|
||||
readonly source: 'asset-generation';
|
||||
readonly createdAtMillis: number;
|
||||
readonly task: ResourceCanvasAssetGenerationTask;
|
||||
}
|
||||
| {
|
||||
readonly source: 'resource-edit';
|
||||
readonly createdAtMillis: number;
|
||||
readonly task: ResourceCanvasResourceEditTask;
|
||||
};
|
||||
|
||||
function resourceCanvasGenerationTaskRowKey(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
): string {
|
||||
return row.source === 'asset-generation'
|
||||
? row.task.taskId
|
||||
: row.task.operationId;
|
||||
}
|
||||
|
||||
function resourceCanvasGenerationTaskRowIsTerminal(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
): boolean {
|
||||
return row.source === 'asset-generation'
|
||||
? resourceCanvasAssetGenerationTaskIsTerminal(row.task)
|
||||
: resourceCanvasResourceEditTaskIsTerminal(row.task);
|
||||
}
|
||||
|
||||
function sortResourceCanvasGenerationTaskRows(
|
||||
rows: readonly ResourceCanvasGenerationTaskRow[],
|
||||
): ResourceCanvasGenerationTaskRow[] {
|
||||
return [...rows].sort(
|
||||
(left, right) =>
|
||||
right.createdAtMillis - left.createdAtMillis ||
|
||||
resourceCanvasGenerationTaskRowKey(left).localeCompare(
|
||||
resourceCanvasGenerationTaskRowKey(right),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function assetGenerationTaskRow(
|
||||
task: ResourceCanvasAssetGenerationTask,
|
||||
nowMillis: number,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
@@ -86,6 +146,79 @@ function taskRow(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生/修改任务的一行。
|
||||
*
|
||||
* 与图片类生成的卡片同一套 class、同一套 tone,只有两点不同:① 没有「定位到素材」——原生账本
|
||||
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
|
||||
* 这行是哪一次修改。
|
||||
*/
|
||||
function resourceEditTaskRow(
|
||||
task: ResourceCanvasResourceEditTask,
|
||||
nowMillis: number,
|
||||
) {
|
||||
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
|
||||
task,
|
||||
nowMillis,
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={task.operationId}
|
||||
className="game-resource-generation-task-card"
|
||||
data-task-status={task.status}
|
||||
data-task-source="resource-edit"
|
||||
>
|
||||
<div className="game-resource-generation-task-card-title-row">
|
||||
<strong className="game-resource-generation-task-card-name">
|
||||
{task.assetName}
|
||||
</strong>
|
||||
<span className="game-resource-generation-task-card-action">
|
||||
{task.actionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-resource-generation-task-card-meta">
|
||||
<span
|
||||
className="game-resource-generation-task-badge"
|
||||
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
|
||||
>
|
||||
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{task.error ? (
|
||||
<p className="game-resource-generation-task-card-error" role="alert">
|
||||
{task.error}
|
||||
</p>
|
||||
) : null}
|
||||
{task.prompt ? (
|
||||
<p
|
||||
className="game-resource-generation-task-card-prompt"
|
||||
title={task.prompt}
|
||||
>
|
||||
{task.prompt}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function resourceCanvasGenerationTaskRowNode(
|
||||
row: ResourceCanvasGenerationTaskRow,
|
||||
nowMillis: number,
|
||||
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
|
||||
) {
|
||||
return row.source === 'asset-generation'
|
||||
? assetGenerationTaskRow(row.task, nowMillis, onFocusTask)
|
||||
: resourceEditTaskRow(row.task, nowMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
|
||||
*
|
||||
@@ -103,6 +236,7 @@ function taskRow(
|
||||
*/
|
||||
export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
tasks,
|
||||
resourceEditTasks = [],
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusTask,
|
||||
@@ -115,20 +249,33 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>(
|
||||
open ? 'entering' : 'idle',
|
||||
);
|
||||
const inFlightCount = tasks.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
).length;
|
||||
const hasLiveTask = inFlightCount > 0;
|
||||
const ordered = useMemo(
|
||||
() => sortResourceCanvasAssetGenerationTasks(tasks),
|
||||
[tasks],
|
||||
/**
|
||||
* 两套账本合成一条列表:图片类生成任务(后端生成账本)在前端本地队列里已按提交时间排序,
|
||||
* 派生/修改任务(原生资源编辑账本)自带提交时间,这里统一按时间倒序,用户看到的就是
|
||||
* 「我最近交出去的那几件事」。
|
||||
*/
|
||||
const ordered = useMemo<ResourceCanvasGenerationTaskRow[]>(
|
||||
() =>
|
||||
sortResourceCanvasGenerationTaskRows([
|
||||
...sortResourceCanvasAssetGenerationTasks(tasks).map((task) => ({
|
||||
source: 'asset-generation' as const,
|
||||
createdAtMillis: task.createdAtMillis,
|
||||
task,
|
||||
})),
|
||||
...resourceEditTasks.map((task) => ({
|
||||
source: 'resource-edit' as const,
|
||||
createdAtMillis: task.createdAtMillis,
|
||||
task,
|
||||
})),
|
||||
]),
|
||||
[resourceEditTasks, tasks],
|
||||
);
|
||||
const active = ordered.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
);
|
||||
const done = ordered.filter((task) =>
|
||||
resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
(row) => !resourceCanvasGenerationTaskRowIsTerminal(row),
|
||||
);
|
||||
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,
|
||||
@@ -248,8 +395,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
</p>
|
||||
) : (
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{rendered.active.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
{rendered.active.map((row) =>
|
||||
resourceCanvasGenerationTaskRowNode(
|
||||
row,
|
||||
nowMillis,
|
||||
onFocusTask,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
@@ -271,8 +422,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
) : (
|
||||
<>
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{rendered.visibleDone.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
{rendered.visibleDone.map((row) =>
|
||||
resourceCanvasGenerationTaskRowNode(
|
||||
row,
|
||||
nowMillis,
|
||||
onFocusTask,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
{rendered.done.length > rendered.visibleDone.length ? (
|
||||
|
||||
+26
-1
@@ -1,4 +1,5 @@
|
||||
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LocalProjectResourceEditKind,
|
||||
@@ -30,14 +31,38 @@ export function ResourcePromptPolishSlot({
|
||||
disabled = false,
|
||||
applyPrompt,
|
||||
}: ResourcePromptPolishSlotProps) {
|
||||
/**
|
||||
* 我们自己写回去的那一份文本。用于区分「润色回填导致的 prop 变化」与「用户手改」:
|
||||
* 只有后者该把上一轮往返留下的提示收掉,否则刚显示出来的提示会被自己的回填清掉。
|
||||
*/
|
||||
const appliedPromptRef = useRef<string | null>(null);
|
||||
// 宿主回调存 ref:包一层只为记账,不该让这份包装每次渲染都换身份、
|
||||
// 进而把 `usePromptPolish` 的 `polish` 也跟着重造。
|
||||
const applyPromptRef = useRef(applyPrompt);
|
||||
applyPromptRef.current = applyPrompt;
|
||||
const applyPromptAndTrack = useCallback((text: string) => {
|
||||
appliedPromptRef.current = text;
|
||||
applyPromptRef.current(text);
|
||||
}, []);
|
||||
const polish = usePromptPolish({
|
||||
readPrompt: () => prompt,
|
||||
applyPrompt,
|
||||
applyPrompt: applyPromptAndTrack,
|
||||
canPolish: () => !disabled,
|
||||
resolveContext: () => resourceAssetPromptPolishContext(subject),
|
||||
normalizeResult: (polished) =>
|
||||
truncateResourceEditPrompt(polished, editKind),
|
||||
});
|
||||
const { clearNotice } = polish;
|
||||
useEffect(() => {
|
||||
if (
|
||||
appliedPromptRef.current !== null &&
|
||||
prompt === appliedPromptRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 手改过提示词:上一轮「与原文相同 / 已截断」的结论不再描述当前这段文本。
|
||||
clearNotice();
|
||||
}, [clearNotice, prompt]);
|
||||
const statusText = polish.polishing
|
||||
? '润色中…'
|
||||
: (polish.error ?? polish.notice);
|
||||
|
||||
+11
@@ -326,6 +326,17 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 派生/修改任务多一行提示词:用户要能认出这行是哪一次修改(图片类生成任务没有这一行)。 */
|
||||
.game-resource-generation-task-card-prompt {
|
||||
overflow: hidden;
|
||||
color: var(--platform-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-style: italic;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-generation-task-locate {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
|
||||
@@ -1408,3 +1408,437 @@
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/*
|
||||
* 角色动画生成面板(共享 `ImageCanvasCharacterAnimationPanelView`)与它的素材缩略图。
|
||||
*
|
||||
* 同一处样式缺口:`image-canvas-editor__character-animation-*`、`__generation-close` 与
|
||||
* `__reference-strip/__reference-slot/__reference-chip*` 在网页端由整站表 `src/index.css` 提供,
|
||||
* 而 AGC 不引那张表、宿主 chrome 表又只照抄了 composer 的通用规则。结果是这个面板只剩通用
|
||||
* composer 外观:素材缩略图、预设胶囊、提交按钮与右上角关闭键全部落回默认流布局,面板里出现
|
||||
* 一大块空白、预设竖排、`生成80泥点` 悬在中间(客户端验收现场截图)。
|
||||
*
|
||||
* 下面逐条照抄 `src/index.css` 的同名规则(注释里给行号),不手写近似值;AGC 不会渲染的兄弟
|
||||
* 选择器不搬(例如 `.image-canvas-editor__character-animation-head` 在这版组件里没有节点)。
|
||||
*/
|
||||
|
||||
/* src/index.css:6776 */
|
||||
.image-canvas-editor__generation-composer--character-animation {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto auto auto;
|
||||
width: min(42rem, calc(100% - 1.5rem));
|
||||
}
|
||||
|
||||
/* src/index.css:6874 */
|
||||
.image-canvas-editor__generation-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* src/index.css:6906 */
|
||||
.image-canvas-editor__reference-strip {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.36rem;
|
||||
overflow-x: auto;
|
||||
padding: 0 2.2rem 0.02rem 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* src/index.css:6917 */
|
||||
.image-canvas-editor__reference-slot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 3.95rem;
|
||||
height: 3.95rem;
|
||||
}
|
||||
|
||||
/* src/index.css:6925 */
|
||||
.image-canvas-editor__reference-chip {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
width: 3.95rem;
|
||||
min-width: 3.95rem;
|
||||
max-width: 3.95rem;
|
||||
height: 3.95rem;
|
||||
min-height: 3.95rem;
|
||||
grid-template-rows: 1.42rem auto;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: 0.28rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 1rem;
|
||||
background: #f2f3f5;
|
||||
padding: 0.62rem 0.36rem 0.5rem;
|
||||
color: #a3a8ae;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
line-height: 1.04;
|
||||
text-align: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* src/index.css:6950 */
|
||||
button.image-canvas-editor__reference-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.image-canvas-editor__reference-chip:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: #eef0f2;
|
||||
}
|
||||
|
||||
button.image-canvas-editor__reference-chip:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* src/index.css:6966 */
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
display: grid;
|
||||
width: 1.42rem;
|
||||
height: 1.42rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0.36rem;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.36rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* src/index.css:6985 */
|
||||
.image-canvas-editor__reference-chip-label {
|
||||
display: -webkit-box;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* src/index.css:6996 */
|
||||
.image-canvas-editor__reference-chip-source-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
/* src/index.css:7006 */
|
||||
.image-canvas-editor__reference-chip-remove {
|
||||
position: absolute;
|
||||
top: 0.16rem;
|
||||
right: 0.16rem;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: 1.12rem;
|
||||
height: 1.12rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
transform: scale(0.88);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip-remove--always-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-slot:hover
|
||||
.image-canvas-editor__reference-chip-remove,
|
||||
.image-canvas-editor__reference-slot:focus-within
|
||||
.image-canvas-editor__reference-chip-remove {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.image-canvas-editor__reference-chip-remove {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip-remove:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip-remove:hover:not(:disabled) {
|
||||
background: rgba(220, 38, 38, 0.95);
|
||||
}
|
||||
|
||||
/* src/index.css:7061:带图缩略图的图标尺寸(角色动画的源图就是这一档) */
|
||||
.image-canvas-editor__reference-chip--has-image
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
width: 1.74rem;
|
||||
height: 1.74rem;
|
||||
border-radius: 0.48rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--has-image
|
||||
.image-canvas-editor__reference-chip-icon
|
||||
img {
|
||||
border-radius: 0.48rem;
|
||||
}
|
||||
|
||||
/* src/index.css:7074–7112:tone 取色(AGC 目前渲染 quick-edit 与 character,其余一并有备) */
|
||||
.image-canvas-editor__reference-chip--spec
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--icon
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--ui
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--character
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--quick-edit {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--quick-edit
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--video
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.image-canvas-editor__reference-chip--audio
|
||||
.image-canvas-editor__reference-chip-icon {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* src/index.css:8728 */
|
||||
.image-canvas-editor__character-animation-panel {
|
||||
position: absolute;
|
||||
z-index: 14;
|
||||
display: grid;
|
||||
width: min(42rem, calc(100vw - 1.5rem));
|
||||
max-height: min(34rem, calc(100% - 1.5rem));
|
||||
gap: 0.58rem;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.36);
|
||||
border-radius: 1.1rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 0.76rem;
|
||||
font-size: 0.82rem;
|
||||
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
/* src/index.css:8753 */
|
||||
.image-canvas-editor__character-animation-textarea {
|
||||
grid-column: 1 / -1;
|
||||
--auto-grow-editor-min-height: 8rem;
|
||||
--auto-grow-editor-max-height: 16rem;
|
||||
}
|
||||
|
||||
/* src/index.css:8759 */
|
||||
.image-canvas-editor__character-animation-presets {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__character-animation-preset {
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
padding: 0.34rem 0.58rem;
|
||||
color: #334155;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* src/index.css:8775 */
|
||||
.image-canvas-editor__character-animation-footer {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.46rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__character-animation-submit {
|
||||
grid-column: 3;
|
||||
min-width: 5.45rem;
|
||||
height: 2.25rem;
|
||||
gap: 0.4rem;
|
||||
border: 0;
|
||||
background: var(--image-canvas-brand-fill);
|
||||
color: var(--image-canvas-brand-text-on-fill);
|
||||
}
|
||||
|
||||
/* src/index.css:6811:提示词输入区的聚焦态(快速编辑 / 动画面板共用这一档)。 */
|
||||
.image-canvas-editor__generation-prompt:focus-within,
|
||||
.image-canvas-editor__video-prompt:focus {
|
||||
border-color: var(--image-canvas-brand-border-strong);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px var(--image-canvas-brand-focus-ring);
|
||||
}
|
||||
|
||||
/* src/index.css:6822 */
|
||||
.image-canvas-editor__generation-prompt:has(
|
||||
.auto-grow-text-area__content[aria-invalid='true']
|
||||
) {
|
||||
border-color: #e11d48;
|
||||
background: #fff7f8;
|
||||
}
|
||||
|
||||
/* src/index.css:6829 */
|
||||
.image-canvas-editor__generation-prompt:has(
|
||||
.auto-grow-text-area__content[aria-invalid='true']
|
||||
):focus-within {
|
||||
border-color: #e11d48;
|
||||
box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.14);
|
||||
}
|
||||
|
||||
/*
|
||||
* src/index.css:7630 —— 关闭键在浮层里的**落位**。
|
||||
*
|
||||
* 这一族在网页端有两条同名规则:6874 给基础外观,这条给「贴在浮层右上角」的绝对定位。
|
||||
* 只搬前一条时按钮会掉进网格流、独自占一行并居中(客户端验收现场那张图里的 ×)。
|
||||
*/
|
||||
.image-canvas-editor__generation-close {
|
||||
position: absolute;
|
||||
right: 0.62rem;
|
||||
top: 0.58rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* src/index.css:16988:composer 里的参数簇去掉自身底色,hover / 展开时才浮起。 */
|
||||
.image-canvas-editor__generation-composer .image-canvas-editor__option-cluster {
|
||||
min-height: 2.25rem;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* src/index.css:16994 */
|
||||
.image-canvas-editor__generation-composer
|
||||
.image-canvas-editor__option-cluster:hover:not(:disabled),
|
||||
.image-canvas-editor__generation-composer
|
||||
.image-canvas-editor__option-cluster[aria-expanded='true'] {
|
||||
border-color: rgba(15, 23, 42, 0.08);
|
||||
background: #f8fafc;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
/*
|
||||
* 窄屏整档照抄(src/index.css:8981 的 `@media (max-width: 760px)`):浮层落到底部整宽、
|
||||
* 动作竖排、参数与提交整宽。选择器与断点都按网页端原样搬,避免"我拆一半、你拆一半"之后
|
||||
* 两边再各自漂移。
|
||||
*/
|
||||
@media (max-width: 760px) {
|
||||
/*
|
||||
* src/index.css:9020 —— **只搬动画面板那一支**。
|
||||
*
|
||||
* 网页端这一条把快速编辑浮层与动画面板一起变成 `position: fixed` 的整宽底栏;AGC 的快速编辑
|
||||
* 浮层是「居中贴资源卡」的绝对定位(`transform: translateX(-50%)` + 内联 left/top,见本文件
|
||||
* 上面那一族),窄屏宽度已经由 `calc(100vw - 1.5rem)` 自己收好——跟网页端改成底栏会压住栏目
|
||||
* 画布左下角工具栏与左侧「生成任务」侧栏。这条宿主差异在样式守卫里显式放行,不静默漂移。
|
||||
*/
|
||||
.image-canvas-editor__character-animation-panel {
|
||||
position: fixed;
|
||||
left: 0.75rem !important;
|
||||
right: 0.75rem;
|
||||
top: auto !important;
|
||||
bottom: 0.75rem;
|
||||
width: auto;
|
||||
max-height: min(72vh, 34rem);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* src/index.css:9037 */
|
||||
.image-canvas-editor__generation-composer {
|
||||
position: fixed;
|
||||
left: 0.75rem !important;
|
||||
right: 0.75rem;
|
||||
top: auto !important;
|
||||
bottom: 3.85rem;
|
||||
width: auto;
|
||||
max-height: min(72vh, 31rem);
|
||||
grid-template-columns: 3.55rem minmax(0, 1fr);
|
||||
overflow: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* src/index.css:9050 */
|
||||
.image-canvas-editor__generation-composer-footer,
|
||||
.image-canvas-editor__character-animation-footer {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* src/index.css:9056 */
|
||||
.image-canvas-editor__option-popover-anchor--dimensions,
|
||||
.image-canvas-editor__option-popover-anchor--model {
|
||||
grid-column: auto;
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
/* src/index.css:9063 */
|
||||
.image-canvas-editor__option-cluster--dimensions,
|
||||
.image-canvas-editor__option-cluster--model,
|
||||
.image-canvas-editor__image-style-toggle,
|
||||
.image-canvas-editor__readonly-generation-option,
|
||||
.image-canvas-editor__generation-submit,
|
||||
.image-canvas-editor__character-animation-submit {
|
||||
grid-column: auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-15
@@ -48,9 +48,9 @@ export function isResourceCanvasPanTarget(
|
||||
if (!isResourceCanvasInteractionTarget(target)) return true;
|
||||
return Boolean(
|
||||
target.closest('.game-resource-card') &&
|
||||
!target.closest(
|
||||
'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video',
|
||||
),
|
||||
!target.closest(
|
||||
'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,18 +85,6 @@ export function isResourceCanvasWheelOverlayTarget(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布浮层是否可以被清焦点顺手关掉。
|
||||
*
|
||||
* 对齐美术画布 `closeTransientEditorPanels()`:生成中的面板不关,否则用户看不到
|
||||
* 「修改中」状态,也拿不到失败后的重试入口。信息浮层是只读的,没有生成态。
|
||||
*/
|
||||
export function canDismissResourceCanvasQuickEdit(
|
||||
panel: { readonly status: string } | null | undefined,
|
||||
): boolean {
|
||||
return panel?.status !== 'generating';
|
||||
}
|
||||
|
||||
/** 资源画布上的 AGC 自有弹层:它们开着时画布浮层不参与「点外部关闭」。 */
|
||||
export type ResourceCanvasHostOverlayState = {
|
||||
readonly isResourcePanelOpen: boolean;
|
||||
|
||||
+60
-2
@@ -14,8 +14,9 @@
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
/* 差异只留「虚线 + 更深的描边」(表示还没落地);圆角与浮层卡共用基类 token。 */
|
||||
border: 1px dashed #c9a493;
|
||||
border-radius: 14px;
|
||||
border-radius: var(--game-canvas-card-radius-inner, 14px);
|
||||
background: rgb(255 250 247 / 88%);
|
||||
color: #8a6a5c;
|
||||
text-align: center;
|
||||
@@ -80,14 +81,70 @@
|
||||
/* 锚点给的是占位卡中心:不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。 */
|
||||
transform: translateX(-50%);
|
||||
width: min(560px, calc(100% - 24px));
|
||||
/* 外形走基类 token(= 网页端画布面板那一套),三块浮层因此长得一样。 */
|
||||
border: 1px solid var(--game-canvas-card-border, #e4c8ba);
|
||||
border-radius: var(--game-canvas-card-radius, 18px);
|
||||
background: var(--game-canvas-card-fill, #fffaf7);
|
||||
box-shadow: var(--game-canvas-card-shadow, 0 24px 64px rgb(62 37 27 / 24%));
|
||||
padding: var(--game-canvas-card-padding, 18px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
pointer-events: auto;
|
||||
/* 内联 `maxHeight` 按真实画布底边算;这条只是拿不到几何时的兜底上界。 */
|
||||
/*
|
||||
* 内联 `maxHeight` 按真实画布底边算;这条只是拿不到几何时的兜底上界。
|
||||
*
|
||||
* 面板内容已经压到「一行提示词 + 一行参数 + 一行动作」的紧凑版(见下面那组规则),正常几何下
|
||||
* 根本到不了这里;`overflow` 与这条上界保留为极端窗口下的兜底,而不是日常出现的滚动条。
|
||||
*/
|
||||
max-height: min(560px, calc(100dvh - 160px));
|
||||
}
|
||||
|
||||
/*
|
||||
* 紧凑版:字段行两列(标签在左、控件在右)、比例与尺寸并排、动作用一行收口。
|
||||
*
|
||||
* 验收现场那张图里面板要滚动,是因为「标签各占一行 + 提示词 6 行 + 比例一行 + 尺寸一行 + 润色
|
||||
* 一行 + 参考计数一行」把内容顶到几何上界之外。这里按信息层级重新排:从属信息(标签、计数、
|
||||
* 润色)让位给主体(提示词、参数、动作),面板高度回落到上界以内,滚动条物理上不再出现。
|
||||
*/
|
||||
.game-resource-generation-form {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-resource-generation-form > label,
|
||||
.game-resource-generation-form
|
||||
> .resource-canvas-asset-generation-prompt-field {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.resource-canvas-asset-generation-prompt-field > span,
|
||||
.game-resource-generation-form > label > span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 比例(5 项)与尺寸(3 项)并排:两组都很短,各占半行足够点。 */
|
||||
.resource-canvas-asset-generation-dimensions {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 动作行:润色与参考计数贴左,两个按钮贴右,同一行收口。 */
|
||||
.game-approval-dialog.resource-canvas-generation-floating-panel
|
||||
.game-resource-generation-actions {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.game-resource-generation-actions-buttons {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 提交行固定在浮层底部。
|
||||
*
|
||||
@@ -113,4 +170,5 @@
|
||||
margin: 0;
|
||||
color: #9a7d70;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+172
@@ -1,6 +1,7 @@
|
||||
import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel';
|
||||
import type { ChatReference } from '../project-workspace/resourceReferences';
|
||||
|
||||
/**
|
||||
* AGC 资源卡快速编辑面板的初始草稿。
|
||||
@@ -13,3 +14,174 @@ export function createResourceQuickEditPanelDraft(
|
||||
): QuickEditPanelState {
|
||||
return createQuickEditPanelDraft(sourceLayer, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份还没提交的快速编辑草稿。
|
||||
*
|
||||
* 这是**宿主会话内存**里的一笔记录,不是正式业务状态:不落盘、不进资源编辑账本、
|
||||
* 不参与对账。它存在的唯一理由是面板被点外部 / Esc 收起、或用户切到另一张素材卡之后,
|
||||
* 用户刚写的提示词与 `@` 引用还能原样回到面板里;正式的可恢复事实仍然只由原生
|
||||
* `list_pending_local_project_resource_edits` / `resume_local_project_resource_edit`
|
||||
* 那条既有账本链路负责。
|
||||
*/
|
||||
export type ResourceQuickEditDraft = {
|
||||
/** 提示词原文(含 `@显示名` 字面量,与聊天同源)。 */
|
||||
readonly prompt: string;
|
||||
/** 与提示词配套的 `@` 引用,回填输入区 chip。 */
|
||||
readonly references: readonly ChatReference[];
|
||||
/** 最后一次改动时间(恢复列表按新→旧排序)。 */
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源**路径** → 未提交草稿。
|
||||
*
|
||||
* 键必须是投影的稳定身份,也就是 `ProjectResource.path`:资源投影本来就按 `path` 去重
|
||||
* (`resourceProjectionModel` 的 `uniqueByPath`),而资源 ID 会变——任务产物被正规化登记成
|
||||
* 正式素材后,同一张卡会从 `task:<任务>:<路径>` 变成 `asset:<id>`。拿 ID 当键时,只要重投影
|
||||
* 不是「打开中这一笔快速编辑」自己触发(换素材卡收起面板、Agent 或外部编辑器把同一路径登记成
|
||||
* 资产),草稿就会挂在一个再也点不到的键上:恢复入口按 id 找不到而把它静默过滤,重开面板也按
|
||||
* 新卡 id 查不到,用户刚写的提示词凭空消失。
|
||||
*/
|
||||
export type ResourceQuickEditDraftStore = ReadonlyMap<
|
||||
string,
|
||||
ResourceQuickEditDraft
|
||||
>;
|
||||
|
||||
/**
|
||||
* 空草稿表:只在项目身份变化时用它整体清空。
|
||||
*
|
||||
* 走工厂而不是导出一个共享常量:这个表会直接交给 `useState`,一旦哪天有人改成原地
|
||||
* `set` / `delete`,模块级单例就会跨面板、跨项目串味。每次调用都拿到一张新表。
|
||||
*/
|
||||
export function createEmptyResourceQuickEditDraftStore(): ResourceQuickEditDraftStore {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/** 提示词与引用都空才算「没有草稿」,不留一条空记录去占用恢复入口。 */
|
||||
export function isResourceQuickEditDraftEmpty(draft: {
|
||||
readonly prompt: string;
|
||||
readonly references: readonly unknown[];
|
||||
}): boolean {
|
||||
return draft.prompt.trim().length === 0 && draft.references.length === 0;
|
||||
}
|
||||
|
||||
export function readResourceQuickEditDraft(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
key: string | null | undefined,
|
||||
): ResourceQuickEditDraft | undefined {
|
||||
return key ? store.get(key) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一笔草稿补丁。
|
||||
*
|
||||
* - 只给 `prompt` 或只给 `references` 时,另一头沿用该键下既有记录(输入区两条
|
||||
* 状态各自上报,谁都不该把对方清掉);
|
||||
* - 合并结果为空就不留记录,避免恢复入口里堆一条点开空面板的条目。
|
||||
*
|
||||
* 没有「换键」这一步:键是资源路径,正规化把卡片换成 `asset:…` 也不改路径,草稿不需要
|
||||
* 跟着投影搬家(换键逻辑本身正是「搬家搬丢了」的来源)。
|
||||
*/
|
||||
export function writeResourceQuickEditDraft(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
input: {
|
||||
readonly key: string;
|
||||
readonly prompt?: string;
|
||||
readonly references?: readonly ChatReference[];
|
||||
readonly updatedAt: number;
|
||||
},
|
||||
): ResourceQuickEditDraftStore {
|
||||
const current = store.get(input.key);
|
||||
const next: ResourceQuickEditDraft = {
|
||||
prompt: input.prompt ?? current?.prompt ?? '',
|
||||
references: input.references ?? current?.references ?? [],
|
||||
updatedAt: input.updatedAt,
|
||||
};
|
||||
const result = new Map(store);
|
||||
if (isResourceQuickEditDraftEmpty(next)) {
|
||||
result.delete(input.key);
|
||||
return result;
|
||||
}
|
||||
result.set(input.key, next);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 恢复入口里的一行未提交草稿。 */
|
||||
export type ResourceQuickEditDraftEntry = {
|
||||
/** 资源路径——`writeResourceQuickEditDraft` 的键,也是回填 / 丢弃时要用的键。 */
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly prompt: string;
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 恢复入口里的未提交草稿:只列**此刻还在当前投影里**的素材(按路径匹配,与卡片 id 无关),
|
||||
* 按最后改动时间从新到旧。
|
||||
*
|
||||
* 资源已经不在项目里的草稿没有可继续的落点,不该继续占着入口;但表里那份记录不在这里删,
|
||||
* 删了就分不清「资源暂时不可见」和「用户已经不要这笔草稿」——由「继续编辑」与用户显式
|
||||
* 丢弃两处负责清理。
|
||||
*/
|
||||
export function listResourceQuickEditDraftEntries(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
resources: readonly {
|
||||
readonly path: string;
|
||||
readonly label: string;
|
||||
}[],
|
||||
): ResourceQuickEditDraftEntry[] {
|
||||
const labelByPath = new Map(
|
||||
resources.map((resource) => [resource.path, resource.label] as const),
|
||||
);
|
||||
const entries: ResourceQuickEditDraftEntry[] = [];
|
||||
for (const [key, draft] of store) {
|
||||
const label = labelByPath.get(key);
|
||||
if (label === undefined) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
label,
|
||||
prompt: draft.prompt,
|
||||
updatedAt: draft.updatedAt,
|
||||
});
|
||||
}
|
||||
return entries.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
}
|
||||
|
||||
/** 成功提交 / 用户显式丢弃后抹掉这笔草稿。 */
|
||||
export function dropResourceQuickEditDraft(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
key: string | null | undefined,
|
||||
): ResourceQuickEditDraftStore {
|
||||
if (!key || !store.has(key)) {
|
||||
return store;
|
||||
}
|
||||
const result = new Map(store);
|
||||
result.delete(key);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把记录下来的草稿盖回刚造出来的面板草稿:模型 / 比例 / 尺寸等仍取当下的默认值,
|
||||
* 只有用户真正改过的提示词与引用回填,避免用一份过期面板把当前默认档位也一起还原。
|
||||
*/
|
||||
export function restoreResourceQuickEditPanelDraft(
|
||||
panelDraft: QuickEditPanelState,
|
||||
draft: ResourceQuickEditDraft | undefined,
|
||||
): QuickEditPanelState {
|
||||
return draft ? { ...panelDraft, prompt: draft.prompt } : panelDraft;
|
||||
}
|
||||
|
||||
/** 恢复入口里的提示词摘要:只做一行展示,不改变原草稿内容。 */
|
||||
export function resourceQuickEditDraftPromptExcerpt(
|
||||
prompt: string,
|
||||
limit = 48,
|
||||
): string {
|
||||
const text = prompt.trim().replace(/\s+/gu, ' ');
|
||||
if (!text) {
|
||||
return '(尚未填写提示词)';
|
||||
}
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
||||
}
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
|
||||
import type { ResourceCanvasAssetGenerationTaskStatus } from './resourceCanvasAssetGenerationTaskModel';
|
||||
|
||||
/**
|
||||
* 原生资源编辑账本里的一条**未收口**操作(Rust `list_pending_local_project_resource_edits`)。
|
||||
*
|
||||
* `phase` 由 Rust 的 `ResourceEditLedgerPhase` 拥有:前端只做「阶段 → 面板状态」的映射与文案渲染,
|
||||
* 不自己发明阶段,也不从别的字段反推进度。
|
||||
*/
|
||||
export type PendingLocalProjectResourceEdit = {
|
||||
operationId: string;
|
||||
editKind: string;
|
||||
sourceResourceId: string;
|
||||
assetName: string;
|
||||
backgroundMode?: string | null;
|
||||
screenColor?: string | null;
|
||||
phase: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本会话内**由本进程发起**的一次派生/修改提交。
|
||||
*
|
||||
* 原生账本要等 Rust 建好 ledger 才读得到,而「点了生成」到那一刻之间界面上一片空白;这份
|
||||
* 本地记录就是补那一段的:提交当帧就进「生成任务」侧栏,收口时按终态改写。它与原生待办
|
||||
* 列表按 `operationId` 合并,同一条不会出现两遍。
|
||||
*/
|
||||
export type ResourceCanvasResourceEditSubmission = {
|
||||
operationId: string;
|
||||
/** 输入区里那句需求,收进侧栏当副标题(与聊天 `@` 同源的原文)。 */
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
/** 入口文案:快速编辑 / 生成动画 / 图片抠图…,由 `pendingResourceEditKindLabel` 出。 */
|
||||
actionLabel: string;
|
||||
/**
|
||||
* 这笔提交属于哪张资源(资源路径,见 `ProjectResource.path`);无源入口(视频 / 音效 /
|
||||
* 背景音乐)为 `null`。
|
||||
*
|
||||
* 两个用途:① 快速编辑提交期间,这张资源的未提交草稿不再算「未完成编辑」——用户已经交出去了,
|
||||
* 恢复入口里不该再多一条点开就重复提交的条目(见 `resourceCanvasQuickEditModel`);② 同一张
|
||||
* 资源还有在途提交时,快速编辑与生成动画都不允许再铸一次身份(见
|
||||
* `resourceCanvasResourceEditSubmissionBlocksResource`)。
|
||||
*/
|
||||
resourcePath: string | null;
|
||||
createdAtMillis: number;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
error: string | null;
|
||||
assetId: string | null;
|
||||
finishedAtMillis: number | null;
|
||||
};
|
||||
|
||||
/** 「生成任务」侧栏里的一条派生/修改任务:原生阶段 + 本地提交记录合并后的呈现单元。 */
|
||||
export type ResourceCanvasResourceEditTask = {
|
||||
operationId: string;
|
||||
assetName: string;
|
||||
actionLabel: string;
|
||||
prompt: string;
|
||||
status: ResourceCanvasAssetGenerationTaskStatus;
|
||||
phaseDetail: string;
|
||||
createdAtMillis: number;
|
||||
finishedAtMillis: number | null;
|
||||
error: string | null;
|
||||
assetId: string | null;
|
||||
/** 从原生账本恢复、本地没有对应提交记录的那一条:耗时不可知,面板不编时间。 */
|
||||
restored: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 原生阶段 → 面板文案。
|
||||
*
|
||||
* 键逐字对应 Rust `ResourceEditLedgerPhase::as_str()`;措辞沿用恢复入口(「管理未完成编辑」)里
|
||||
* 已经在用的那一套——同一条操作在两处必须叫同一个名字,否则用户会以为是两件事。未知阶段原样
|
||||
* 透出阶段名,排障时能一眼看出前端还没跟上的新档,而不是显示一句编造的进度。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS: Record<
|
||||
string,
|
||||
string
|
||||
> = {
|
||||
prepared: '原请求已冻结',
|
||||
accepted: '远程已受理',
|
||||
'remote-completed': '远程已完成',
|
||||
'media-downloaded': '结果已下载',
|
||||
'remote-failed': '远程明确失败',
|
||||
'reconciliation-required': '需要人工对账',
|
||||
committed: '已写入项目',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
/** 本会话内刚提交、原生账本还没读回来的那一段自述文案。 */
|
||||
export const RESOURCE_CANVAS_RESOURCE_EDIT_SUBMITTED_PHASE =
|
||||
'已提交,等待远端受理';
|
||||
/** 提交成功、本地已经拿到结果的收口文案。 */
|
||||
export const RESOURCE_CANVAS_RESOURCE_EDIT_COMPLETED_PHASE = '已产出新素材';
|
||||
|
||||
/**
|
||||
* `editKind` → 入口文案。
|
||||
*
|
||||
* 与恢复入口(`list_pending_local_project_resource_edits` 那张列表)共用同一个映射:同一条操作
|
||||
* 在两处显示的入口名必须一致,否则用户会以为它们是两件事。
|
||||
*/
|
||||
export function pendingResourceEditKindLabel({
|
||||
editKind,
|
||||
backgroundMode,
|
||||
screenColor,
|
||||
}: Pick<
|
||||
PendingLocalProjectResourceEdit,
|
||||
'editKind' | 'backgroundMode' | 'screenColor'
|
||||
>) {
|
||||
if (editKind === 'image') return '图片编辑';
|
||||
if (editKind === 'background-removal') {
|
||||
if (backgroundMode === 'complex') return '图片抠图 · 复杂背景';
|
||||
if (backgroundMode === 'flat') {
|
||||
const color = screenColor === 'auto' ? '自动背景色' : screenColor;
|
||||
return `图片抠图 · 平面背景${color ? ` · ${color}` : ''}`;
|
||||
}
|
||||
return '图片抠图';
|
||||
}
|
||||
if (editKind === 'text') return '文本编辑';
|
||||
if (editKind === 'agent-result') return '智能体结果编辑';
|
||||
if (editKind === 'image-reference') return '快速编辑';
|
||||
if (editKind === 'character-animation') return '生成动画';
|
||||
if (editKind === 'sound-effect') return '生成音效';
|
||||
if (editKind === 'background-music') return '生成背景音乐';
|
||||
if (editKind === 'video') return '生成视频';
|
||||
return '资源编辑';
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生阶段 → 面板状态。
|
||||
*
|
||||
* `prepared` 是「账本已建、远端请求还没发出」的本地排队档,映射成 `queued` 让面板把它和
|
||||
* `running` 一起放进「排队/生成中」分栏;两个真失败档(远端失败、需要人工对账)映射成
|
||||
* `failed`,用户才有可点的定位与可见的失败原因。
|
||||
*/
|
||||
export function resourceCanvasResourceEditTaskStatusFromPhase(
|
||||
phase: string,
|
||||
): ResourceCanvasAssetGenerationTaskStatus {
|
||||
switch (phase) {
|
||||
case 'prepared':
|
||||
return 'queued';
|
||||
case 'remote-failed':
|
||||
case 'reconciliation-required':
|
||||
return 'failed';
|
||||
case 'committed':
|
||||
case 'archived':
|
||||
return 'completed';
|
||||
default:
|
||||
return 'running';
|
||||
}
|
||||
}
|
||||
|
||||
/** 未知阶段不编文案:原样显示阶段名,排障时能一眼看出前端还没跟上的档。 */
|
||||
export function resourceCanvasResourceEditPhaseLabel(phase: string): string {
|
||||
return RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS[phase] ?? phase;
|
||||
}
|
||||
|
||||
export function resourceCanvasResourceEditTaskIsTerminal(
|
||||
task: Pick<ResourceCanvasResourceEditTask, 'status'>,
|
||||
): boolean {
|
||||
return task.status === 'completed' || task.status === 'failed';
|
||||
}
|
||||
|
||||
export function resourceCanvasResourceEditSubmissionIsLive(
|
||||
submission: Pick<ResourceCanvasResourceEditSubmission, 'status'>,
|
||||
): boolean {
|
||||
return submission.status === 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* 「这张资源上还有一笔没结束的提交」。
|
||||
*
|
||||
* 快速编辑(顺带把该资源的草稿从「未完成编辑」里让位)与生成动画共用同一条判据:提交即关面板
|
||||
* 之后,「重开面板再提交」是唯一的重试路径,不拦就会出现两条并发提交——提示词没变时第二次会卡在
|
||||
* 原生 per-operation 锁上、最后以阶段不可恢复报错;改了提示词则直接多出一笔付费生成。
|
||||
*/
|
||||
export function resourceCanvasResourceEditSubmissionBlocksResource(
|
||||
submissions: readonly ResourceCanvasResourceEditSubmission[],
|
||||
resourcePath: string,
|
||||
): boolean {
|
||||
return submissions.some(
|
||||
(submission) =>
|
||||
submission.resourcePath === resourcePath &&
|
||||
resourceCanvasResourceEditSubmissionIsLive(submission),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记一笔刚发生的提交。
|
||||
*
|
||||
* 按 `operationId` 覆盖写入:同一笔重试(身份复用)不该在侧栏里堆出第二条;换了身份则是新的一笔,
|
||||
* 两条并存本来就是用户看到的真相。
|
||||
*/
|
||||
export function beginResourceCanvasResourceEditSubmission(
|
||||
submissions: readonly ResourceCanvasResourceEditSubmission[],
|
||||
input: Omit<
|
||||
ResourceCanvasResourceEditSubmission,
|
||||
'status' | 'error' | 'assetId' | 'finishedAtMillis'
|
||||
>,
|
||||
): ResourceCanvasResourceEditSubmission[] {
|
||||
const next: ResourceCanvasResourceEditSubmission = {
|
||||
...input,
|
||||
status: 'running',
|
||||
error: null,
|
||||
assetId: null,
|
||||
finishedAtMillis: null,
|
||||
};
|
||||
return [
|
||||
...submissions.filter(
|
||||
(submission) => submission.operationId !== input.operationId,
|
||||
),
|
||||
next,
|
||||
];
|
||||
}
|
||||
|
||||
/** 收口一笔提交:成功给产物 id,失败给原因;两者都写结束时间,侧栏据此停表。 */
|
||||
export function settleResourceCanvasResourceEditSubmission(
|
||||
submissions: readonly ResourceCanvasResourceEditSubmission[],
|
||||
operationId: string,
|
||||
result:
|
||||
| { status: 'completed'; assetId: string | null; finishedAtMillis: number }
|
||||
| { status: 'failed'; error: string; finishedAtMillis: number },
|
||||
): ResourceCanvasResourceEditSubmission[] {
|
||||
return submissions.map((submission) =>
|
||||
submission.operationId === operationId
|
||||
? result.status === 'completed'
|
||||
? {
|
||||
...submission,
|
||||
status: 'completed',
|
||||
assetId: result.assetId,
|
||||
error: null,
|
||||
finishedAtMillis: result.finishedAtMillis,
|
||||
}
|
||||
: {
|
||||
...submission,
|
||||
status: 'failed',
|
||||
assetId: null,
|
||||
error: result.error,
|
||||
finishedAtMillis: result.finishedAtMillis,
|
||||
}
|
||||
: submission,
|
||||
);
|
||||
}
|
||||
|
||||
function resourceCanvasResourceEditTaskFromPendingEdit(
|
||||
edit: PendingLocalProjectResourceEdit,
|
||||
): ResourceCanvasResourceEditTask {
|
||||
const status = resourceCanvasResourceEditTaskStatusFromPhase(edit.phase);
|
||||
return {
|
||||
operationId: edit.operationId,
|
||||
assetName: edit.assetName,
|
||||
actionLabel: pendingResourceEditKindLabel(edit),
|
||||
prompt: '',
|
||||
status,
|
||||
phaseDetail: resourceCanvasResourceEditPhaseLabel(edit.phase),
|
||||
createdAtMillis: edit.createdAt,
|
||||
finishedAtMillis: null,
|
||||
error:
|
||||
status === 'failed'
|
||||
? `原生账本停留在「${resourceCanvasResourceEditPhaseLabel(edit.phase)}」阶段`
|
||||
: null,
|
||||
assetId: null,
|
||||
restored: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resourceCanvasResourceEditTaskFromSubmission(
|
||||
submission: ResourceCanvasResourceEditSubmission,
|
||||
pending: PendingLocalProjectResourceEdit | undefined,
|
||||
): ResourceCanvasResourceEditTask {
|
||||
const live = resourceCanvasResourceEditSubmissionIsLive(submission);
|
||||
return {
|
||||
operationId: submission.operationId,
|
||||
assetName: submission.assetName,
|
||||
actionLabel: submission.actionLabel,
|
||||
prompt: submission.prompt,
|
||||
status: live
|
||||
? pending
|
||||
? resourceCanvasResourceEditTaskStatusFromPhase(pending.phase)
|
||||
: 'running'
|
||||
: submission.status,
|
||||
// 在途期间以原生阶段为准(它才知道远端走到了哪一步);收口后由本地说结果。
|
||||
phaseDetail: live
|
||||
? pending
|
||||
? resourceCanvasResourceEditPhaseLabel(pending.phase)
|
||||
: RESOURCE_CANVAS_RESOURCE_EDIT_SUBMITTED_PHASE
|
||||
: submission.status === 'failed'
|
||||
? submission.error || '资源修改失败'
|
||||
: RESOURCE_CANVAS_RESOURCE_EDIT_COMPLETED_PHASE,
|
||||
createdAtMillis: submission.createdAtMillis,
|
||||
finishedAtMillis: submission.finishedAtMillis,
|
||||
error: live ? null : submission.error,
|
||||
assetId: submission.assetId,
|
||||
restored: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生待办账本 + 本地提交记录 → 侧栏里的派生/修改任务。
|
||||
*
|
||||
* 两个方向都要覆盖,与图片类生成任务的合并口径一致:本地提交按 `operationId` 用原生阶段刷新,
|
||||
* 原生有、本地没有的记录(重开项目、别处提交、本会话已剪掉的历史)恢复成一条;本地已收口、
|
||||
* 原生已经查不到的那几笔仍留在列表里——用户刚看到的结果不该因为账本收口而从面板上消失。
|
||||
*/
|
||||
export function resourceCanvasResourceEditTasks({
|
||||
submissions,
|
||||
pendingEdits,
|
||||
}: {
|
||||
submissions: readonly ResourceCanvasResourceEditSubmission[];
|
||||
pendingEdits: readonly PendingLocalProjectResourceEdit[] | null | undefined;
|
||||
}): ResourceCanvasResourceEditTask[] {
|
||||
const safePending = Array.isArray(pendingEdits) ? pendingEdits : [];
|
||||
const pendingByOperationId = new Map(
|
||||
safePending.map((edit) => [edit.operationId, edit]),
|
||||
);
|
||||
const local = submissions.map((submission) =>
|
||||
resourceCanvasResourceEditTaskFromSubmission(
|
||||
submission,
|
||||
pendingByOperationId.get(submission.operationId),
|
||||
),
|
||||
);
|
||||
const knownOperationIds = new Set(
|
||||
submissions.map((submission) => submission.operationId),
|
||||
);
|
||||
const restored = safePending
|
||||
.filter((edit) => !knownOperationIds.has(edit.operationId))
|
||||
.map(resourceCanvasResourceEditTaskFromPendingEdit);
|
||||
return sortResourceCanvasResourceEditTasks([...local, ...restored]);
|
||||
}
|
||||
|
||||
/** 面板展示顺序:新提交的在上。 */
|
||||
export function sortResourceCanvasResourceEditTasks(
|
||||
tasks: readonly ResourceCanvasResourceEditTask[],
|
||||
): ResourceCanvasResourceEditTask[] {
|
||||
return [...tasks].sort(
|
||||
(left, right) =>
|
||||
right.createdAtMillis - left.createdAtMillis ||
|
||||
left.operationId.localeCompare(right.operationId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生/修改任务的已耗时。
|
||||
*
|
||||
* 从原生账本恢复的收口记录没有结束时间(Rust 只给 `createdAt`),这里返回 `null`,由面板整格
|
||||
* 不渲染——宁可不显示,也不拿创建时间冒充耗时。
|
||||
*/
|
||||
export function resourceCanvasResourceEditTaskElapsedMillis(
|
||||
task: Pick<
|
||||
ResourceCanvasResourceEditTask,
|
||||
'createdAtMillis' | 'finishedAtMillis' | 'restored'
|
||||
>,
|
||||
nowMillis: number,
|
||||
): number | null {
|
||||
if (task.restored && task.finishedAtMillis === null) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
(task.finishedAtMillis ?? nowMillis) - task.createdAtMillis,
|
||||
);
|
||||
}
|
||||
|
||||
/** 与图片类生成任务共用一套耗时文案(`formatElapsedDuration` 的唯一入口)。 */
|
||||
export function resourceCanvasResourceEditElapsedLabel(
|
||||
elapsedMillis: number | null,
|
||||
): string {
|
||||
if (elapsedMillis === null) {
|
||||
return '—';
|
||||
}
|
||||
return formatElapsedDuration(elapsedMillis) ?? '—';
|
||||
}
|
||||
@@ -9546,6 +9546,26 @@ iframe.preview-frame {
|
||||
box-shadow: 0 24px 64px rgb(62 37 27 / 24%);
|
||||
}
|
||||
|
||||
/*
|
||||
* 画布卡片基类 token。
|
||||
*
|
||||
* 取值**照抄网页端美术画布的面板**(`src/index.css` 的 `.image-canvas-editor__generation-composer`
|
||||
* 一族):占位卡、生成浮层 / 弹窗、资源信息浮层从此共用同一组圆角 / 描边 / 底色 / 投影,各卡
|
||||
* 自己的规则只表达差异(占位卡虚线描边、浮层的定位与宽度、信息浮层的窄宽…)。
|
||||
*
|
||||
* 两块从网页端整站表逐条照抄下来的浮层(快速编辑 / 生成动画)本身不在这里改——它们的取值由
|
||||
* `tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx` 逐条比对,改了会直接把守卫打红;
|
||||
* 它们本来就是这套取值的来源。
|
||||
*/
|
||||
.game-project-workbench {
|
||||
--game-canvas-card-radius: 1.1rem;
|
||||
--game-canvas-card-radius-inner: 0.85rem;
|
||||
--game-canvas-card-border: rgba(148, 163, 184, 0.36);
|
||||
--game-canvas-card-fill: rgba(255, 255, 255, 0.96);
|
||||
--game-canvas-card-shadow: 0 22px 48px rgba(15, 23, 42, 0.22);
|
||||
--game-canvas-card-padding: 0.76rem;
|
||||
}
|
||||
|
||||
.game-approval-dialog > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -9590,7 +9610,7 @@ iframe.preview-frame {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > div {
|
||||
.game-resource-recovery-item > div:first-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
@@ -9606,7 +9626,18 @@ iframe.preview-frame {
|
||||
color: #9a7d70;
|
||||
}
|
||||
|
||||
/*
|
||||
* 一行里的多枚动作收在一个容器里:条目是 `space-between` 的 flex,动作不收拢就会被
|
||||
* 摊到中间,和原生条目右对齐的单枚动作对不齐(移动端尤其明显)。
|
||||
*/
|
||||
.game-resource-recovery-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button,
|
||||
.game-resource-recovery-actions > button,
|
||||
.game-resource-recovery-error > button {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
@@ -9618,6 +9649,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button:disabled,
|
||||
.game-resource-recovery-actions > button:disabled,
|
||||
.game-resource-recovery-error > button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.56;
|
||||
@@ -9652,9 +9684,14 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button,
|
||||
.game-resource-recovery-actions,
|
||||
.game-resource-recovery-error > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-resource-recovery-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.game-approval-dialog > header button {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -208,8 +208,122 @@ export function resourceBaseName(
|
||||
return resource.label.replace(/\.[^.]+$/u, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生资源名上限:与 Rust `normalize_resource_edit_name`
|
||||
* (`src-tauri/src/project/resource_editor.rs:802-808`)逐值同口径。
|
||||
*/
|
||||
export const RESOURCE_EDIT_NAME_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* 名称不合法时给用户看的那一句:与 Rust 的返回文案逐字一致(同文件 :805)。
|
||||
*
|
||||
* 前端只做镜像、不另造一套中文语义:提交前拦住和提交后失败看到的是同一句话,
|
||||
* 用户不必靠比对两边措辞去猜这是不是同一个问题。
|
||||
*/
|
||||
export const RESOURCE_EDIT_NAME_INVALID_NOTICE =
|
||||
'派生资源名称必须在 1..=120 字符内且不能包含控制字符';
|
||||
|
||||
/**
|
||||
* 名称检查结果:只有 `error === null` 才允许进入生成请求。
|
||||
*
|
||||
* 不合法时 `name` 原样保留(已 trim),**绝不返回截断名**:超长名截到 120 会让两个不同的
|
||||
* 长源名塌成同一个名字(同前缀的两张图就是这种情况),那是把一次可见失败换成一次静默重名。
|
||||
*/
|
||||
export type ResourceEditNameCheck = {
|
||||
name: string;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按 Unicode 码点算长度:Rust 侧是 `value.chars().count()`(同文件 :804),
|
||||
* 不是 JS 的 UTF-16 `.length`——`'😀'.repeat(120)` 的 `.length` 是 240,码点数才是 120。
|
||||
* 用 `.length` 会把 Rust 明确接受的名字在前端误判成非法。
|
||||
*/
|
||||
function resourceNameLength(value: string) {
|
||||
return [...value].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust `char::is_control()` 的口径:Unicode 通用类别 `Cc`,即 C0(U+0000–U+001F)、
|
||||
* DEL(U+007F)与 C1(U+0080–U+009F)。
|
||||
*
|
||||
* 用码点比较而不是控制字符正则:`Cc` 之外(例如 `Cf` 格式符)Rust 是放行的,前端也不额外
|
||||
* 收窄——这份实现的职责是镜像,不是借机加一条客户端自有规则。
|
||||
*/
|
||||
function isResourceNameControlCodePoint(codePoint: number) {
|
||||
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
}
|
||||
|
||||
function resourceNameHasControlCharacter(value: string) {
|
||||
for (const character of value) {
|
||||
if (isResourceNameControlCodePoint(character.codePointAt(0) ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前按 Rust 的同一条规则检查派生资源名:先 `trim`,再要求非空、码点数 1..=120,
|
||||
* 且不含控制字符。首尾的换行 / 制表符会被 `trim` 掉,因此不算违规——与 Rust 逐条一致。
|
||||
*
|
||||
* 调用方拿到 `error` 非空时必须在发请求**之前**停下并把它显示给用户(不合法时 `name`
|
||||
* 只是 trim 后的原样名,不能拿它去提交)。
|
||||
*/
|
||||
export function resolveResourceEditNameCheck(
|
||||
name: string,
|
||||
): ResourceEditNameCheck {
|
||||
const trimmed = name.trim();
|
||||
if (
|
||||
trimmed.length === 0 ||
|
||||
resourceNameLength(trimmed) > RESOURCE_EDIT_NAME_MAX_LENGTH ||
|
||||
resourceNameHasControlCharacter(trimmed)
|
||||
) {
|
||||
return { name: trimmed, error: RESOURCE_EDIT_NAME_INVALID_NOTICE };
|
||||
}
|
||||
return { name: trimmed, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 现役两种派生资源名后缀:快速编辑产出「-编辑版」,角色动画产出「-角色动画」。
|
||||
*
|
||||
* 后缀表是唯一来源,默认名与提交前检查共用它,避免「-编辑版」与「-角色动画」两条路径
|
||||
* 再次各自演化出不同的拼接口径。
|
||||
*/
|
||||
export const DERIVED_RESOURCE_NAME_SUFFIXES = {
|
||||
edit: '-编辑版',
|
||||
'character-animation': '-角色动画',
|
||||
} as const;
|
||||
|
||||
export type DerivedResourceNameKind =
|
||||
keyof typeof DERIVED_RESOURCE_NAME_SUFFIXES;
|
||||
|
||||
/**
|
||||
* 派生资源名的默认值 + 校验:去扩展名的基名(空则兜底「资源」)拼上对应后缀再检查。
|
||||
*
|
||||
* 后缀本身只有 4~5 字,所以超限几乎总是源名太长(例如 Agent 回执的 label 直接取任务标题)
|
||||
* 或源名里带控制字符(任务标题里的换行就是)。
|
||||
*/
|
||||
export function resolveDerivedResourceNameCheck(
|
||||
resource: ProjectResource,
|
||||
kind: DerivedResourceNameKind,
|
||||
): ResourceEditNameCheck {
|
||||
const base = resourceBaseName(resource) || '资源';
|
||||
return resolveResourceEditNameCheck(
|
||||
`${base}${DERIVED_RESOURCE_NAME_SUFFIXES[kind]}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速编辑派生资源的默认名称。
|
||||
*
|
||||
* 只负责拼名字,不校验也不截断:名字是否能在 Rust 侧通过由
|
||||
* `resolveDerivedResourceNameCheck(resource, 'edit')` 回答,调用方必须在提交前用它拦下
|
||||
* 不可用的名字。这里保持返回不合法原名(而不是悄悄截到 120),是为了让上层的检查
|
||||
* 拿到真实长度并给出提示。
|
||||
*/
|
||||
export function defaultDerivedResourceName(resource: ProjectResource) {
|
||||
return `${resourceBaseName(resource) || '资源'}-编辑版`;
|
||||
return resolveDerivedResourceNameCheck(resource, 'edit').name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,11 +331,14 @@ export function defaultDerivedResourceName(resource: ProjectResource) {
|
||||
*
|
||||
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
|
||||
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
|
||||
*
|
||||
* 同样只是默认名,提交前必须走
|
||||
* `resolveDerivedResourceNameCheck(resource, 'character-animation')`。
|
||||
*/
|
||||
export function defaultCharacterAnimationResourceName(
|
||||
resource: ProjectResource,
|
||||
) {
|
||||
return `${resourceBaseName(resource) || '资源'}-角色动画`;
|
||||
return resolveDerivedResourceNameCheck(resource, 'character-animation').name;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AgentMessageContent } from '../../../packages/shared/src/components/AgentMessageContent';
|
||||
import { ChatMarkdownMessage } from '../src/components/ChatMarkdownMessage';
|
||||
import { repoPath } from './repoPath';
|
||||
import {
|
||||
declaration,
|
||||
parseStyleSheet,
|
||||
@@ -15,14 +14,11 @@ import {
|
||||
} from './styleCascade';
|
||||
|
||||
const sharedCss = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'packages/shared/src/components/AgentMessageContent.css',
|
||||
),
|
||||
repoPath('packages/shared/src/components/AgentMessageContent.css'),
|
||||
'utf8',
|
||||
);
|
||||
const appCss = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const base = '.agent-message-content[data-agent-content]';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
act,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
ResourceDependencyOverlay,
|
||||
type ResourceDependencyOverlayHandle,
|
||||
} from '../src/view/project-development/ResourceDependencyOverlay';
|
||||
import { repoPath } from './repoPath';
|
||||
|
||||
function position(
|
||||
resourceId: string,
|
||||
@@ -1030,7 +1030,7 @@ describe('ResourceDependencyOverlay', () => {
|
||||
|
||||
it('uses a persistent orange with at least 3:1 canvas contrast', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
|
||||
@@ -25,6 +25,7 @@ import { normalizeProjectResourceGraph } from '../../src/view/project-developmen
|
||||
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
|
||||
import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel';
|
||||
import { projectResourcesFromReadModels } from '../../src/view/project-development/resourceProjectionModel';
|
||||
import { repoPath } from '../repoPath';
|
||||
import {
|
||||
generationPromptText,
|
||||
typeGenerationPrompt,
|
||||
@@ -60,7 +61,6 @@ import {
|
||||
render,
|
||||
renderAppAt,
|
||||
renderLauncherProjectsAt,
|
||||
resolve,
|
||||
screen,
|
||||
setComposerText,
|
||||
submitChat,
|
||||
@@ -1455,12 +1455,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the resource filter panel above the titlebar band and anchored to the dock', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const tsxSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||||
),
|
||||
'utf8',
|
||||
@@ -1538,7 +1537,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the resource overview grid off fit-content so its columns stay responsive', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
// 先剥掉 CSS 注释再取声明体:这条修复的注释里就写着 `width: 100%` 等字样,
|
||||
@@ -1668,7 +1667,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the shared selection overlay colours resolvable from the scene root', () => {
|
||||
const sharedStyles = readFileSync(
|
||||
resolve(process.cwd(), 'packages/image-canvas-react/src/styles.css'),
|
||||
repoPath('packages/image-canvas-react/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
// 消费端 `border/background: var(...)` 没有 fallback:token 缺失或被写透明,框选就没有颜色。
|
||||
@@ -1896,12 +1895,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the overview-return button out of the floating notice layer hit region', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const tsxSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||||
),
|
||||
'utf8',
|
||||
@@ -3479,7 +3477,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('资源卡 chrome 只保留选中按钮与当前版本边框', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
/*
|
||||
@@ -3573,7 +3571,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('资源卡的 @ 引用入口只留在选中工具条里', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
// 卡片右上角那个圆钮连同它撑出来的 44×44 热区一起退役:卡片本体只剩
|
||||
@@ -3581,8 +3579,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(styles).not.toContain('.game-resource-card-reference');
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||||
),
|
||||
'utf8',
|
||||
@@ -5837,7 +5834,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the landscape workbench edge-to-edge with internal chat scrolling', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -5993,12 +5990,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the wallet entry available when the workbench opens the UI editor', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const projectDevelopmentSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||||
),
|
||||
'utf8',
|
||||
@@ -6307,7 +6303,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -6318,7 +6314,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps the chat composer an inset block inside the conversation dialog', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
// 对话框就是消息列表那只铺满会话区的盒子;输入区是它内部的一块,不再是贴在它下边、
|
||||
@@ -6367,7 +6363,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const messageListRule =
|
||||
@@ -7079,7 +7075,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
|
||||
it('anchors the model dropdown to its own trigger instead of the composer box', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
@@ -7139,7 +7135,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
|
||||
it('bounds the model dropdown height so a long catalog cannot cover the composer', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const menu = styleRuleBody(styles, '\\.conversation-model-menu');
|
||||
@@ -11639,7 +11635,7 @@ export function registerProjectAgentStatusTests() {
|
||||
).not.toBeNull();
|
||||
// 宿主几何与栏目卡共用同一条规则,视觉上不是另写一套。
|
||||
const previewStyles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const previewHostRule = styleRuleBody(
|
||||
@@ -11747,7 +11743,7 @@ export function registerProjectAgentStatusTests() {
|
||||
// 视觉不是另写一套:那一页没有任何专属选择器,卡与宿主都只用共享规则
|
||||
// (声明级断言——它验的是"没有平行样式",验不到布局本身)。
|
||||
const allPageStyles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
expect(allPageStyles).not.toMatch(/\.game-resource-all-/);
|
||||
@@ -13396,12 +13392,11 @@ export function registerProjectAgentStatusTests() {
|
||||
|
||||
it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => {
|
||||
const styles = readFileSync(
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const chromeStyles = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
repoPath(
|
||||
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css',
|
||||
),
|
||||
'utf8',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { APP_VERSION } from '../../src/app/appMetadata';
|
||||
import { repoPath } from '../repoPath';
|
||||
import {
|
||||
act,
|
||||
createGameCreationAppManifest,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
export function registerAgentStatusDerivationTests() {
|
||||
it('keeps fixed overlays below the in-page window title bar', () => {
|
||||
const styles = fs.readFileSync(
|
||||
path.join(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
expect(styles).toContain('--window-chrome-height: 50px;');
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { repoPath } from './repoPath';
|
||||
import {
|
||||
declaration,
|
||||
parseStyleSheet,
|
||||
resolveDeclarations,
|
||||
} from './styleCascade';
|
||||
|
||||
const STYLES_PATH = resolve(
|
||||
process.cwd(),
|
||||
'apps/ai-game-creator-shell/src/styles.css',
|
||||
);
|
||||
const VIEW_PATH = resolve(
|
||||
process.cwd(),
|
||||
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
|
||||
const VIEW_PATH = repoPath(
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx',
|
||||
);
|
||||
|
||||
|
||||
@@ -291,6 +291,22 @@ describe('聊天输入区 AI 润色与发送前提醒', () => {
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('回包与原文相同时聊天输入区也给提示,且不冒出「恢复原文」', async () => {
|
||||
const user = userEvent.setup();
|
||||
installPolishingInvoke('原本的需求');
|
||||
renderComposer({ initialText: '原本的需求' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
|
||||
// 聊天输入区与资源侧共用 `usePromptPolish`:修好状态机还不够,共享输入区必须把
|
||||
// notice 渲染出来,否则这三个宿主里「点了没反应」看起来还是按钮坏了。
|
||||
expect(
|
||||
await screen.findByText('AI 润色结果与原文相同,未做修改'),
|
||||
).not.toBeNull();
|
||||
expect(await composerText()).toBe('原本的需求');
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('holds a long prompt behind the reminder panel and sends the original on demand', async () => {
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ImageCanvasProjectAssetPickerDialog } from '../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
|
||||
import { REPO_ROOT } from './repoPath';
|
||||
|
||||
/**
|
||||
* 「选择替换素材」弹窗在 AGC 的面板底色契约。
|
||||
@@ -25,7 +26,6 @@ import { ImageCanvasProjectAssetPickerDialog } from '../../../src/components/ima
|
||||
* 这是**弱验证**(声明级,不是真机观感)。真机判据见文件末尾注释。
|
||||
*/
|
||||
|
||||
const REPO_ROOT = process.cwd();
|
||||
const AGC_ROOT = resolve(REPO_ROOT, 'apps/ai-game-creator-shell');
|
||||
|
||||
const PLATFORM_MODAL_SHELL = '.platform-modal-shell';
|
||||
@@ -336,9 +336,14 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
|
||||
});
|
||||
|
||||
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
|
||||
const hostRules = HOST_STYLE_SHEETS.filter(existsSync).flatMap((sheet) =>
|
||||
readRules(absoluteSheetPath(sheet)),
|
||||
);
|
||||
/*
|
||||
这里必须**先转绝对路径再判断存在**:`filter(existsSync)` 拿到的是仓库相对路径,`existsSync`
|
||||
按 `process.cwd()` 解析——从 `apps/ai-game-creator-shell` 跑时四张表全被判成不存在,
|
||||
断言退化成「谁都没定义」的假绿/假红(本次就是从这条假红里揪出来的)。
|
||||
*/
|
||||
const hostRules = HOST_STYLE_SHEETS.filter((sheet) =>
|
||||
existsSync(absoluteSheetPath(sheet)),
|
||||
).flatMap((sheet) => readRules(absoluteSheetPath(sheet)));
|
||||
for (const className of [
|
||||
PLATFORM_MODAL_SHELL,
|
||||
PLATFORM_MODAL_BACKDROP,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import { RESOURCE_EDIT_NAME_INVALID_NOTICE } from '../src/view/project-development/resourceEditModel';
|
||||
import {
|
||||
act,
|
||||
createGameCreationAppManifest,
|
||||
@@ -259,9 +260,17 @@ function ClassificationWorkbench() {
|
||||
function DerivedWorkbench({
|
||||
includeArt = false,
|
||||
includeCharacter = false,
|
||||
artName = 'source-art.png',
|
||||
characterName = 'hero.png',
|
||||
}: {
|
||||
includeArt?: boolean;
|
||||
includeCharacter?: boolean;
|
||||
/**
|
||||
* 源文件名可替换:派生资源名按源资源 label 拼出,用长名夹具复现
|
||||
* `normalize_resource_edit_name` 的 120 码点门禁(AGC-025)。
|
||||
*/
|
||||
artName?: string;
|
||||
characterName?: string;
|
||||
}) {
|
||||
const initial = createGameCreationAppManifest(
|
||||
'live-canvas-project',
|
||||
@@ -281,7 +290,7 @@ function DerivedWorkbench({
|
||||
id: 'source-art',
|
||||
kind: 'art-image',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/source-art.png',
|
||||
localPath: `assets/${artName}`,
|
||||
source: { kind: 'generated', resourceId: 'art-resource' },
|
||||
},
|
||||
]
|
||||
@@ -293,7 +302,7 @@ function DerivedWorkbench({
|
||||
kind: 'character',
|
||||
category: 'character' as const,
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
localPath: `assets/${characterName}`,
|
||||
source: { kind: 'generated', resourceId: 'hero-resource' },
|
||||
},
|
||||
]
|
||||
@@ -832,14 +841,33 @@ describe('project resource live canvas integration', () => {
|
||||
'把角色头发设定改为红色',
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
expect(await screen.findByRole('alert')).not.toBeNull();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
// 提交即关面板:失败也不把面板拉回来,重试只能重开面板(任务与失败都在「生成任务」侧栏里)。
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
|
||||
);
|
||||
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
||||
const retryToolbar = await screen.findByRole('toolbar', {
|
||||
name: '图片工具栏',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(retryToolbar).getByRole('button', { name: '快速编辑' }),
|
||||
);
|
||||
const retryPanel = await screen.findByRole('dialog', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
// 草稿回填,提示词没变 → 沿用上一次那笔 operation 身份。
|
||||
expect(
|
||||
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
|
||||
).toContain('把角色头发设定改为红色');
|
||||
fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' }));
|
||||
|
||||
await waitFor(() => expect(deriveCalls).toHaveLength(2));
|
||||
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
|
||||
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
|
||||
expect(deriveCalls[0]?.editKind).toBe('image-reference');
|
||||
expect(deriveCalls[0]?.generationMode).toBe('derive');
|
||||
// 名字合法时出站 assetName 就是提交前校验过的那个名字(默认名,逐字未改)。
|
||||
expect(deriveCalls[0]?.assetName).toBe('source-art-编辑版');
|
||||
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
|
||||
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
|
||||
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
|
||||
@@ -891,6 +919,71 @@ describe('project resource live canvas integration', () => {
|
||||
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
/**
|
||||
* AGC-025:派生资源名由源资源 label 拼出,Rust `normalize_resource_edit_name`
|
||||
* (`resource_editor.rs:802-808`)对「超 120 码点 / 含控制字符 / trim 后为空」的名字整条
|
||||
* 拒绝。前端必须在**任何正规化写盘与派生请求之前**用同一句中文回写面板失败态,否则用户得
|
||||
* 提交一次才知道名字不合法,项目里还可能先多出一条正规化素材。
|
||||
*/
|
||||
it('快速编辑:源名超 120 码点时在派生请求之前拦下,并回写面板错误态', async () => {
|
||||
const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
|
||||
const { invoke, deriveCalls } = installTauri();
|
||||
render(<DerivedWorkbench includeArt artName={longName} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
|
||||
fireEvent.click(await findResourceSelectButton(longName));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
|
||||
const panel = await screen.findByRole('dialog', { name: '快速编辑图片' });
|
||||
await setComposerText(
|
||||
within(panel).getByLabelText('快速编辑提示词'),
|
||||
'把角色头发设定改为红色',
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
// 零写盘、零远程请求:派生请求一次没发,正规化也没有被调用。
|
||||
expect(deriveCalls).toHaveLength(0);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'normalize_local_project_raster_resource',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('角色动画:源名超 120 码点时同样在派生请求之前拦下', async () => {
|
||||
const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
|
||||
const { invoke, deriveCalls } = installTauri();
|
||||
render(<DerivedWorkbench includeCharacter characterName={longName} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
|
||||
fireEvent.click(await findResourceSelectButton(longName));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成动画' }));
|
||||
const panel = await screen.findByRole('dialog', {
|
||||
name: '角色动画生成面板',
|
||||
});
|
||||
await setComposerText(
|
||||
within(panel).getByLabelText('动画描述'),
|
||||
'角色挥手打招呼',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /生成[\d.]+泥点/ }),
|
||||
);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
expect(deriveCalls).toHaveLength(0);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'normalize_local_project_raster_resource',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => {
|
||||
const { deriveCalls, polishCalls } = installTauri({
|
||||
failFirstDerive: true,
|
||||
@@ -912,13 +1005,28 @@ describe('project resource live canvas integration', () => {
|
||||
'把角色头发改成红色',
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
expect(await screen.findByRole('alert')).not.toBeNull();
|
||||
// 提交即关面板,重开面板继续改:草稿回填的是原文。
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
|
||||
);
|
||||
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
||||
const retryToolbar = await screen.findByRole('toolbar', {
|
||||
name: '图片工具栏',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(retryToolbar).getByRole('button', { name: '快速编辑' }),
|
||||
);
|
||||
const retryPanel = await screen.findByRole('dialog', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
|
||||
// 失败后提示词仍可改:润色一次,回填的是润色结果,而不是原文。
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' }));
|
||||
fireEvent.click(
|
||||
within(retryPanel).getByRole('button', { name: 'AI 润色' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(panel).getByLabelText('快速编辑提示词').textContent,
|
||||
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
|
||||
).toContain('亮红色');
|
||||
});
|
||||
expect(polishCalls[0]).toMatchObject({ prompt: '把角色头发改成红色' });
|
||||
@@ -926,7 +1034,7 @@ describe('project resource live canvas integration', () => {
|
||||
'图片素材的快速编辑提示词',
|
||||
);
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' }));
|
||||
await waitFor(() => expect(deriveCalls).toHaveLength(2));
|
||||
// Rust 的 request_fingerprint 含 prompt:提示词换过就必须换 operationId / 幂等键,
|
||||
// 否则后端会判「已绑定到不同资源编辑请求」。
|
||||
@@ -955,17 +1063,32 @@ describe('project resource live canvas integration', () => {
|
||||
'把角色头发改成红色',
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
expect(await screen.findByRole('alert')).not.toBeNull();
|
||||
// 提交即关面板:重开面板后提示词仍是原文(草稿回填),再改再润色都在这一份草稿上。
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(),
|
||||
);
|
||||
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
||||
const retryToolbar = await screen.findByRole('toolbar', {
|
||||
name: '图片工具栏',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(retryToolbar).getByRole('button', { name: '快速编辑' }),
|
||||
);
|
||||
const retryPanel = await screen.findByRole('dialog', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' }));
|
||||
fireEvent.click(
|
||||
within(retryPanel).getByRole('button', { name: 'AI 润色' }),
|
||||
);
|
||||
expect(
|
||||
await within(panel).findByText('AI 润色失败,可重试'),
|
||||
await within(retryPanel).findByText('AI 润色失败,可重试'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(panel).getByLabelText('快速编辑提示词').textContent,
|
||||
within(retryPanel).getByLabelText('快速编辑提示词').textContent,
|
||||
).toContain('把角色头发改成红色');
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' }));
|
||||
await waitFor(() => expect(deriveCalls).toHaveLength(2));
|
||||
// 提示词没变,身份也不该变:重试仍然命中同一 operation 账本。
|
||||
expect(deriveCalls[1]?.operationId).toBe(deriveCalls[0]?.operationId);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* 仓库根与"按仓库相对路径取文件"的唯一入口。
|
||||
*
|
||||
* 读样式表 / 脚本 / 文档的用例原先一律写 `resolve(process.cwd(), 'apps/ai-game-creator-shell/…')`:
|
||||
* 从仓库根跑(CI 的 `npm run test`、根 `vitest.config.ts`)没问题,但只要从
|
||||
* `apps/ai-game-creator-shell` 目录跑一次,路径就会翻成
|
||||
* `…/apps/ai-game-creator-shell/apps/ai-game-creator-shell/…` 并整片 ENOENT——看起来像"这些用例本来
|
||||
* 就红",实际是路径解析跟着 cwd 漂。这里按 `import.meta.url` 反推仓库根,两种跑法结论一致。
|
||||
*/
|
||||
export const REPO_ROOT = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
);
|
||||
|
||||
/** 传仓库相对路径(如 `apps/ai-game-creator-shell/src/styles.css`)。 */
|
||||
export function repoPath(...segments: readonly string[]): string {
|
||||
return resolve(REPO_ROOT, ...segments);
|
||||
}
|
||||
@@ -300,6 +300,94 @@ describe('生成面板的参考接线', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('点提示词输入区不该自己弹出素材选择框', async () => {
|
||||
const user = userEvent.setup();
|
||||
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={imageAction}
|
||||
assets={[imageAsset]}
|
||||
projectPath="/tmp/project"
|
||||
draft={{
|
||||
prompt: '',
|
||||
assetName: '猫',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
references: [],
|
||||
}}
|
||||
onSubmit={vi.fn()}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('生成提示词'));
|
||||
expect(screen.queryByRole('dialog', { name: '选择素材' })).toBeNull();
|
||||
|
||||
// 只有「插入素材引用」这一枚按钮才打开选择器。
|
||||
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '选择素材' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('紧凑版排布:动作行同时装下润色 / 参考计数 / 两个动作,参数与提示词各占一行', () => {
|
||||
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
|
||||
render(
|
||||
<ResourceCanvasAssetGenerationPanelView
|
||||
action={imageAction}
|
||||
assets={[imageAsset]}
|
||||
projectPath="/tmp/project"
|
||||
draft={{
|
||||
prompt: '',
|
||||
assetName: '猫',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
references: [],
|
||||
}}
|
||||
onSubmit={vi.fn()}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
/*
|
||||
验收现场那张图里面板要滚动:标签各占一行、提示词 6 行、比例与尺寸各占一行、润色与参考
|
||||
计数又各占一行。这里钉住重排后的形状——动作行一行收口、参数两栏并排、提示词三行,
|
||||
面板高度因此回落到几何上界以内(滚动条不该再出现)。
|
||||
*/
|
||||
const actions = panel.querySelector('.game-resource-generation-actions');
|
||||
expect(actions).not.toBeNull();
|
||||
expect(
|
||||
within(actions as HTMLElement).getByRole('button', { name: 'AI 润色' }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(actions as HTMLElement).getByText('参考图 0/5'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(actions as HTMLElement).getByRole('button', { name: '取消' }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(actions as HTMLElement).getByRole('button', { name: '生成图片' }),
|
||||
).not.toBeNull();
|
||||
|
||||
// 比例与尺寸同处一格(CSS 里排成两栏),不再各占一行。
|
||||
const dimensions = panel.querySelectorAll(
|
||||
'.resource-canvas-asset-generation-dimensions',
|
||||
);
|
||||
expect(dimensions).toHaveLength(1);
|
||||
// 两组选择器(比例 5 项、尺寸 3 项)同处这一格,CSS 里排成两栏。
|
||||
expect(
|
||||
within(dimensions[0] as HTMLElement).getByRole('button', {
|
||||
name: '生成图片比例 1:1',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(dimensions[0] as HTMLElement).getByRole('button', {
|
||||
name: '生成图片尺寸 1K',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('超限时提交被挡住并给出原因', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit =
|
||||
|
||||
+2
-3
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { repoPath } from './repoPath';
|
||||
import {
|
||||
declaration,
|
||||
parseStyleSheet,
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
* jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**:
|
||||
* 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。
|
||||
*/
|
||||
const SIDEBAR_CSS_PATH = resolve(
|
||||
process.cwd(),
|
||||
const SIDEBAR_CSS_PATH = repoPath(
|
||||
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css',
|
||||
);
|
||||
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user