合并主干 C3 交互改造并接入 C7 版本切换
- 合并 feat/agc-canvas-resource-workbench-v3:资源卡改为选中+浮出工具条、支持多选、删除资源详情面板与 resourceEditorRoute - index.tsx 冲突以 C3 新结构为底:保留本包的素材重命名回调,丢弃被 C3 取消的 focusedResource 详情面板派生状态与浮层 JSX - 重命名入口从已删除的详情面板搬到选中工具条 extraActions,与「分类与标签」并列 - check-config.mjs 的 allowlist 保持主干版本(normalize_local_project_raster_resource 一行已删) - 还原 normalize_local_project_raster_resource Tauri 命令与注册:C3 已在前端接回调用方 - C7:新增 GameRunVersionPicker 运行模块右上角版本入口,无版本不渲染 - C7:版本状态收敛到 WorkspaceLauncherShell 一份,透传给资源画布与 @ 面板;换项目或版本消失时清回最新版本 - C7:currentVersionResourceBindingIds 接入 activeVersionId,复用 resolveActiveIterationVersion 口径,资源卡「当前使用」高亮随版本切换 - C7:切换版本时复用既有 onPlay 预览入口重载画面,不新增运行时资源重映射 - 新增 10 条测试覆盖版本判定口径、版本入口与切换、当前使用高亮、素材重命名链路
This commit is contained in:
@@ -2172,6 +2172,15 @@ pub(crate) async fn archive_failed_local_project_resource_edit(
|
||||
archive_failed_local_project_resource_edit_at(input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn normalize_local_project_raster_resource(
|
||||
input: NormalizeLocalProjectRasterResourceInput,
|
||||
) -> Result<NormalizeLocalProjectRasterResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
normalize_local_project_raster_resource_at(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_canvas_asset(
|
||||
project_path: String,
|
||||
|
||||
@@ -2622,6 +2622,7 @@ fn main() {
|
||||
request_local_project_resource_edit_service_identity_confirmation,
|
||||
confirm_local_project_resource_edit_service_identity,
|
||||
archive_failed_local_project_resource_edit,
|
||||
normalize_local_project_raster_resource,
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
sync_canvas_project_assets,
|
||||
|
||||
@@ -480,6 +480,7 @@ type AppProps = {
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
projectSupervisorOnly?: boolean;
|
||||
planningStartMode?: boolean;
|
||||
activeVersionId?: ProjectSupervisorComponentProps['activeVersionId'];
|
||||
supervisorChatOnly?: boolean;
|
||||
initialSupervisorMessage?: string;
|
||||
initialCreationType?: HomeCreationType | null;
|
||||
@@ -515,6 +516,7 @@ export function App({
|
||||
orchestrationMode = 'professional-dag',
|
||||
projectSupervisorOnly = false,
|
||||
planningStartMode = false,
|
||||
activeVersionId = null,
|
||||
supervisorChatOnly = false,
|
||||
initialSupervisorMessage = '',
|
||||
initialCreationType = null,
|
||||
@@ -11092,10 +11094,11 @@ export function App({
|
||||
const chatProjectAssets = manifest.assets.filter(
|
||||
(asset) => asset.localPath && !asset.localPath.startsWith('.agent/'),
|
||||
);
|
||||
// `@` 面板「当前版本素材」的版本来源。本次只透传 manifest 版本列表,
|
||||
// activeVersionId 传 null 表示回退到 manifest 中最新的版本。
|
||||
// `@` 面板「当前版本素材」的版本来源:版本列表来自 manifest,
|
||||
// 当前版本由工作台壳(`WorkspaceLauncherShell`)持有的那一份状态给出,
|
||||
// 传 `null` 表示回退到 manifest 中最新的版本。
|
||||
const chatProjectVersions = manifest.versions ?? [];
|
||||
const chatActiveVersionId = null;
|
||||
const chatActiveVersionId = activeVersionId;
|
||||
const visibleMainProjectCheckpoints = projectCheckpoints.slice(0, 5);
|
||||
const currentProjectTitle = localProject
|
||||
? manifest.name.trim() || projectNameFromPath(localProject.projectPath)
|
||||
|
||||
@@ -84,6 +84,11 @@ export function WorkspaceLauncherShell({
|
||||
const activeProjectContextRef = useRef(currentProjectContext);
|
||||
const manifestMergeRef = useRef<ProjectManifestMergeState | null>(null);
|
||||
activeProjectContextRef.current = currentProjectContext;
|
||||
/**
|
||||
* C7 当前游戏版本:唯一一份版本状态,同时喂给资源画布(「当前使用」高亮)与
|
||||
* `@` 面板(「当前版本素材」页签)。`null` 表示回退到 manifest 中最新的版本。
|
||||
*/
|
||||
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextTitle =
|
||||
@@ -112,6 +117,22 @@ export function WorkspaceLauncherShell({
|
||||
currentProjectContext?.projectPath,
|
||||
]);
|
||||
|
||||
// 换项目时回到「最新版本」,不把上一个项目的 versionId 带过去。
|
||||
useEffect(() => {
|
||||
setActiveVersionId(null);
|
||||
}, [currentProjectContext?.projectPath]);
|
||||
|
||||
// 选中的版本已经不在了(例如随素材一并删除)时清回「最新版本」,
|
||||
// 否则版本入口会显示最新版本、而资源卡按空态不高亮,两边口径对不上。
|
||||
const projectVersionIds = (currentProjectContext?.manifest.versions ?? [])
|
||||
.map((version) => version.versionId)
|
||||
.join('\u0000');
|
||||
useEffect(() => {
|
||||
if (!activeVersionId) return;
|
||||
if (projectVersionIds.split('\u0000').includes(activeVersionId)) return;
|
||||
setActiveVersionId(null);
|
||||
}, [activeVersionId, projectVersionIds]);
|
||||
|
||||
const applyManifestSnapshot = useCallback(
|
||||
(snapshot: ProjectManifestSnapshot) => {
|
||||
const current = activeProjectContextRef.current;
|
||||
@@ -329,6 +350,8 @@ export function WorkspaceLauncherShell({
|
||||
agentRuntimeSummaries={activeProjectAgentRuntimeSummaries}
|
||||
agentResults={activeProjectAgentResults}
|
||||
planningStartMode={currentProjectContext.startMode === 'planning'}
|
||||
activeVersionId={activeVersionId}
|
||||
onActiveVersionChange={setActiveVersionId}
|
||||
onPlay={() =>
|
||||
requestCurrentProjectPlay(currentProjectContext.projectPath)
|
||||
}
|
||||
@@ -344,6 +367,7 @@ export function WorkspaceLauncherShell({
|
||||
initialSupervisorMessage={currentProjectContext.initialPrompt}
|
||||
initialCreationType={currentProjectContext.creationType}
|
||||
initialAttachments={currentProjectContext.attachments}
|
||||
activeVersionId={activeVersionId}
|
||||
orchestrationMode="single-supervisor"
|
||||
projectSupervisorOnly
|
||||
planningStartMode={
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ProjectSupervisorComponentProps = {
|
||||
orchestrationMode?: 'single-supervisor' | 'professional-dag';
|
||||
projectSupervisorOnly?: boolean;
|
||||
planningStartMode?: boolean;
|
||||
/**
|
||||
* C7 当前游戏版本:由工作台壳持有,supervisor 里的 `@` 面板按它切「当前版本素材」。
|
||||
* `null` 表示回退到 manifest 中最新的版本。
|
||||
*/
|
||||
activeVersionId?: string | null;
|
||||
playRequest?: {
|
||||
projectPath: string;
|
||||
requestId: number;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { GameIterationVersion } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { formatIterationVersionLabel } from './resourceCanvasVersionBindingModel';
|
||||
|
||||
type GameRunVersionPickerProps = {
|
||||
versions: readonly GameIterationVersion[];
|
||||
activeVersionId: string | null;
|
||||
onSelectVersion: (versionId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行模块右上角的版本入口。
|
||||
*
|
||||
* 数据只来自 manifest `versions[]`:没有版本时**不渲染**入口,不伪造版本。
|
||||
* 选择只改「哪个版本是当前版本」这条记录层状态;画面刷新由宿主重载既有预览完成。
|
||||
*/
|
||||
export function GameRunVersionPicker({
|
||||
versions,
|
||||
activeVersionId,
|
||||
onSelectVersion,
|
||||
}: GameRunVersionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (rootRef.current?.contains(event.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('mousedown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (versions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentVersion =
|
||||
versions.find((version) => version.versionId === activeVersionId) ??
|
||||
versions[versions.length - 1];
|
||||
if (!currentVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="game-run-version-picker">
|
||||
<button
|
||||
type="button"
|
||||
className="game-run-version-trigger"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{formatIterationVersionLabel(currentVersion)}
|
||||
</button>
|
||||
{open
|
||||
? createPortal(
|
||||
<div
|
||||
className="game-run-version-menu"
|
||||
role="listbox"
|
||||
aria-label="切换游戏版本"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
// 用视口坐标而不是相对定位:运行画面会缩放,挂在 body 上才不跟着画布跑偏。
|
||||
top: rootRef.current
|
||||
? `${rootRef.current.getBoundingClientRect().bottom + 6}px`
|
||||
: '0',
|
||||
right: rootRef.current
|
||||
? `${Math.max(
|
||||
8,
|
||||
window.innerWidth -
|
||||
rootRef.current.getBoundingClientRect().right,
|
||||
)}px`
|
||||
: '0',
|
||||
}}
|
||||
>
|
||||
{versions.map((version) => {
|
||||
const selected = version.versionId === currentVersion.versionId;
|
||||
return (
|
||||
<button
|
||||
key={version.versionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={`切换到${formatIterationVersionLabel(version)}`}
|
||||
className={selected ? 'is-selected' : undefined}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (selected) return;
|
||||
onSelectVersion(version.versionId);
|
||||
}}
|
||||
>
|
||||
<span>{formatIterationVersionLabel(version)}</span>
|
||||
<small>{version.versionId}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 共享美术画布组件(ImageCanvasSelectedLayerToolbarView / ImageCanvasQuickEditPanelView)
|
||||
* 的 AGC 宿主 chrome。
|
||||
*
|
||||
* 这些 `image-canvas-editor__*` 类名在网页端由全局样式表 src/index.css 提供;AGC 是独立
|
||||
* 应用、不引入那张整站样式表,因此在这里按实际用到的类补一套宿主样式:定位、浮层层级、
|
||||
* 按钮与面板外观。组件本身与交互语义仍全部复用美术画布的实现。
|
||||
*/
|
||||
|
||||
/* 主题变量:与网页端 .image-canvas-editor 作用域下的一组变量保持一致。 */
|
||||
.game-resource-canvas-toolbar-host,
|
||||
.image-canvas-editor__portal-theme,
|
||||
.image-canvas-editor__portal-menu {
|
||||
--image-canvas-brand-accent: var(--platform-accent, #c7653d);
|
||||
--image-canvas-brand-accent-strong: #6f2f21;
|
||||
--image-canvas-brand-fill: var(
|
||||
--platform-button-primary-fill,
|
||||
linear-gradient(135deg, #df7f40, #b95d3a)
|
||||
);
|
||||
--image-canvas-brand-text-on-fill: var(
|
||||
--platform-button-primary-text,
|
||||
#fffaf5
|
||||
);
|
||||
--image-canvas-brand-border: rgba(204, 117, 76, 0.34);
|
||||
--image-canvas-brand-border-strong: rgba(199, 101, 61, 0.64);
|
||||
--image-canvas-brand-border-soft: rgba(226, 203, 184, 0.82);
|
||||
--image-canvas-brand-soft: rgba(234, 204, 179, 0.28);
|
||||
--image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46);
|
||||
--image-canvas-brand-shadow: rgba(182, 98, 63, 0.16);
|
||||
--image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18);
|
||||
}
|
||||
|
||||
/* 选中工具条:贴在资源卡正上方浮出,层级高于卡片与画布标题栏。 */
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
display: inline-flex;
|
||||
max-width: min(560px, 92vw);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.28rem;
|
||||
box-shadow: 0 18px 34px rgba(15, 23, 42, 0.18);
|
||||
transform: translate(-50%, -100%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.375rem;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar
|
||||
button:hover:not([disabled]) {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--image-canvas-brand-border-strong);
|
||||
background: var(--image-canvas-brand-soft);
|
||||
color: var(--image-canvas-brand-accent-strong);
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar
|
||||
button[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar
|
||||
button.image-canvas-editor__floating-toolbar-text-button {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
gap: 0.35rem;
|
||||
padding: 0.34rem 0.62rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar-divider {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 1.1rem;
|
||||
background: var(--image-canvas-brand-border-soft);
|
||||
}
|
||||
|
||||
/* 快速编辑浮层面板:锚在资源卡正下方。 */
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer {
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
width: min(360px, 92vw);
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.75rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 40px rgba(15, 23, 42, 0.22);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__generation-prompt {
|
||||
width: 100%;
|
||||
min-height: 4.5rem;
|
||||
resize: none;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #fffdfa;
|
||||
color: #1f2937;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__reference-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__generation-composer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__option-cluster {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__option-popover-anchor {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__option-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 80;
|
||||
min-width: 12rem;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.6rem;
|
||||
background: #ffffff;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__option-popover-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__option-popover-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__option-popover-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.5rem;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.375rem;
|
||||
background: #fffdfa;
|
||||
color: #334155;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__generation-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--image-canvas-brand-fill);
|
||||
color: var(--image-canvas-brand-text-on-fill);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__generation-submit:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__generate-status {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: #8c6252;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.game-resource-canvas-toolbar-host
|
||||
.image-canvas-editor__floating-toolbar {
|
||||
max-width: 94vw;
|
||||
}
|
||||
|
||||
.game-resource-canvas-toolbar-host .image-canvas-editor__generation-composer {
|
||||
width: 94vw;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* AGC 资源卡快速编辑面板的初始草稿。
|
||||
*
|
||||
* 直接在网页端美术画布的草稿工厂上包一层,保证两个画布的默认模型、比例与尺寸
|
||||
* 取值完全相同;AGC 侧不再自带第二套默认值。
|
||||
*/
|
||||
export function createResourceQuickEditPanelDraft(
|
||||
sourceLayer: CanvasLayer,
|
||||
): QuickEditPanelState {
|
||||
return createQuickEditPanelDraft(sourceLayer, {});
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
CanvasAssetKind,
|
||||
CanvasLayer,
|
||||
CanvasMediaType,
|
||||
} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import type { ImageCanvasSelectedToolbarAction } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
|
||||
import { canonicalProjectedResourceMediaType } from '../../view/project-development/resourceEditModel';
|
||||
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
|
||||
|
||||
/** 快速编辑与改造都走 `derive_local_project_resource`,只接受这三种栅格图片格式。 */
|
||||
const RESOURCE_RASTER_MEDIA_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const CANVAS_ASSET_KINDS = new Set<string>([
|
||||
'audio',
|
||||
'spec',
|
||||
'character',
|
||||
'character-animation',
|
||||
'icon',
|
||||
'icon-spritesheet',
|
||||
'icon-spec',
|
||||
'publication-material',
|
||||
'ui-design',
|
||||
'video',
|
||||
'sound-effect',
|
||||
'background-music',
|
||||
'scene',
|
||||
]);
|
||||
|
||||
export function resourceCanvasMediaType(
|
||||
resource: Pick<ProjectResource, 'mediaType' | 'category' | 'path'>,
|
||||
): CanvasMediaType | undefined {
|
||||
const mediaType = canonicalProjectedResourceMediaType({
|
||||
mediaType: resource.mediaType,
|
||||
path: resource.path,
|
||||
} as ProjectResource);
|
||||
if (mediaType.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
if (mediaType.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
if (mediaType.startsWith('audio/') || resource.category === 'audio') {
|
||||
return 'audio';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resourceCanvasAssetKind(
|
||||
resource: Pick<ProjectResource, 'subtype'>,
|
||||
): CanvasAssetKind | null {
|
||||
return CANVAS_ASSET_KINDS.has(resource.subtype)
|
||||
? (resource.subtype as CanvasAssetKind)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isResourceRasterImage(
|
||||
resource: Pick<ProjectResource, 'mediaType' | 'category' | 'path'>,
|
||||
) {
|
||||
return RESOURCE_RASTER_MEDIA_TYPES.has(
|
||||
canonicalProjectedResourceMediaType({
|
||||
mediaType: resource.mediaType,
|
||||
path: resource.path,
|
||||
} as ProjectResource),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务产物类型的资源只有先被正规化成正式 manifest 素材,才能作为派生源。
|
||||
*
|
||||
* 与 Rust `normalize_local_project_raster_resource_at` 的门禁对齐:必须是已完成
|
||||
* 任务登记在 artifacts 里的栅格图片产物。
|
||||
*/
|
||||
export function canNormalizeResourceIntoManifestAsset(
|
||||
manifest: GameCreationAppManifest,
|
||||
resource: Pick<
|
||||
ProjectResource,
|
||||
'manifestAssetId' | 'producerTaskId' | 'path' | 'mediaType' | 'category'
|
||||
>,
|
||||
) {
|
||||
if (resource.manifestAssetId !== null) {
|
||||
return false;
|
||||
}
|
||||
const producerTaskId = resource.producerTaskId?.trim();
|
||||
if (!producerTaskId || !isResourceRasterImage(resource)) {
|
||||
return false;
|
||||
}
|
||||
return manifest.tasks.some(
|
||||
(task) =>
|
||||
task.id === producerTaskId &&
|
||||
task.status === 'completed' &&
|
||||
task.artifacts.includes(resource.path),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveResourceCanvasToolbarActions(
|
||||
manifest: GameCreationAppManifest,
|
||||
resource: ProjectResource,
|
||||
): ReadonlySet<ImageCanvasSelectedToolbarAction> {
|
||||
const supported = new Set<ImageCanvasSelectedToolbarAction>();
|
||||
const mediaType = resourceCanvasMediaType(resource);
|
||||
const canDeriveFromResource =
|
||||
resource.manifestAssetId !== null ||
|
||||
canNormalizeResourceIntoManifestAsset(manifest, resource);
|
||||
if (mediaType === 'image') {
|
||||
if (isResourceRasterImage(resource) && canDeriveFromResource) {
|
||||
supported.add('quick-edit');
|
||||
}
|
||||
if (canDeriveFromResource) {
|
||||
supported.add('redraw');
|
||||
}
|
||||
if (
|
||||
resourceCanvasAssetKind(resource) === 'character' &&
|
||||
canDeriveFromResource
|
||||
) {
|
||||
supported.add('character-animation');
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
if (
|
||||
(mediaType === 'video' || mediaType === 'audio') &&
|
||||
canDeriveFromResource
|
||||
) {
|
||||
supported.add('redraw');
|
||||
}
|
||||
return supported;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把资源卡投影成美术画布 `CanvasLayer`,让工具条、命中测试与几何判定共用同一套语义。
|
||||
*/
|
||||
export function projectResourceToCanvasLayer({
|
||||
resource,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
src = '',
|
||||
}: {
|
||||
resource: ProjectResource;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
src?: string;
|
||||
}): CanvasLayer {
|
||||
return {
|
||||
id: resource.id,
|
||||
resourceId: resource.id,
|
||||
title: resource.label,
|
||||
src,
|
||||
mediaType: resourceCanvasMediaType(resource),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
zIndex: 0,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: resourceCanvasAssetKind(resource),
|
||||
};
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
GameCreationAppManifest,
|
||||
GameIterationVersion,
|
||||
GameIterationVersionCreatedReason,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
|
||||
import { resolveActiveIterationVersion } from '../project-workspace/resourceReferences';
|
||||
|
||||
/**
|
||||
* 版本创建原因的中文标签口径。运行模块版本入口与资源详情共用同一份映射,
|
||||
* 不在各调用点各写一遍。
|
||||
*/
|
||||
export const ITERATION_VERSION_REASON_LABELS: Record<
|
||||
GameIterationVersionCreatedReason,
|
||||
string
|
||||
> = {
|
||||
initial: '初始版本',
|
||||
'resource-replacement': '资源替换',
|
||||
'agent-revision': '智能体修订',
|
||||
};
|
||||
|
||||
/** 版本在 UI 里的可读标识:创建原因 + 创建时间。 */
|
||||
export function formatIterationVersionLabel(
|
||||
version: Pick<GameIterationVersion, 'createdReason' | 'createdAt'>,
|
||||
) {
|
||||
const reason =
|
||||
ITERATION_VERSION_REASON_LABELS[version.createdReason] ??
|
||||
version.createdReason;
|
||||
const createdAt = new Date(version.createdAt);
|
||||
const time = Number.isFinite(createdAt.getTime())
|
||||
? createdAt.toLocaleString('zh-CN')
|
||||
: String(version.createdAt);
|
||||
return `${reason} · ${time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* C5 / C7 卡片边框:一个资源是否正在被「当前版本」使用。
|
||||
*
|
||||
* 版本绑定是资源记录的权威口径,`slotId` 恒等映射为 `asset:{assetId}`,
|
||||
* `resourceId` 指向 manifest 资产 ID。当前版本由 `activeVersionId` 决定,
|
||||
* 解析口径复用 `@` 面板那一条:传 `null` / 不传时回退到 manifest 中最新的版本。
|
||||
*/
|
||||
export function currentVersionResourceBindingIds(
|
||||
manifest: Pick<GameCreationAppManifest, 'versions'>,
|
||||
activeVersionId: string | null = null,
|
||||
): Set<string> {
|
||||
const currentVersion = resolveActiveIterationVersion(
|
||||
manifest.versions,
|
||||
activeVersionId,
|
||||
);
|
||||
if (!currentVersion) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(
|
||||
currentVersion.resourceBindings.flatMap((binding) =>
|
||||
binding.slotId === `asset:${binding.resourceId}`
|
||||
? [binding.resourceId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isResourceUsedByCurrentVersion(
|
||||
resource: Pick<ProjectResource, 'manifestAssetId'>,
|
||||
currentVersionBindingIds: ReadonlySet<string>,
|
||||
) {
|
||||
return (
|
||||
resource.manifestAssetId !== null &&
|
||||
currentVersionBindingIds.has(resource.manifestAssetId)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@genarrative/image-canvas-react/styles.css';
|
||||
import './styles.css';
|
||||
import './features/resource-canvas/resourceCanvasChrome.css';
|
||||
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
@@ -6862,7 +6862,7 @@ iframe.preview-frame {
|
||||
min-height: var(--resource-card-height);
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 1px solid #eadbd4;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #4e382f;
|
||||
@@ -6892,6 +6892,14 @@ iframe.preview-frame {
|
||||
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
|
||||
}
|
||||
|
||||
/* C5 卡片边框:只有被当前版本绑定使用的素材带边框,其余资源卡无边框。 */
|
||||
.game-resource-card.is-current-version {
|
||||
border: 1px solid #d87342;
|
||||
box-shadow:
|
||||
0 6px 18px rgb(96 62 47 / 10%),
|
||||
0 0 0 2px rgb(216 115 66 / 14%);
|
||||
}
|
||||
|
||||
.game-resource-card.is-relation-version-binding {
|
||||
border-color: #d87342;
|
||||
box-shadow:
|
||||
@@ -6975,7 +6983,7 @@ iframe.preview-frame {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.game-resource-card-open {
|
||||
.game-resource-card-select {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
@@ -6989,7 +6997,7 @@ iframe.preview-frame {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.game-resource-card-open:focus-visible {
|
||||
.game-resource-card-select:focus-visible {
|
||||
outline: 3px solid rgb(213 123 81 / 55%);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
@@ -7661,6 +7669,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-run-surface {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(300px, 1fr) auto;
|
||||
grid-row: 2 / -1;
|
||||
@@ -7671,6 +7680,82 @@ iframe.preview-frame {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */
|
||||
.game-run-version-picker {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.game-run-version-trigger {
|
||||
display: inline-flex;
|
||||
max-width: min(18rem, 60vw);
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e5cfc4;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 250 246 / 92%);
|
||||
color: #8a4a30;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-run-version-trigger:hover,
|
||||
.game-run-version-trigger:focus-visible {
|
||||
border-color: #cc8060;
|
||||
outline: 0;
|
||||
box-shadow: 0 3px 10px rgb(158 87 57 / 14%);
|
||||
}
|
||||
|
||||
.game-run-version-menu {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: min(20rem, 60vh);
|
||||
min-width: 14rem;
|
||||
padding: 6px;
|
||||
border: 1px solid #e5cfc4;
|
||||
border-radius: 12px;
|
||||
background: #fffaf6;
|
||||
box-shadow: 0 12px 28px rgb(120 70 45 / 18%);
|
||||
overflow-y: auto;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.game-run-version-menu button {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 7px 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #50382f;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-run-version-menu button small {
|
||||
color: #a08073;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-run-version-menu button:hover,
|
||||
.game-run-version-menu button:focus-visible {
|
||||
background: #fdeee5;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-run-version-menu button.is-selected {
|
||||
background: #f8e2d6;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-run-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
@@ -15,4 +15,23 @@ interface Window {
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
};
|
||||
// 资源画布复用网页端美术画布的组件,这条依赖链是明确的跨端耦合:
|
||||
// ImageCanvasSelectedLayerToolbarView → ImageCanvasGenerationModel →(值导入
|
||||
// ApiClientError)→ 主仓 src/services/apiClient.ts → src/services/host-bridge/hostBridge.ts。
|
||||
// hostBridge 读取的全局只在主仓 `src/vite-env.d.ts` 里声明过,AGC 侧必须补同名字段
|
||||
// 才能过 strict 检查。以后往 AGC 引 `src/components/image-editor/` 的组件,都要检查这条链。
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (message: string) => void;
|
||||
};
|
||||
wx?: {
|
||||
miniProgram?: {
|
||||
navigateTo?: (options: {
|
||||
url: string;
|
||||
success?: (result?: unknown) => void;
|
||||
fail?: (error: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
postMessage?: (message: unknown) => void;
|
||||
};
|
||||
};
|
||||
WeixinJSBridge?: unknown;
|
||||
}
|
||||
|
||||
@@ -332,35 +332,35 @@ async function submitChat(value: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
}
|
||||
|
||||
// 同一张资源卡上有两个按钮:打开详情与 V3 新增的 @ 引用按钮,二者的可访问名都包含
|
||||
// 资源文件名,因此按文件名正则查询会同时命中两个元素。资源详情按钮的可访问名模板固定为
|
||||
// `打开资源详情:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。
|
||||
// 同一张资源卡上有两个按钮:选中资源与 V3 新增的 @ 引用按钮,二者的可访问名都包含
|
||||
// 资源文件名,因此按文件名正则查询会同时命中两个元素。选中按钮的可访问名模板固定为
|
||||
// `选中资源:<分类标签> <资源文件名>`(分类标签内不含空格),这里按模板做精确匹配。
|
||||
function escapeRegExpLiteral(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function resourceDetailButtonName(label: string) {
|
||||
return new RegExp(`^打开资源详情:\\S+ ${escapeRegExpLiteral(label)}$`);
|
||||
function resourceSelectButtonName(label: string) {
|
||||
return new RegExp(`^选中资源:\\S+ ${escapeRegExpLiteral(label)}$`);
|
||||
}
|
||||
|
||||
function getResourceDetailButton(label: string) {
|
||||
return screen.getByRole('button', { name: resourceDetailButtonName(label) });
|
||||
function getResourceSelectButton(label: string) {
|
||||
return screen.getByRole('button', { name: resourceSelectButtonName(label) });
|
||||
}
|
||||
|
||||
function findResourceDetailButton(
|
||||
function findResourceSelectButton(
|
||||
label: string,
|
||||
options?: { timeout?: number; interval?: number },
|
||||
) {
|
||||
return screen.findByRole(
|
||||
'button',
|
||||
{ name: resourceDetailButtonName(label) },
|
||||
{ name: resourceSelectButtonName(label) },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
function queryResourceDetailButton(label: string) {
|
||||
function queryResourceSelectButton(label: string) {
|
||||
return screen.queryByRole('button', {
|
||||
name: resourceDetailButtonName(label),
|
||||
name: resourceSelectButtonName(label),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1293,10 +1293,10 @@ export {
|
||||
describe,
|
||||
emptyProjectPolicy,
|
||||
expect,
|
||||
findResourceDetailButton,
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||||
getResourceDetailButton,
|
||||
getResourceSelectButton,
|
||||
it,
|
||||
mockRoleAgentReply,
|
||||
nativeClipboardMock,
|
||||
@@ -1304,7 +1304,7 @@ export {
|
||||
pickProjectFromLauncher,
|
||||
ProjectDevelopmentView,
|
||||
projectSupervisorResponseStream,
|
||||
queryResourceDetailButton,
|
||||
queryResourceSelectButton,
|
||||
React,
|
||||
readFileSync,
|
||||
render,
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
createGameCreationAppManifest,
|
||||
createProjectSupervisorRuntimeHarness,
|
||||
expect,
|
||||
findResourceDetailButton,
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
getResourceDetailButton,
|
||||
getResourceSelectButton,
|
||||
it,
|
||||
nativeClipboardMock,
|
||||
pickProjectFromLauncher,
|
||||
queryResourceDetailButton,
|
||||
queryResourceSelectButton,
|
||||
React,
|
||||
render,
|
||||
renderAppAt,
|
||||
@@ -316,12 +316,12 @@ export function registerClientHomeTests() {
|
||||
pickProjectFromLauncher(projectPath);
|
||||
const runButton = await screen.findByRole('tab', { name: '运行' });
|
||||
expect(runButton.getAttribute('data-unavailable')).toBe('true');
|
||||
expect(queryResourceDetailButton('live-hero.png')).toBeNull();
|
||||
expect(queryResourceSelectButton('live-hero.png')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' }));
|
||||
|
||||
await openResourceBookCategory('美术资源');
|
||||
expect(await findResourceDetailButton('live-hero.png')).not.toBeNull();
|
||||
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
|
||||
await openResourceBookCategory('项目版本');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /版本 1/ }),
|
||||
@@ -462,7 +462,7 @@ export function registerClientHomeTests() {
|
||||
pickProjectFromLauncher(projectPath);
|
||||
const runButton = await screen.findByRole('tab', { name: '运行' });
|
||||
expect(runButton.getAttribute('data-unavailable')).toBe('true');
|
||||
expect(queryResourceDetailButton('runtime-live-hero.png')).toBeNull();
|
||||
expect(queryResourceSelectButton('runtime-live-hero.png')).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(runtimeHarness.listen).toHaveBeenCalledWith(
|
||||
'game-creator-manifest-invalidated',
|
||||
@@ -488,7 +488,7 @@ export function registerClientHomeTests() {
|
||||
|
||||
await openResourceBookCategory('美术资源');
|
||||
expect(
|
||||
await findResourceDetailButton('runtime-live-hero.png', {
|
||||
await findResourceSelectButton('runtime-live-hero.png', {
|
||||
timeout: 5_000,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
@@ -663,14 +663,14 @@ export function registerClientHomeTests() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
pickProjectFromLauncher(secondProjectPath);
|
||||
await openResourceBookCategory('美术资源');
|
||||
expect(await findResourceDetailButton('second.png')).not.toBeNull();
|
||||
expect(await findResourceSelectButton('second.png')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveStaleRefresh(staleFirstManifest);
|
||||
await staleRefresh;
|
||||
});
|
||||
expect(queryResourceDetailButton('stale-first.png')).toBeNull();
|
||||
expect(getResourceDetailButton('second.png')).not.toBeNull();
|
||||
expect(queryResourceSelectButton('stale-first.png')).toBeNull();
|
||||
expect(getResourceSelectButton('second.png')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('starts from the client home and opens a project in the same window', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,13 @@ import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import {
|
||||
act,
|
||||
createGameCreationAppManifest,
|
||||
findResourceDetailButton,
|
||||
findResourceSelectButton,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
setComposerText,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const projectPath = '/tmp/live-canvas-integration';
|
||||
@@ -473,39 +475,36 @@ describe('project resource live canvas integration', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('derives a document non-destructively and retries with the original operation identity', async () => {
|
||||
it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => {
|
||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||
render(<DerivedWorkbench />);
|
||||
|
||||
fireEvent.click(await findResourceDetailButton('rules.md'));
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
expect(await screen.findByText('编辑现有资源')).not.toBeNull();
|
||||
fireEvent.change(screen.getByLabelText('编辑提示词'), {
|
||||
target: { value: '把角色头发设定改为红色' },
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开美术资源' }));
|
||||
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', {
|
||||
name: '图片工具栏',
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { 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')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' }));
|
||||
fireEvent.click(within(panel).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');
|
||||
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
|
||||
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
|
||||
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
|
||||
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
|
||||
const operationId = String(deriveCalls[1]?.operationId);
|
||||
expect(
|
||||
await screen.findByRole('dialog', {
|
||||
name: new RegExp(operationId, 'u'),
|
||||
}),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: '打开资源详情:设计文档 rules.md',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => {
|
||||
@@ -529,11 +528,14 @@ describe('project resource live canvas integration', () => {
|
||||
expect(resumeCalls[0]).not.toHaveProperty('prompt');
|
||||
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
|
||||
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
|
||||
// 恢复完成后新素材被选中;不再有"资源详情"面板。
|
||||
expect(
|
||||
await screen.findByRole('dialog', {
|
||||
name: /recovered-rules\.md/,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
(
|
||||
await screen.findByRole('button', {
|
||||
name: /选中资源:\S+ \S*recovered-rules\.md$/,
|
||||
})
|
||||
).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => {
|
||||
@@ -683,8 +685,6 @@ describe('project resource live canvas integration', () => {
|
||||
expect(resumeCalls[0]?.operationId).toBe(resumableOperationId);
|
||||
expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '管理未完成编辑 (2)',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
currentVersionResourceBindingIds,
|
||||
formatIterationVersionLabel,
|
||||
isResourceUsedByCurrentVersion,
|
||||
} from '../src/features/resource-canvas/resourceCanvasVersionBindingModel';
|
||||
|
||||
const versions = [
|
||||
{
|
||||
versionId: 'version-root',
|
||||
parentVersionId: null,
|
||||
projectRevision: 3,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
|
||||
],
|
||||
createdReason: 'initial' as const,
|
||||
createdAt: 1_760_000_000_000,
|
||||
},
|
||||
{
|
||||
versionId: 'version-child',
|
||||
parentVersionId: 'version-root',
|
||||
projectRevision: 4,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-town', resourceId: 'asset-town' },
|
||||
// 悬空绑定的 slotId 不是恒等映射,不参与「当前使用」判定。
|
||||
{ slotId: 'player', resourceId: 'asset-player' },
|
||||
],
|
||||
createdReason: 'agent-revision' as const,
|
||||
createdAt: 1_760_003_600_000,
|
||||
},
|
||||
];
|
||||
|
||||
describe('运行模块版本绑定判定', () => {
|
||||
test('falls back to the newest manifest version when no version is selected', () => {
|
||||
expect(Array.from(currentVersionResourceBindingIds({ versions }))).toEqual([
|
||||
'asset-town',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves the binding ids of the explicitly selected version', () => {
|
||||
expect(
|
||||
Array.from(
|
||||
currentVersionResourceBindingIds({ versions }, 'version-root'),
|
||||
),
|
||||
).toEqual(['asset-player']);
|
||||
});
|
||||
|
||||
test('returns no bindings for a missing version or a manifest without versions', () => {
|
||||
expect(
|
||||
currentVersionResourceBindingIds({ versions }, 'version-missing').size,
|
||||
).toBe(0);
|
||||
expect(currentVersionResourceBindingIds({}).size).toBe(0);
|
||||
expect(currentVersionResourceBindingIds({ versions: [] }).size).toBe(0);
|
||||
});
|
||||
|
||||
test('marks only registered assets bound by the current version as in use', () => {
|
||||
const bindingIds = currentVersionResourceBindingIds(
|
||||
{ versions },
|
||||
'version-root',
|
||||
);
|
||||
|
||||
expect(
|
||||
isResourceUsedByCurrentVersion(
|
||||
{ manifestAssetId: 'asset-player' },
|
||||
bindingIds,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isResourceUsedByCurrentVersion(
|
||||
{ manifestAssetId: 'asset-town' },
|
||||
bindingIds,
|
||||
),
|
||||
).toBe(false);
|
||||
// 未登记为 manifest 素材的资源永远不算「当前使用」。
|
||||
expect(
|
||||
isResourceUsedByCurrentVersion({ manifestAssetId: null }, bindingIds),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('labels a version with its Chinese created reason and creation time', () => {
|
||||
expect(formatIterationVersionLabel(versions[0])).toBe(
|
||||
`初始版本 · ${new Date(1_760_000_000_000).toLocaleString('zh-CN')}`,
|
||||
);
|
||||
expect(formatIterationVersionLabel(versions[1])).toBe(
|
||||
`智能体修订 · ${new Date(1_760_003_600_000).toLocaleString('zh-CN')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,7 @@ import type { GameCreationAppManifest } from '../../../packages/shared/src/contr
|
||||
import {
|
||||
cleanup,
|
||||
createGameCreationAppManifest,
|
||||
findResourceDetailButton,
|
||||
fireEvent,
|
||||
getResourceDetailButton,
|
||||
ProjectDevelopmentView,
|
||||
React,
|
||||
render,
|
||||
@@ -94,7 +92,13 @@ async function openHeroCard(name = 'hero.png') {
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
return findResourceDetailButton(name);
|
||||
// C3 后点卡是「选中 + 浮出工具条」,重命名入口在工具条上。
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: new RegExp(`^选中资源:美术资源 ${name}$`),
|
||||
}),
|
||||
);
|
||||
return screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
}
|
||||
|
||||
function renderWorkbench(
|
||||
@@ -167,9 +171,8 @@ describe('素材重命名前端链路', () => {
|
||||
createManifestWithHero('assets/hero.png'),
|
||||
onManifestChange,
|
||||
);
|
||||
fireEvent.click(await openHeroCard());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重命名' }));
|
||||
const toolbar = await openHeroCard();
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
|
||||
const field = (await screen.findByLabelText(
|
||||
'新文件名',
|
||||
)) as HTMLInputElement;
|
||||
@@ -200,7 +203,11 @@ describe('素材重命名前端链路', () => {
|
||||
|
||||
rendered.rerenderWith(renamedManifest);
|
||||
await waitFor(() => {
|
||||
expect(getResourceDetailButton('hero-v2.png')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: '选中资源:美术资源 hero-v2.png',
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,9 +221,8 @@ describe('素材重命名前端链路', () => {
|
||||
});
|
||||
|
||||
renderWorkbench(createManifestWithHero('assets/hero.png'));
|
||||
fireEvent.click(await openHeroCard());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重命名' }));
|
||||
const toolbar = await openHeroCard();
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
|
||||
const field = (await screen.findByLabelText(
|
||||
'新文件名',
|
||||
)) as HTMLInputElement;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
cleanup,
|
||||
createGameCreationAppManifest,
|
||||
createGameCreationAppSeedTasks,
|
||||
fireEvent,
|
||||
ProjectDevelopmentView,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const PROJECT_PATH = '/tmp/workbench-version-switch';
|
||||
|
||||
function installResourceCardIntersectionObserver() {
|
||||
class ResourceCardIntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '160px';
|
||||
readonly thresholds = [0];
|
||||
readonly observed = new Set<Element>();
|
||||
|
||||
constructor(readonly callback: IntersectionObserverCallback) {}
|
||||
|
||||
observe(element: Element) {
|
||||
this.observed.add(element);
|
||||
}
|
||||
|
||||
unobserve(element: Element) {
|
||||
this.observed.delete(element);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observed.clear();
|
||||
}
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: ResourceCardIntersectionObserver,
|
||||
});
|
||||
}
|
||||
|
||||
function createVersionedManifest(): GameCreationAppManifest {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-version-switch',
|
||||
'版本切换测试',
|
||||
);
|
||||
// 运行模块的可用性与版本入口无关,这里用一条已完成原型任务把它打开。
|
||||
manifest.tasks = createGameCreationAppSeedTasks().map((task) =>
|
||||
task.id === 'code-prototype'
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'asset-player',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/player.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
{
|
||||
id: 'asset-town',
|
||||
kind: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/town.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
manifest.versions = [
|
||||
{
|
||||
versionId: 'version-root',
|
||||
parentVersionId: null,
|
||||
projectRevision: 3,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
|
||||
],
|
||||
createdReason: 'initial',
|
||||
createdAt: 1_760_000_000_000,
|
||||
},
|
||||
{
|
||||
versionId: 'version-child',
|
||||
parentVersionId: 'version-root',
|
||||
projectRevision: 4,
|
||||
resourceBindings: [
|
||||
{ slotId: 'asset:asset-town', resourceId: 'asset-town' },
|
||||
],
|
||||
createdReason: 'agent-revision',
|
||||
createdAt: 1_760_003_600_000,
|
||||
},
|
||||
];
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function renderWorkbench(
|
||||
manifest: GameCreationAppManifest,
|
||||
props: {
|
||||
activeVersionId?: string | null;
|
||||
onActiveVersionChange?: (versionId: string) => void;
|
||||
} = {},
|
||||
) {
|
||||
const { rerender } = render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onPlay: vi.fn(),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
rerenderWith(next: Partial<Record<string, unknown>>) {
|
||||
rerender(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: PROJECT_PATH,
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onPlay: vi.fn(),
|
||||
...props,
|
||||
...next,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openArtCategory() {
|
||||
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
|
||||
const outline = await screen.findByLabelText('资源栏目大纲');
|
||||
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('[data-resource-book-view="child"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
function cardFor(label: string) {
|
||||
return screen
|
||||
.getByRole('button', { name: `选中资源:美术资源 ${label}` })
|
||||
.closest('.game-resource-card');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('C7 运行模块版本切换', () => {
|
||||
test('keeps the version entry hidden without versions and shows the latest one by default', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const manifest = createVersionedManifest();
|
||||
manifest.versions = [];
|
||||
const withoutVersions = renderWorkbench(manifest);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
expect(screen.queryByLabelText(/^当前版本:/)).toBeNull();
|
||||
|
||||
withoutVersions.rerenderWith({ manifest: createVersionedManifest() });
|
||||
const trigger = await screen.findByLabelText(/^当前版本:/);
|
||||
expect(trigger.getAttribute('aria-label')).toContain('智能体修订');
|
||||
});
|
||||
|
||||
test('switches the current version through the entry and reports it to the host', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const manifest = createVersionedManifest();
|
||||
const onActiveVersionChange = vi.fn();
|
||||
renderWorkbench(manifest, { onActiveVersionChange });
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
|
||||
|
||||
const menu = await screen.findByRole('listbox', {
|
||||
name: '切换游戏版本',
|
||||
});
|
||||
expect(within(menu).getAllByRole('option')).toHaveLength(2);
|
||||
// 当前版本(最新的那个)已在菜单里标记为选中。
|
||||
expect(
|
||||
within(menu)
|
||||
.getByRole('option', { name: /智能体修订/ })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
|
||||
fireEvent.click(within(menu).getByRole('option', { name: /初始版本/ }));
|
||||
expect(onActiveVersionChange).toHaveBeenCalledWith('version-root');
|
||||
});
|
||||
|
||||
test('drives the "current use" card highlight from the active version', async () => {
|
||||
installResourceCardIntersectionObserver();
|
||||
const manifest = createVersionedManifest();
|
||||
const rendered = renderWorkbench(manifest);
|
||||
await openArtCategory();
|
||||
|
||||
// 默认当前版本是 manifest 里最新的那个:只有它绑定的资源算「当前使用」。
|
||||
expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
cardFor('player.png')?.classList.contains('is-current-version'),
|
||||
).toBe(false);
|
||||
|
||||
rendered.rerenderWith({ activeVersionId: 'version-root' });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
cardFor('player.png')?.classList.contains('is-current-version'),
|
||||
).toBe(true);
|
||||
});
|
||||
expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// 选中的版本已经不存在时按空态处理(与 `@` 面板同一口径),不残留旧高亮;
|
||||
// 工作台壳会在版本消失时把选择清回「最新版本」。
|
||||
rendered.rerenderWith({ activeVersionId: 'version-removed' });
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
cardFor('town.png')?.classList.contains('is-current-version'),
|
||||
).toBe(false);
|
||||
});
|
||||
expect(
|
||||
cardFor('player.png')?.classList.contains('is-current-version'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5059,3 +5059,12 @@
|
||||
- 原因:`image-canvas-editor__*` 的规则(约 918 条)全部在网页端全局样式表 `src/index.css` 里;AGC 只引入了 `@genarrative/image-canvas-react/styles.css`(178 行,只有 `genarrative-image-canvas__*` 视口样式)和 `apps/ai-game-creator-shell/src/styles.css`。
|
||||
- 处理:口径是“复用组件 + AGC 宿主给样式”——在 `apps/ai-game-creator-shell/src/styles.css` 里为实际用到的 class 补宿主 chrome 样式(浮出定位、层级、按钮外观)。**不要**让 AGC import 网页端 `src/index.css`(整站全局表会连带引入无关 reset 与主题),也不要在同一次改动里做数百条规则的抽取重构。
|
||||
- 关联:`src/index.css`、`packages/image-canvas-react/src/styles.css`、`apps/ai-game-creator-shell/src/styles.css`。
|
||||
|
||||
## AGC 复用网页端 `src/components/image-editor/` 组件会拖进网页端服务层(2026-09-10)
|
||||
|
||||
- 现象:AGC 侧刚把 `ImageCanvasSelectedLayerToolbarView` 引进来,`npm run ai-game-creator-shell:typecheck` 立刻报十几条**不在自己代码里**的类型错误,全部指向主仓 `src/services/host-bridge/hostBridge.ts`(`Property 'wx' / 'ReactNativeWebView' does not exist on type 'Window'`)。
|
||||
- 原因:跨端组件有一条**值级**依赖链:`ImageCanvasSelectedLayerToolbarView` → `ImageCanvasGenerationModel`(`error instanceof ApiClientError` 是值导入,不能退化成 type-only)→ `src/services/apiClient.ts` → `hostBridge.ts`。`hostBridge.ts` 读取的 `wx` / `ReactNativeWebView` / `WeixinJSBridge` 只在主仓 `src/vite-env.d.ts` 里声明过,而 AGC 的 `apps/ai-game-creator-shell/tsconfig.json` 是 `strict: true`,主仓不是。
|
||||
- 处理:在 `apps/ai-game-creator-shell/src/vite-env.d.ts` 的 `interface Window` 上补**与主仓同名同形**的字段(纯类型声明、零运行时影响)。**不要**为了让它过而关闭 AGC 的 `noImplicitAny`(等于为借一个组件把整个 App 的类型门禁降级),**也不要**因此在 AGC 里另写一套平行组件。
|
||||
- 运行时结论(已核实,可放心):`hostBridge.ts` 的模块作用域只有常量与缓存声明,**没有导入即执行的副作用**;`window.wx` 只在函数体内访问,`ReactNativeWebView` 有 `typeof window !== 'undefined'` 守卫。所以这条链被编进 AGC bundle 是惰性的。
|
||||
- 通用规则:**今后任何往 AGC 引 `src/components/image-editor/` 组件的改动,都要先检查这条链**(组件 → `ImageCanvasGenerationModel` → `apiClient` → `hostBridge`),并确认新增的跨端全局在 AGC 侧有声明。
|
||||
- 关联:`apps/ai-game-creator-shell/src/vite-env.d.ts`、`src/vite-env.d.ts`、`src/components/image-editor/ImageCanvasGenerationModel.ts`、`src/services/apiClient.ts`、`src/services/host-bridge/hostBridge.ts`。
|
||||
|
||||
@@ -1301,3 +1301,12 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
|
||||
- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。
|
||||
- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。
|
||||
- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。
|
||||
|
||||
## 2026-09-10 AGC 资源工作台 V3:资源记录口径收敛、删除新口径、引用页签与 AI 润色
|
||||
|
||||
- **版本绑定恒等化**:游戏版本“使用了哪些资源”的权威记录就是 `.agent/manifest.json` 的 `versions[].resourceBindings`;`slotId` 恒为 `asset:{assetId}`,`resourceId` 指向 manifest 资产 ID。运行期资源观察方案(代码注入 / Phaser wrapper / Vite 虚拟模块 / 脚手架哈希校验 / `.agent` 使用侧车)整体撤回并按四不写删除:`project/asset_usage.rs` 与其 16 条测试、`GAME_ITERATION_IDENTITY_BINDING_PREFIX`、`asset_is_bound_to_runtime_slot`、`remove_asset_usage_references` 全部移除,`game_iteration_resource_bindings` 退回纯恒等映射。
|
||||
- **素材删除新口径**:`delete_local_project_asset` 新增必填 `deleteReferencedVersions`,三分支语义(无引用直接删 / 有引用且 false 只删素材并保留悬空绑定 / true 连带删除引用版本)在**同一次 manifest 写入**内原子完成;新增只读命令 `read_local_project_asset_references` 供前端在确认弹窗里列出“被哪些版本使用”。版本数组“只允许追加”的唯一例外由 `mutate_manifest_at_allowing_version_removals` 承担——放行集合按**写入前的 manifest** 求值,其余既有版本仍须原样原序保留、新增只能追加,被放行版本不得出现在追加段(防止“删除后重排”绕过);其它写入路径继续传空集。
|
||||
- **Agent 资源检索投影**:`agc_list_registered_assets` 的投影在 `mediaType` 之后固定新增 `category` 与 `tags`,取值直接透出 manifest 已解析的分类(**不在投影层重新按 kind 派生**,否则会覆盖用户显式分类);不改 manifest 结构、不改对外 OpenAPI 或 SpacetimeDB schema。
|
||||
- **素材重命名**:新增 `rename_local_project_asset`(`project/asset_rename.rs`)。语义是**磁盘文件改名 + manifest `localPath` 更新、资产 `id` 不变**(manifest 没有 `name` 字段,显示名来自 `fileName(localPath)`)。校验覆盖新名非空、不含路径分隔符与 `..`、不跨目录、扩展名必须一致、目标不得已存在、原文件必须是真实普通文件;持项目写锁按“读 manifest → 磁盘改名 → 更新 `localPath` → 写 manifest → 推进 revision”执行,**写失败把文件改回原名**,回滚也失败时两个错误都报出并标记 `reconciliation-required`;同名重命名是空操作。已知边界:**不改游戏源码里对旧 `assets/<name>` 的引用**,不做引用扫描与自动替换。
|
||||
- **聊天引用与润色**:`ResourceReferenceInput` 新增 `activeVersionId?: string | null` 与 `versions?: GameIterationVersion[]` 两个可选 prop;`@` 面板两个页签(当前版本素材 / 全部画布素材)各自持有独立 `{query, filter, selectedResourceIds}`。版本解析口径 `resolveActiveIterationVersion` / `currentIterationVersionAssets`:传 `null` 回退 manifest 最新的版本,悬空绑定自然丢弃,空版本或空绑定显示空态而不报错。新增 `polish_local_project_prompt(prompt, context)`(提示词 `src-tauri/prompts/local-project-prompt-polish.md`),复用与 `suggest_automatic_project_name` 同一条短文本通道:`codex_app_server` 模式走 home direct codex,其余走 `LlmClient` 单轮请求;入参有界(4 000 / 1 000 字符)、输出 2 048 token 有界、空回复一律判失败以保留原文。**计费不自建**:泥点扣费仍在 `server-rs` 的 `/api/llm/chat/completions` 与 `/api/llm/responses` 路由(`prepare_llm_router_billing` / `settle_llm_router_usage`),本次 server-rs 零改动。发送前提醒是 portal 独立弹窗,判据为“提醒未关闭 + 本轮未确认 + 纯文本 trim 后 ≥ 40 字符 + 不以 `/` 开头”,“不再提醒”偏好存本机 `localStorage`,不进 manifest 与后端。
|
||||
- **验证**:AGC 前端全量 1075 passed / 4 skipped / 0 failed;共享美术画布组件 1372 passed;`cargo check --all-targets` 通过;定向 Rust 用例 `asset_delete` 7、`manifest::` 30、`assets::tests` 22、`asset_rename` 9、`local_project_prompt_polish` 4、`bridge_registered_resource` / `bridge_art_resource` 3 全绿;`npm run check:encoding` 与 `git diff --check` 干净。
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Sparkles,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
@@ -19,7 +19,28 @@ import {
|
||||
isQuickEditSupportedLayer,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
/** 选中工具条上的动作标识,供宿主声明自己实际可用的动作集合。 */
|
||||
export type ImageCanvasSelectedToolbarAction =
|
||||
| 'quick-edit'
|
||||
| 'redraw'
|
||||
| 'crop-expand'
|
||||
| 'remove-background'
|
||||
| 'perfect-pixel'
|
||||
| 'split-icon-spritesheet'
|
||||
| 'extract-ui-design'
|
||||
| 'character-animation'
|
||||
| 'download';
|
||||
|
||||
type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
/**
|
||||
* 宿主显式声明的可用动作集合。
|
||||
*
|
||||
* 传 `null`(默认)时完全沿用美术画布内置判定;传入集合时以集合为准,
|
||||
* 让没有对应后端链路的画布宿主不必把「点了没反应」的按钮渲染出来。
|
||||
*/
|
||||
supportedActions?: ReadonlySet<ImageCanvasSelectedToolbarAction> | null;
|
||||
/** 宿主在工具条上追加的动作节点,渲染在下载按钮之前。 */
|
||||
extraActions?: ReactNode;
|
||||
selectedLayer: CanvasLayer | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
onOpenQuickEditPanel: (layer: CanvasLayer) => void;
|
||||
@@ -38,6 +59,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
};
|
||||
|
||||
export function ImageCanvasSelectedLayerToolbarView({
|
||||
supportedActions = null,
|
||||
extraActions,
|
||||
selectedLayer,
|
||||
selectedToolbarStyle,
|
||||
onOpenQuickEditPanel,
|
||||
@@ -57,7 +80,20 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
if (!selectedLayer || !selectedToolbarStyle) {
|
||||
return null;
|
||||
}
|
||||
const canRedraw = canOpenRedrawPanel(selectedLayer);
|
||||
const isActionSupported = (
|
||||
action: ImageCanvasSelectedToolbarAction,
|
||||
builtInAvailability: boolean,
|
||||
) => (supportedActions ? supportedActions.has(action) : builtInAvailability);
|
||||
const canRedraw = isActionSupported(
|
||||
'redraw',
|
||||
canOpenRedrawPanel(selectedLayer),
|
||||
);
|
||||
const extraActionDivider = extraActions ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (selectedLayer.mediaType === 'audio') {
|
||||
return (
|
||||
@@ -79,12 +115,16 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
<span>改造</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
{extraActionDivider}
|
||||
{extraActions}
|
||||
{isActionSupported('download', true) ? (
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +133,14 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
selectedLayer.mediaType !== 'video' &&
|
||||
selectedLayer.mediaType !== 'image-sequence' &&
|
||||
selectedLayer.assetKind !== 'character-animation';
|
||||
const showQuickEdit = isActionSupported(
|
||||
'quick-edit',
|
||||
isQuickEditSupportedLayer(selectedLayer),
|
||||
);
|
||||
const showCharacterAnimation = isActionSupported(
|
||||
'character-animation',
|
||||
selectedLayer.assetKind === 'character',
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -102,7 +150,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-label="图片工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isQuickEditSupportedLayer(selectedLayer) ? (
|
||||
{showQuickEdit ? (
|
||||
<>
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
@@ -119,74 +167,77 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{canRasterEdit ? (
|
||||
<>
|
||||
<EditorIconButton
|
||||
label="裁扩按钮"
|
||||
title="裁扩按钮"
|
||||
icon={Crop}
|
||||
onClick={() => onOpenCropExpandPanel(selectedLayer)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label="去除背景按钮"
|
||||
title="去除背景按钮"
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '完美像素结果待确认'
|
||||
: '完美像素'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '结果待确认,双击原占位继续核对或重试'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
// 与相邻的拆分图集按钮保持同一套门禁。
|
||||
disabled={
|
||||
isPersistingAssetKind ||
|
||||
isPerfectPixelProcessing ||
|
||||
isPerfectPixelPendingConfirmation
|
||||
}
|
||||
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isPerfectPixelProcessing
|
||||
? '处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
{canRasterEdit && isActionSupported('crop-expand', true) ? (
|
||||
<EditorIconButton
|
||||
label="裁扩按钮"
|
||||
title="裁扩按钮"
|
||||
icon={Crop}
|
||||
onClick={() => onOpenCropExpandPanel(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
{canRasterEdit && isActionSupported('remove-background', true) ? (
|
||||
<EditorIconButton
|
||||
label="去除背景按钮"
|
||||
title="去除背景按钮"
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{canRasterEdit && isActionSupported('perfect-pixel', true) ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '完美像素结果待确认'
|
||||
: '完美像素'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '结果待确认,双击原占位继续核对或重试'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
// 与相邻的拆分图集按钮保持同一套门禁。
|
||||
disabled={
|
||||
isPersistingAssetKind ||
|
||||
isPerfectPixelProcessing ||
|
||||
isPerfectPixelPendingConfirmation
|
||||
}
|
||||
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isPerfectPixelProcessing
|
||||
? '处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' &&
|
||||
isActionSupported('split-icon-spritesheet', true) ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={
|
||||
@@ -223,7 +274,8 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
{selectedLayer.assetKind === 'ui-design' &&
|
||||
isActionSupported('extract-ui-design', true) ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="提取素材"
|
||||
@@ -234,7 +286,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
<span>提取素材</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'character' ? (
|
||||
{showCharacterAnimation ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="生成动画"
|
||||
@@ -262,12 +314,16 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
{extraActionDivider}
|
||||
{extraActions}
|
||||
{isActionSupported('download', true) ? (
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadLayer(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ export async function navigateWechatMiniProgramPage(
|
||||
success() {
|
||||
resolve();
|
||||
},
|
||||
fail(_error) {
|
||||
fail(_error: unknown) {
|
||||
console.error('[host-bridge] wechat mini program navigation failed');
|
||||
reject(new Error(errorMessage));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user