合并资源工作台分支:素材类型可由用户变更与角标显示资源类型并入
- 把 v3 顶端 ace31a5f1(快速编辑 @ 引用素材 + @ 面板标签筛选)合入本分支,与本分支 1079f6c91(用户可变更素材类型、角标改显示资源类型)并存 - 冲突零:index.tsx 两侧改动落点不同(快速编辑 promptInputSlot 接线 vs 角标取值点),projectResourceLiveIntegration 与验收文档自动合并 - 验证:npm run typecheck exit 0;resourceClassificationPanel + projectResourceLiveIntegration 共 45 tests 全绿;check:encoding 4409 文件通过;git diff --check 干净
This commit is contained in:
+109
-75
@@ -34,7 +34,6 @@ import {
|
||||
Loader2,
|
||||
Music2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal, flushSync } from 'react-dom';
|
||||
|
||||
import { PlatformResourceFilterBar } from '../../../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
|
||||
import {
|
||||
type GameCreationAppAssetManifestEntry,
|
||||
@@ -90,7 +90,9 @@ import {
|
||||
resourceReferenceFromAsset,
|
||||
resourceReferenceMatchesCategoryFilter,
|
||||
resourceReferenceMatchesQuery,
|
||||
resourceReferenceMatchesTagSelection,
|
||||
type ResourceReferenceScope,
|
||||
resourceReferenceTagLibrary,
|
||||
} from './resourceReferences';
|
||||
import { usePromptPolish } from './usePromptPolish';
|
||||
|
||||
@@ -114,6 +116,14 @@ type ResourceReferenceInputProps = {
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
showTriggerButton?: boolean;
|
||||
/**
|
||||
* 内置「AI 润色 / 恢复原文」动作是否渲染,默认 `true`。
|
||||
*
|
||||
* 聊天输入区用内置那一份(不带资源编辑场景上下文);资源侧快速编辑由宿主自己的
|
||||
* `ResourcePromptPolishSlot` 承担润色(带场景上下文与长度上限),传 `false`
|
||||
* 避免同一个面板里出现两个润色入口。
|
||||
*/
|
||||
showPolishAction?: boolean;
|
||||
inputRef?: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
@@ -121,10 +131,17 @@ type ResourcePickerScopeState = {
|
||||
query: string;
|
||||
filter: ResourceReferenceFilter;
|
||||
selectedResourceIds: string[];
|
||||
/** 已选标签(多选),与搜索词、功能分类三者叠加生效。 */
|
||||
selectedTags: string[];
|
||||
};
|
||||
|
||||
function createResourcePickerScopeState(): ResourcePickerScopeState {
|
||||
return { query: '', filter: 'all', selectedResourceIds: [] };
|
||||
return {
|
||||
query: '',
|
||||
filter: 'all',
|
||||
selectedResourceIds: [],
|
||||
selectedTags: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createResourcePickerScopeStates(): Record<
|
||||
@@ -291,6 +308,7 @@ function ResourceReferenceEditor({
|
||||
multiline,
|
||||
rows,
|
||||
showTriggerButton = true,
|
||||
showPolishAction = true,
|
||||
inputRef,
|
||||
composerRef,
|
||||
}: ResourceReferenceInputProps & {
|
||||
@@ -336,12 +354,19 @@ function ResourceReferenceEditor({
|
||||
const pickerQuery = pickerScopeState.query;
|
||||
const pickerFilter = pickerScopeState.filter;
|
||||
const selectedResourceIds = pickerScopeState.selectedResourceIds;
|
||||
const selectedTags = pickerScopeState.selectedTags;
|
||||
|
||||
const scopeReferences =
|
||||
pickerScope === 'current-version'
|
||||
? currentVersionAssetReferences
|
||||
: assetReferences;
|
||||
|
||||
// 标签库按当前页签的候选派生:两个页签各自一份,标签与计数都不跨页签串。
|
||||
const scopeTagItems = useMemo(
|
||||
() => resourceReferenceTagLibrary(scopeReferences),
|
||||
[scopeReferences],
|
||||
);
|
||||
|
||||
const updatePickerScopeState = useCallback(
|
||||
(patch: Partial<ResourcePickerScopeState>) => {
|
||||
setPickerScopeStates((current) => ({
|
||||
@@ -479,24 +504,23 @@ function ResourceReferenceEditor({
|
||||
});
|
||||
}, [editor, refreshResourceReferenceLabels]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (!event || event.shiftKey || event.isComposing) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
(event.target as HTMLElement | null)
|
||||
?.closest('form')
|
||||
?.requestSubmit();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
[editor],
|
||||
);
|
||||
// Enter 提交只属于单行输入区(聊天 / 项目总控)。多行输入区(资源卡快速编辑提示词)
|
||||
// 的 Enter 必须留给换行,否则用户没法在提示词里分行。
|
||||
useEffect(() => {
|
||||
if (multiline) return undefined;
|
||||
return editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (!event || event.shiftKey || event.isComposing) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
(event.target as HTMLElement | null)?.closest('form')?.requestSubmit();
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
);
|
||||
}, [editor, multiline]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -727,9 +751,12 @@ function ResourceReferenceEditor({
|
||||
const pickerReferences = useMemo(() => {
|
||||
return scopeReferences.filter((reference) => {
|
||||
if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false;
|
||||
return resourceReferenceMatchesCategoryFilter(reference, pickerFilter);
|
||||
if (!resourceReferenceMatchesCategoryFilter(reference, pickerFilter)) {
|
||||
return false;
|
||||
}
|
||||
return resourceReferenceMatchesTagSelection(reference, selectedTags);
|
||||
});
|
||||
}, [pickerFilter, pickerQuery, scopeReferences]);
|
||||
}, [pickerFilter, pickerQuery, scopeReferences, selectedTags]);
|
||||
|
||||
const visiblePickerReferences = pickerReferences.slice(0, 120);
|
||||
|
||||
@@ -805,38 +832,46 @@ function ResourceReferenceEditor({
|
||||
<AtSign size={15} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-polish"
|
||||
aria-label="AI 润色"
|
||||
title="AI 润色"
|
||||
aria-busy={polishing}
|
||||
disabled={disabled || polishing || value.trim() === ''}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void polishPrompt()}
|
||||
>
|
||||
{polishing ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{polishedOriginalText !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-restore"
|
||||
aria-label="恢复原文"
|
||||
title="恢复原文"
|
||||
disabled={disabled || polishing}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={restoreOriginalPrompt}
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{showPolishAction ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-polish"
|
||||
aria-label="AI 润色"
|
||||
title="AI 润色"
|
||||
aria-busy={polishing}
|
||||
disabled={disabled || polishing || value.trim() === ''}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void polishPrompt()}
|
||||
>
|
||||
{polishing ? (
|
||||
<Loader2
|
||||
size={15}
|
||||
className="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{polishedOriginalText !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="resource-reference-input-restore"
|
||||
aria-label="恢复原文"
|
||||
title="恢复原文"
|
||||
disabled={disabled || polishing}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={restoreOriginalPrompt}
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
|
||||
{!reminderOpen && (polishing || polishError) ? (
|
||||
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
|
||||
<span
|
||||
className="resource-reference-input-status"
|
||||
role="status"
|
||||
@@ -894,34 +929,33 @@ function ResourceReferenceEditor({
|
||||
ariaLabel="素材范围"
|
||||
className="platform-theme platform-theme--light resource-reference-picker-scopes"
|
||||
/>
|
||||
<div className="resource-reference-picker-search">
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<input
|
||||
aria-label={
|
||||
{/* 搜索 + 功能分类 + 标签用共享筛选条:与资源画布、参考图弹窗同一套筛选 UI,
|
||||
标签口径也来自共享标签库;两个页签各自持有一份独立状态。 */}
|
||||
<PlatformResourceFilterBar
|
||||
ariaLabel="素材筛选"
|
||||
search={{
|
||||
value: pickerQuery,
|
||||
label:
|
||||
pickerScope === 'current-version'
|
||||
? '搜索当前版本素材'
|
||||
: '搜索全部画布素材'
|
||||
}
|
||||
value={pickerQuery}
|
||||
placeholder="搜索名称或资源 ID"
|
||||
onChange={(event) =>
|
||||
updatePickerScopeState({
|
||||
query: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_REFERENCE_FILTERS}
|
||||
activeId={pickerFilter}
|
||||
onChange={(nextFilter) =>
|
||||
: '搜索全部画布素材',
|
||||
placeholder: '搜索名称或资源 ID',
|
||||
onChange: (value) => updatePickerScopeState({ query: value }),
|
||||
}}
|
||||
categoryItems={RESOURCE_REFERENCE_FILTERS}
|
||||
activeCategoryId={pickerFilter}
|
||||
onCategoryChange={(nextFilter) =>
|
||||
updatePickerScopeState({ filter: nextFilter })
|
||||
}
|
||||
layout="scroll"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
tagItems={scopeTagItems}
|
||||
activeTags={selectedTags}
|
||||
onToggleTag={(tag) =>
|
||||
updatePickerScopeState({
|
||||
selectedTags: selectedTags.includes(tag)
|
||||
? selectedTags.filter((item) => item !== tag)
|
||||
: [...selectedTags, tag],
|
||||
})
|
||||
}
|
||||
className="platform-theme platform-theme--light resource-reference-picker-filters"
|
||||
/>
|
||||
<div className="resource-reference-picker-list" role="listbox">
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
gameCreationAppAssetTags,
|
||||
type GameIterationVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
assetTagsMatchSelection,
|
||||
buildGameCreationAppAssetTagLibrary,
|
||||
type GameCreationAppAssetTagLibraryEntry,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary';
|
||||
|
||||
export type ResourceReferenceSource =
|
||||
| 'asset-picker'
|
||||
@@ -60,6 +65,22 @@ export function dispatchResourceReferenceInsert(reference: ChatReference) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `@` 输入区 portal 到 `document.body` 的两块浮层:资源选择器与输入区候选菜单。
|
||||
*
|
||||
* 它们不在宿主的浮层边界里(DOM 上挂在 body),所以宿主的「点外部关闭」判定需要
|
||||
* 拿它当白名单,否则点候选项会被判成点外部、把正在编辑的面板一起收掉。
|
||||
*/
|
||||
export const RESOURCE_REFERENCE_OVERLAY_SELECTOR =
|
||||
'.resource-reference-picker, .resource-reference-menu';
|
||||
|
||||
export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
target.closest(RESOURCE_REFERENCE_OVERLAY_SELECTOR) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
|
||||
text: '',
|
||||
references: [],
|
||||
@@ -153,6 +174,36 @@ export function resourceReferenceMatchesCategoryFilter(
|
||||
return filter === 'all' || resourceReferenceCategory(reference) === filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* `@` 面板的标签判据:直接用引用自带的 manifest `tags`,口径落在共享标签库
|
||||
* (归一化去空白、同素材去重、已选标签全部命中)。这里不另建第二套标签匹配,
|
||||
* 因此 `@` 面板、资源画布与参考图筛选条对「标签算不算命中」永远同一答案。
|
||||
*/
|
||||
export function resourceReferenceMatchesTagSelection(
|
||||
reference: ResourceReference,
|
||||
selectedTags: readonly string[],
|
||||
): boolean {
|
||||
return assetTagsMatchSelection(reference.tags, selectedTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* `@` 面板的标签库:由当前页签的候选引用派生,复用共享标签库的排序与计数口径
|
||||
* (使用次数降序 → 标签 zh-CN 升序)。
|
||||
*
|
||||
* 计数只覆盖传进来的候选,所以两个页签各自的计数与各自的候选范围一致,
|
||||
* 不会拿另一页签或整个画布的素材凑数。
|
||||
*/
|
||||
export function resourceReferenceTagLibrary(
|
||||
references: readonly ResourceReference[],
|
||||
): GameCreationAppAssetTagLibraryEntry[] {
|
||||
return buildGameCreationAppAssetTagLibrary(
|
||||
references.map((reference) => ({
|
||||
id: reference.resourceId,
|
||||
tags: reference.tags,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `@` 面板的两个页签。两个页签各自持有独立的搜索与筛选状态,
|
||||
* 也不与资源画布的筛选联动。
|
||||
|
||||
@@ -618,6 +618,24 @@
|
||||
--auto-grow-editor-min-height: 5.6rem;
|
||||
}
|
||||
|
||||
/* 快速编辑提示词输入区(AGC 注入位 `promptInputSlot`):跑 `@` 引用生成器的
|
||||
* `ResourceReferenceInput` 替掉共享 composer 的默认 textarea,落同一格——
|
||||
* 与 `.image-canvas-editor__generation-prompt` 一样铺满 composer 的一整行。
|
||||
* 这一族类名只属于 AGC 侧,网页端不传注入位,不会渲染到。 */
|
||||
.resource-canvas-quick-edit-prompt-input {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-canvas-quick-edit-prompt-input .resource-reference-input {
|
||||
padding: 6px 6px 6px 10px;
|
||||
border-radius: 1.05rem;
|
||||
}
|
||||
|
||||
.resource-canvas-quick-edit-prompt-input .resource-reference-input-editor {
|
||||
max-height: 8.4rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__quick-edit-submit {
|
||||
justify-self: end;
|
||||
min-width: 6.8rem;
|
||||
|
||||
@@ -4665,7 +4665,7 @@ h2 {
|
||||
right: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
width: min(440px, 88vw);
|
||||
max-height: min(480px, 70vh);
|
||||
overflow: hidden;
|
||||
@@ -4699,30 +4699,11 @@ h2 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-reference-picker-search {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 12px 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--platform-surface-border, #dbe3ef);
|
||||
border-radius: 9px;
|
||||
background: var(--platform-input-fill, #fff);
|
||||
}
|
||||
|
||||
.resource-reference-picker-search input {
|
||||
height: 34px;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 搜索 + 功能分类 + 标签由共享筛选条(`PlatformResourceFilterBar`)渲染,
|
||||
这里只给它留出面板内的边距;两个页签各自持有自己那一份筛选状态。 */
|
||||
.resource-reference-picker-filters {
|
||||
padding: 0 12px 8px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
|
||||
.resource-reference-picker-list {
|
||||
|
||||
@@ -94,8 +94,12 @@ import {
|
||||
type LocalGamePreviewInspectSelection,
|
||||
resolveEmbeddedPreviewUrl,
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput';
|
||||
import {
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
dispatchResourceReferenceInsert,
|
||||
isResourceReferenceOverlayTarget,
|
||||
resolveActiveIterationVersion,
|
||||
resourceReferenceCategoryLabel,
|
||||
} from '../../features/project-workspace/resourceReferences';
|
||||
@@ -1402,6 +1406,17 @@ export default function ProjectDevelopmentView({
|
||||
useState<CanvasLayer | null>(null);
|
||||
const [quickEditPanel, setQuickEditPanel] =
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
/**
|
||||
* 快速编辑提示词里的 `@` 资源引用。
|
||||
*
|
||||
* 与聊天输入区共用同一套引用模型(`ChatReference` / `resourceReferenceFromAsset`),
|
||||
* 提示词文本也由同一个 `ResourceReferenceInput` 拼装成 `@显示名`,所以出站 payload 里
|
||||
* 的提示词与聊天 `@` 逐字同源,不存在第二种引用格式。
|
||||
* 引用列表按面板生命周期重开:每次打开面板清空一份,面板关闭后不再被读取。
|
||||
*/
|
||||
const [quickEditReferences, setQuickEditReferences] = useState<
|
||||
ChatReference[]
|
||||
>([]);
|
||||
/**
|
||||
* 「生成动画」的源与浮层。
|
||||
*
|
||||
@@ -1635,6 +1650,10 @@ export default function ProjectDevelopmentView({
|
||||
*
|
||||
* 边界取整个资源画布管理器:面板内部的点(改提示词、切选项、看信息)不算外部;
|
||||
* 点到画布以外(顶部工具条、对话区、左侧导航)立即收起。
|
||||
*
|
||||
* 边界之外还要认下 `@` 输入区 portal 到 body 的选择器与候选菜单:快速编辑的
|
||||
* 提示词输入区里点一个候选资源,DOM 上确实落在画布管理区之外,不登记就
|
||||
* 会把正在编辑的快速编辑面板一起收掉。
|
||||
*/
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: resolveResourceCanvasFloatingPanelDismissOpen({
|
||||
@@ -1650,6 +1669,7 @@ export default function ProjectDevelopmentView({
|
||||
}),
|
||||
boundaryRefs: [resourceBookManagerRef],
|
||||
onDismiss: clearResourceCanvasFocus,
|
||||
isInsideExtraOverlay: isResourceReferenceOverlayTarget,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -5762,6 +5782,7 @@ export default function ProjectDevelopmentView({
|
||||
setQuickEditSourceLayer(layer);
|
||||
const panelDraft = createResourceQuickEditPanelDraft(layer);
|
||||
setQuickEditPanel(panelDraft);
|
||||
setQuickEditReferences([]);
|
||||
resourceQuickEditRequestRef.current = {
|
||||
...createResourceEditRequestIdentity(panelDraft.prompt),
|
||||
sourceLayerId: layer.id,
|
||||
@@ -5793,6 +5814,20 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 快速编辑提示词输入区的整体草稿(文本 + `@` 引用)。
|
||||
*
|
||||
* 文本仍走 `applyResourceQuickEditPrompt`(失败态重置口径不变),引用单独收下来供
|
||||
* 输入区回填 chip。提交时的出站 payload 就是这份文本:与聊天 `@` 同源、同字面量。
|
||||
*/
|
||||
const applyResourceQuickEditDraft = useCallback(
|
||||
(draft: ChatComposerDraft) => {
|
||||
setQuickEditReferences(draft.references);
|
||||
applyResourceQuickEditPrompt(draft.text);
|
||||
},
|
||||
[applyResourceQuickEditPrompt],
|
||||
);
|
||||
|
||||
/**
|
||||
* 快速编辑:先按后端真实门禁把任务产物正规化成正式素材,再走资源派生产出一张新素材。
|
||||
* 源素材(文件与 manifest 条目)保持不变。
|
||||
@@ -6729,6 +6764,33 @@ export default function ProjectDevelopmentView({
|
||||
panel={quickEditPanel}
|
||||
style={quickEditPanelStyle}
|
||||
setQuickEditPanel={setQuickEditPanel}
|
||||
promptInputSlot={
|
||||
// 与聊天输入区同一个 `@` 引用输入区:候选、插入动作与
|
||||
// 引用模型都复用那一份,提示词文本里的 `@显示名` 也
|
||||
// 由它拼装,因此出站 payload 与聊天 `@` 一致。
|
||||
<div className="resource-canvas-quick-edit-prompt-input">
|
||||
<ResourceReferenceInput
|
||||
ariaLabel="快速编辑提示词"
|
||||
value={quickEditPanel.prompt}
|
||||
references={quickEditReferences}
|
||||
onChange={applyResourceQuickEditDraft}
|
||||
assets={manifest.assets}
|
||||
projectPath={projectPath}
|
||||
versions={projectVersions}
|
||||
activeVersionId={activeVersionId}
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="你希望素材如何修改?"
|
||||
disabled={
|
||||
quickEditPanel.status === 'generating'
|
||||
}
|
||||
// 润色由下面的 ResourcePromptPolishSlot 承担
|
||||
// (带资源编辑场景上下文与长度上限),
|
||||
// 不在这里再渲染第二个润色入口。
|
||||
showPolishAction={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
promptActionSlot={
|
||||
<ResourcePromptPolishSlot
|
||||
subject="图片素材的快速编辑提示词"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
calculateCharacterAnimationPrice,
|
||||
CHARACTER_ANIMATION_MODEL,
|
||||
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import {
|
||||
act,
|
||||
@@ -840,6 +841,58 @@ describe('project resource live canvas integration', () => {
|
||||
expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey);
|
||||
});
|
||||
|
||||
it('快速编辑提示词里能 @ 出资源选择器,插入的引用与聊天同字面量进入派生请求', async () => {
|
||||
const { deriveCalls } = installTauri();
|
||||
render(<DerivedWorkbench includeArt />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
|
||||
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
||||
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: '插入素材引用' }),
|
||||
);
|
||||
const picker = await screen.findByRole('dialog', { name: '选择素材' });
|
||||
fireEvent.click(within(picker).getByRole('option', { name: /^rules/u }));
|
||||
fireEvent.click(within(picker).getByRole('button', { name: '插入引用' }));
|
||||
|
||||
// 回填:chip 与文本都落在快速编辑提示词里。
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-resource-reference-id="source-rules"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
expect(
|
||||
within(panel).getByLabelText('快速编辑提示词').textContent,
|
||||
).toContain('把夜色改成星空@rules');
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
await waitFor(() => expect(deriveCalls).toHaveLength(1));
|
||||
|
||||
// 出站负载:派生请求里的提示词就是聊天 `@` 拼出来的那一串(`@显示名`),
|
||||
// 期望值直接由共享引用模型算,钉住"快速编辑没有第二种引用格式"。
|
||||
const mentionedAsset = canvasFixture.manifest?.assets.find(
|
||||
(entry) => entry.id === 'source-rules',
|
||||
);
|
||||
if (!mentionedAsset) throw new Error('missing source-rules fixture');
|
||||
const expectedText = `把夜色改成星空@${
|
||||
resourceReferenceFromAsset(mentionedAsset, 'asset-picker').label
|
||||
}`;
|
||||
expect(deriveCalls[0]?.prompt).toBe(expectedText);
|
||||
});
|
||||
|
||||
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
|
||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { $getRoot } from 'lexical';
|
||||
import { StrictMode } from 'react';
|
||||
@@ -27,6 +33,8 @@ import {
|
||||
resourceReferenceFromAsset,
|
||||
resourceReferenceMatchesCategoryFilter,
|
||||
resourceReferenceMatchesQuery,
|
||||
resourceReferenceMatchesTagSelection,
|
||||
resourceReferenceTagLibrary,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
|
||||
function asset(
|
||||
@@ -97,6 +105,46 @@ const assets = [
|
||||
asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'),
|
||||
];
|
||||
|
||||
/** 带 manifest 标签的同一批素材:标签筛选用例只换 `tags`,其余口径与 `assets` 一致。 */
|
||||
const taggedAssets: GameCreationAppAssetManifestEntry[] = [
|
||||
{ ...assets[0]!, tags: ['角色', '主角'] },
|
||||
{ ...assets[1]!, tags: ['角色'] },
|
||||
{ ...assets[2]!, tags: ['背景乐'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 面板里的候选筛选条由共享组件渲染,标签行是它内部带 `素材筛选标签` 名字的分组。
|
||||
* 用这一层 scope 取标签 chip,避免和同名的功能分类按钮(例如「角色与对象」)串。
|
||||
*/
|
||||
function pickerTagGroup() {
|
||||
return within(screen.getByRole('group', { name: '素材筛选标签' }));
|
||||
}
|
||||
|
||||
/** 取某个 aria-label 的引用输入区,把它内部(含 `@` 按钮)的操作局限定在自己身上。 */
|
||||
function referenceInputByLabel(ariaLabel: string) {
|
||||
const editor = screen.getByLabelText(ariaLabel);
|
||||
const root = editor.closest('.resource-reference-input');
|
||||
if (!root)
|
||||
throw new Error(`resource reference input not found: ${ariaLabel}`);
|
||||
return within(root as HTMLElement);
|
||||
}
|
||||
|
||||
async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) {
|
||||
await userEvent.click(
|
||||
referenceInputByLabel(ariaLabel).getByRole('button', {
|
||||
name: '插入素材引用',
|
||||
}),
|
||||
);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '选择素材' })).getByRole(
|
||||
'option',
|
||||
{ name: optionName },
|
||||
),
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: '插入引用' }));
|
||||
await settleComposer();
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ResourceReferenceInput', () => {
|
||||
@@ -602,4 +650,201 @@ describe('ResourceReferenceInput', () => {
|
||||
|
||||
expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero');
|
||||
});
|
||||
|
||||
test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => {
|
||||
const references = taggedAssets.map((entry) =>
|
||||
resourceReferenceFromAsset(entry, 'asset-picker'),
|
||||
);
|
||||
expect(resourceReferenceTagLibrary(references)).toEqual([
|
||||
{ tag: '角色', assetCount: 2, assetIds: ['enemy', 'hero'] },
|
||||
{ tag: '背景乐', assetCount: 1, assetIds: ['theme'] },
|
||||
{ tag: '主角', assetCount: 1, assetIds: ['hero'] },
|
||||
]);
|
||||
|
||||
const hero = references[0]!;
|
||||
const enemy = references[1]!;
|
||||
const theme = references[2]!;
|
||||
// 空选择不过滤;单标签按「素材带这个标签」判。
|
||||
expect(resourceReferenceMatchesTagSelection(hero, [])).toBe(true);
|
||||
expect(resourceReferenceMatchesTagSelection(theme, ['角色'])).toBe(false);
|
||||
// 多标签是 AND:hero 同时有「角色」「主角」才算命中,enemy 只有「角色」。
|
||||
expect(resourceReferenceMatchesTagSelection(hero, ['角色', '主角'])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resourceReferenceMatchesTagSelection(enemy, ['角色', '主角'])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('标签筛选把不符合的候选筛掉,多标签叠加,再与搜索关键字、功能分类叠加', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={taggedAssets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
|
||||
// 没传 versions 时默认落在「全部画布素材」,标签行按这一页签的候选派生。
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3);
|
||||
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
|
||||
'角色2',
|
||||
);
|
||||
|
||||
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
|
||||
// 只勾一个标签:没带这个标签的素材必须消失,不是"排前面"。
|
||||
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
|
||||
expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull();
|
||||
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
|
||||
|
||||
await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u }));
|
||||
// 第二个标签再收一层:两个标签都命中的只剩 hero。
|
||||
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
|
||||
expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull();
|
||||
expect(
|
||||
pickerTagGroup()
|
||||
.getByRole('button', { name: /^主角/u })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
// 关键字叠在标签之上:hero 不含 'enemy',两个标签 + 关键字一起就空了。
|
||||
await user.type(screen.getByLabelText('搜索全部画布素材'), 'enemy');
|
||||
expect(screen.queryByRole('option')).toBeNull();
|
||||
expect(screen.getByText('没有匹配的素材')).not.toBeNull();
|
||||
|
||||
// 功能分类也参与叠加:hero 是 character,切到「音频」后同样为空。
|
||||
await user.clear(screen.getByLabelText('搜索全部画布素材'));
|
||||
await user.click(screen.getByRole('button', { name: '音频' }));
|
||||
expect(screen.queryByRole('option')).toBeNull();
|
||||
|
||||
// 取消两个标签,分类仍在生效:只剩音频素材 theme。
|
||||
await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u }));
|
||||
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
|
||||
expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull();
|
||||
expect(screen.queryByRole('option', { name: /hero/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('两个页签各自持有一份独立的标签库与已选标签', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={taggedAssets}
|
||||
versions={[iterationVersion('v1', ['hero', 'theme'])]}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
|
||||
// 当前版本 v1 只绑定 hero / theme,标签计数也只看这两个。
|
||||
expect(
|
||||
screen
|
||||
.getByRole('tab', { name: '当前版本素材' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
|
||||
'角色1',
|
||||
);
|
||||
expect(pickerTagGroup().queryByText('主角')).not.toBeNull();
|
||||
await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u }));
|
||||
expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull();
|
||||
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '全部画布素材' }));
|
||||
// 另一个页签有自己的已选标签(此时为空)与自己的标签计数。
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3);
|
||||
expect(
|
||||
pickerTagGroup()
|
||||
.getByRole('button', { name: /^角色/u })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe(
|
||||
'角色2',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '当前版本素材' }));
|
||||
// 切回来仍是自己那份选择与筛后结果。
|
||||
expect(
|
||||
pickerTagGroup()
|
||||
.getByRole('button', { name: /^角色/u })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.queryByRole('option', { name: /theme/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => {
|
||||
const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
|
||||
const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>();
|
||||
render(
|
||||
<>
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={chatOnChange}
|
||||
assets={taggedAssets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="聊天"
|
||||
/>
|
||||
<ResourceReferenceInput
|
||||
value=""
|
||||
references={[]}
|
||||
onChange={quickEditOnChange}
|
||||
assets={taggedAssets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="快速编辑提示词"
|
||||
multiline
|
||||
rows={3}
|
||||
showPolishAction={false}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
await insertAssetThroughPicker('聊天', /hero/u);
|
||||
await insertAssetThroughPicker('快速编辑提示词', /hero/u);
|
||||
|
||||
const chatDraft = chatOnChange.mock.calls.at(-1)?.[0];
|
||||
const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0];
|
||||
// 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同:
|
||||
// 「快速编辑」不允许出现第二种引用格式。
|
||||
expect(chatDraft?.text).toBe('@hero');
|
||||
expect(quickEditDraft).toEqual(chatDraft);
|
||||
expect(quickEditDraft?.references).toEqual([
|
||||
resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'),
|
||||
]);
|
||||
expect(
|
||||
document.querySelectorAll('[data-resource-reference-id="hero"]'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => {
|
||||
render(
|
||||
<ResourceReferenceInput
|
||||
value="把夜色改成星空"
|
||||
references={[]}
|
||||
onChange={vi.fn()}
|
||||
assets={taggedAssets}
|
||||
projectPath="C:/project"
|
||||
ariaLabel="快速编辑提示词"
|
||||
multiline
|
||||
rows={3}
|
||||
showPolishAction={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'AI 润色' })).toBeNull();
|
||||
// `@` 入口照旧在:润色收起来不影响引用。
|
||||
expect(
|
||||
referenceInputByLabel('快速编辑提示词').getByRole('button', {
|
||||
name: '插入素材引用',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
|
||||
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| **S19** @ 引用 | 输入区「@」→ 两个页签 + 筛选 + 搜索 + 多选 → 「添加到对话」 | 两页签**各自独立**的搜索与筛选状态,**不影响**资源画布筛选;已选素材不因筛选或搜索隐藏而被取消;chip 整体插入 | L187、L420–426、L453–461;#309 C4 | `role="tablist"`(`aria-label="素材范围"`)内两个 `role="tab"`:`当前版本素材` / `全部画布素材`;输入框 aria-label 分别为 `搜索当前版本素材` / `搜索全部画布素材`;chip = `span.resource-reference-chip[data-resource-reference-id]` | **chip 原子性有正式定义**:chip 是 Lexical `DecoratorNode`,纯文本表示固定占 **1 个字符** `U+FFFC` ⇒ 一次 Backspace 只能整体删掉一个 chip,不可能删半截;引用身份是稳定 `resourceId`,改名后按 id 刷新显示名 |
|
||||
| **S19** @ 引用 | 输入区「@」→ 两个页签 + 筛选(功能分类 + 多标签)+ 搜索 + 多选 → 「添加到对话」 | 两页签**各自独立**的搜索与筛选状态(含各自一份标签库),**不影响**资源画布筛选;已选素材不因筛选或搜索隐藏而被取消;chip 整体插入 | L187、L420–426、L453–461;#309 C4 | `role="tablist"`(`aria-label="素材范围"`)内两个 `role="tab"`:`当前版本素材` / `全部画布素材`;输入框 aria-label 分别为 `搜索当前版本素材` / `搜索全部画布素材`;标签行是共享筛选条里 `aria-label="素材筛选标签"` 的分组,chip 用 `aria-pressed` 表达选中;chip = `span.resource-reference-chip[data-resource-reference-id]` | **chip 原子性有正式定义**:chip 是 Lexical `DecoratorNode`,纯文本表示固定占 **1 个字符** `U+FFFC` ⇒ 一次 Backspace 只能整体删掉一个 chip,不可能删半截;引用身份是稳定 `resourceId`,改名后按 id 刷新显示名。**多标签是 AND**(口径来自 `packages/shared` 的 `assetTagsMatchSelection`,标签取自 manifest `assets[].tags`),并与搜索词、功能分类三者叠加 |
|
||||
| **S19a** 快速编辑内 @ 引用 | 资源卡「快速编辑」→ 提示词输入区「插入素材引用」或直接输入 `@` → 选素材 → 「修改」 | 快速编辑提示词输入区与聊天输入区**同一套**引用输入区:候选、插入动作、引用模型一致;提示词文本里的引用与聊天同字面量(`@显示名`),提交后该文本原样进入 `derive_local_project_resource` 的 `prompt` | #309 C4 / C10 | 快速编辑面板内存在 `aria-label="插入素材引用"` 与 `aria-label="快速编辑提示词"`;插入后编辑区出现 `[data-resource-reference-id]` chip;派生请求 `prompt` 等于 `文本@显示名` | 该输入区不渲染内置「AI 润色」(润色仍由面板的「AI 润色」承担,带资源编辑场景上下文与长度上限);多行输入区的 Enter 只换行、不提交;点 `@` 选择器里的候选不会被判成「点外部」而收起面板 |
|
||||
| **S20** 润色与发送前提醒 | ① 点「AI 润色」→ 看回填与「恢复原文」;② 直接发送(≥40 字)→ 处理提醒弹窗三条动作与「不再提醒」 | 润色只优化文本、不发送不提交;第一次成功润色落下原文快照、可重复润色、失败保留原文;未润色直接发送时弹「发送前提醒」 | L120(气泡对比度)、飞书 §12.1/12.2;#309 C8 | 润色按钮 `aria-label="AI 润色"` + `aria-busy`;出现「恢复原文」按钮;提醒弹窗为 portal `role="dialog"`、标题「发送前提醒」、动作 `关闭` / `使用原文提交` / `AI 润色` + checkbox `不再提醒`;偏好存 `localStorage['agc.chat.prompt-polish-reminder.disabled']` | 触发门禁:trim 后 ≥40 字、不以 `/` 开头、本轮未润色未确认、未勾「不再提醒」。计费不自建,走 server-rs `/api/llm/chat/completions` 与 `/api/llm/responses`;「1 泥点」这个数字源码中未硬编码 |
|
||||
|
||||
### F 阶段 · 台账核对
|
||||
@@ -194,6 +195,8 @@ api-server 是否本次重启:□ 是 □ 否
|
||||
| S17 播放 → 运行视图 + 本地预览 | ☐ 过 ☐ 不过 | url:______________ |
|
||||
| S18 停止预览 / 退出收尾对齐 stopped | ☐ 过 ☐ 不过 | |
|
||||
| S19 @ 引用两页签 + 独立筛选 + chip 原子性 | ☐ 过 ☐ 不过 | |
|
||||
| S19a @ 面板标签筛选(多标签 AND,叠加关键字与功能分类) | ☐ 过 ☐ 不过 | |
|
||||
| S19b 快速编辑提示词内 @ 引用(同聊天引用模型与字面量) | ☐ 过 ☐ 不过 | |
|
||||
| S20 润色 + 发送前提醒 + 不再提醒 | ☐ 过 ☐ 不过 | |
|
||||
| S21 台账(即时同步 / 双布局 / 无溢出) | ☐ 过 ☐ 不过 | 1280×800 ☐ 1280×720 ☐ |
|
||||
| 门禁:typecheck / AGC tests / check:encoding / git diff --check | ☐ 过 ☐ 不过 | |
|
||||
@@ -257,5 +260,5 @@ api-server 是否本次重启:□ 是 □ 否
|
||||
|
||||
## 8. 验收边界
|
||||
|
||||
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览)**、聊天 @ 引用与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
|
||||
- 本用例覆盖:资源总览 / 栏目分页画布、资源卡预览与本地受控读取、资源卡选择与多选、快速编辑派生(含提示词内 @ 引用)、画布生成入口(3 类)、编辑标签、重命名、下载、删除三分支、版本切换与当前使用高亮、**资源替换(入口放行判据 / 候选禁用与原因 / 格式提示 / 写入载荷 / 拒绝零副作用 / 成功后不切版本不重载预览)**、聊天 @ 引用(两页签 + 功能分类 + 多标签筛选 + 搜索)与原子 chip、AI 润色与发送前提醒、本地预览启动与退出收尾、两份布局 sidecar 台账。
|
||||
- 本用例不覆盖(另走专项或定向测试):双窗口 CAS 冲突、大规模 fixture 性能、素材创作无限画布阶段一至五的草稿 / 事务 / 恢复矩阵(见 `【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 与配套专题)、UI 编辑器子路由、主站图片编辑器回归(见 PRD §7.7)。资源替换的后端矩阵(版本绑定改写放行的六条不变式与两组互斥、绑定改写两条路径、硬门禁与提示、CAS、四条拒绝路径、读时自愈口径、审计留痕)见 `apps/ai-game-creator-shell/src-tauri/src/project/manifest/version_binding_rewrite_tests.rs` 与 `apps/ai-game-creator-shell/src-tauri/src/tests/version_resource_replacement.rs`,前端矩阵见 `apps/ai-game-creator-shell/tests/resourceVersionReplacement*.test.ts(x)`。
|
||||
|
||||
@@ -98,6 +98,14 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
submitAriaLabel?: string;
|
||||
submittingStatusLabel?: string;
|
||||
styleControl?: ReactNode;
|
||||
/**
|
||||
* 提示词输入控件的主机注入位(AGC 用它在快速编辑提示词里跑 `@` 引用生成器)。
|
||||
*
|
||||
* 传了它就用宿主控件替换默认的 `AutoGrowTextArea`(宿主自己负责等价的可访问性:
|
||||
* 同一个 `aria-label`、同一份提示词文本);不传时 DOM 与行为逐字不变。
|
||||
* 场景错误提示的 `aria-describedby` 只挂在默认 textarea 上,需要它的调用方不要传本插槽。
|
||||
*/
|
||||
promptInputSlot?: ReactNode;
|
||||
/**
|
||||
* 提示词下方的主机注入位(AGC 用它放「AI 润色」)。
|
||||
*
|
||||
@@ -162,6 +170,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
submitAriaLabel = '生成',
|
||||
submittingStatusLabel = '生成中',
|
||||
styleControl,
|
||||
promptInputSlot,
|
||||
promptActionSlot,
|
||||
}: ImageCanvasBasicGenerationComposerViewProps) {
|
||||
const references = dialog.generationReferences ?? [];
|
||||
@@ -284,27 +293,29 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<AutoGrowTextArea
|
||||
aria-label={resolvedPromptLabel}
|
||||
aria-invalid={sceneContentError ? true : undefined}
|
||||
aria-describedby={
|
||||
sceneContentError ? EDITOR_SCENE_CONTENT_ERROR_ID : undefined
|
||||
}
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder={resolvedPromptPlaceholder}
|
||||
className={promptClassName}
|
||||
onValueChange={(value) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
prompt: value,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{promptInputSlot ?? (
|
||||
<AutoGrowTextArea
|
||||
aria-label={resolvedPromptLabel}
|
||||
aria-invalid={sceneContentError ? true : undefined}
|
||||
aria-describedby={
|
||||
sceneContentError ? EDITOR_SCENE_CONTENT_ERROR_ID : undefined
|
||||
}
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder={resolvedPromptPlaceholder}
|
||||
className={promptClassName}
|
||||
onValueChange={(value) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog
|
||||
? {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
prompt: value,
|
||||
}
|
||||
: currentDialog,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{promptActionSlot}
|
||||
{sceneContentError ? (
|
||||
<p
|
||||
|
||||
@@ -33,6 +33,8 @@ export type ImageCanvasQuickEditPanelViewProps = {
|
||||
onRequestUpload?: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
/** 提示词输入控件的主机注入位(AGC 用它把提示词换成能 `@` 引用素材的输入区)。 */
|
||||
promptInputSlot?: ReactNode;
|
||||
/** 提示词下方的主机注入位(AGC 用它放「AI 润色」);不传时 DOM 不变。 */
|
||||
promptActionSlot?: ReactNode;
|
||||
onSubmit: () => void;
|
||||
@@ -103,6 +105,7 @@ export function ImageCanvasQuickEditPanelView({
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
promptInputSlot,
|
||||
promptActionSlot,
|
||||
onSubmit,
|
||||
}: ImageCanvasQuickEditPanelViewProps) {
|
||||
@@ -166,6 +169,7 @@ export function ImageCanvasQuickEditPanelView({
|
||||
referenceAriaLabelFormatter={(_, index) => `图${index + 1}`}
|
||||
referenceRemoveLabelFormatter={(_, index) => `删除图${index + 1}`}
|
||||
panelClassName="image-canvas-editor__quick-edit-panel image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--quick-edit"
|
||||
promptInputSlot={promptInputSlot}
|
||||
promptActionSlot={promptActionSlot}
|
||||
footerClassName="image-canvas-editor__generation-composer-footer image-canvas-editor__quick-edit-footer"
|
||||
promptClassName="image-canvas-editor__generation-prompt image-canvas-editor__quick-edit-prompt"
|
||||
|
||||
@@ -10,6 +10,15 @@ type UseImageCanvasFloatingOptionDismissOptions = {
|
||||
onDismiss: () => void;
|
||||
/** Escape 关闭后把焦点还给的触发按钮;不传则只关闭不移焦。 */
|
||||
restoreFocusRef?: FloatingOptionBoundaryRef | null;
|
||||
/**
|
||||
* 宿主自己的「点这里不算外部」补充判据。
|
||||
*
|
||||
* 共享 hook 只认识 `image-canvas-editor__portal-menu` 这一类共享弹出层;宿主自己
|
||||
* portal 到 body 的浮层(例如 `@` 资源选择器与它的候选菜单)用它在同一处登记,
|
||||
* 否则点浮层里的候选项会被判成点外部、把宿主面板一起收掉。
|
||||
* 不传时(网页端画布)判定与既有行为逐字一致。
|
||||
*/
|
||||
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
|
||||
};
|
||||
|
||||
function isEventInsideBoundary(
|
||||
@@ -38,6 +47,7 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
boundaryRefs,
|
||||
onDismiss,
|
||||
restoreFocusRef,
|
||||
isInsideExtraOverlay,
|
||||
}: UseImageCanvasFloatingOptionDismissOptions) {
|
||||
useEffect(() => {
|
||||
if (!isOpen || typeof document === 'undefined') {
|
||||
@@ -48,7 +58,8 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
|
||||
if (
|
||||
isEventInsideBoundary(event.target, boundaryRefs) ||
|
||||
isEventInsideFloatingMenu(event.target)
|
||||
isEventInsideFloatingMenu(event.target) ||
|
||||
isInsideExtraOverlay?.(event.target) === true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -80,5 +91,5 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [boundaryRefs, isOpen, onDismiss, restoreFocusRef]);
|
||||
}, [boundaryRefs, isInsideExtraOverlay, isOpen, onDismiss, restoreFocusRef]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user