修复开放Issue 115、118、127和128
Project CI / Frontend tests (push) Failing after 21s
Project CI / Repository checks (push) Successful in 58s
Project CI / Native shell tests (push) Failing after 3m7s
Project CI / Backend tests (push) Successful in 3m33s

补齐Anthropic strict能力门控、传输schema、缓存usage和真实端点验收
隔离可选MCP服务目录失败并安全重定位局部schema引用
修复PowerShell 7环境下Windows私有ACL检查
统一角色与图标素材落库的用户prompt语义
合并权威画布快照与本地待保存或在途布局并补齐回归
同步更新技术文档、项目记忆和验证门禁
This commit is contained in:
2026-08-03 14:03:20 +08:00
parent 38c43b708a
commit 6fe646ba2b
18 changed files with 2185 additions and 185 deletions
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ import { ApiClientError } from '../../services/apiClient';
import { uploadEditorMediaAssetObjectFile } from '../../services/image-editor/editorMediaAssetUploadClient';
import {
createEditorProjectResource,
type EditorProjectLayerSnapshot,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
@@ -75,6 +76,12 @@ type PendingProjectLayoutSave = {
transportRetries?: number;
};
type ActiveProjectLayoutSaveAttempt = {
save: PendingProjectLayoutSave;
authorityEpoch: number;
ownerUserId: string | null | undefined;
};
type CachedEditorProjectSnapshot = {
project: EditorProjectSnapshot;
cachedAt: number;
@@ -87,6 +94,102 @@ type ApplyProjectSnapshotOptions = {
allowProjectSwitch?: boolean;
};
function canvasLayoutItemId(item: EditorProjectLayerSnapshot) {
return typeof item.layerId === 'string' ? item.layerId : null;
}
function mergePendingCanvasLayerLayout(
authoritative: EditorProjectLayerSnapshot,
pending: EditorProjectLayerSnapshot,
): EditorProjectLayerSnapshot {
if (pending.itemType === 'canvas-settings') {
return pending;
}
if (
authoritative.itemType === 'generation-dialog' &&
pending.itemType === 'generation-dialog'
) {
const authoritativeDialog = (
authoritative as { dialog?: CanvasGenerationDialogState }
).dialog;
const pendingDialog = (pending as { dialog?: CanvasGenerationDialogState })
.dialog;
if (authoritativeDialog && pendingDialog) {
return {
...authoritative,
dialog: {
...authoritativeDialog,
...pendingDialog,
status: authoritativeDialog.status,
generatedLayerId: authoritativeDialog.generatedLayerId,
errorMessage: authoritativeDialog.errorMessage,
generationStartedAt: authoritativeDialog.generationStartedAt,
generationFinishedAt: authoritativeDialog.generationFinishedAt,
characterAnimationResult:
authoritativeDialog.characterAnimationResult,
},
};
}
return pending;
}
return {
...authoritative,
// pendingItems 来自 serializeCanvasLayout,这些布局字段始终存在;直接
// 覆盖才能保留 false / undefined 代表的解锁、取消分组和取消翻转。
title: pending.title,
x: pending.x,
y: pending.y,
width: pending.width,
height: pending.height,
originalWidth: pending.originalWidth,
originalHeight: pending.originalHeight,
zIndex: pending.zIndex,
groupId: pending.groupId,
hidden: pending.hidden,
locked: pending.locked,
flipX: pending.flipX,
flipY: pending.flipY,
};
}
export function mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
authoritativeItems,
pendingItems,
previousAuthoritativeItemIds,
}: {
authoritativeItems: EditorProjectLayerSnapshot[];
pendingItems: EditorProjectLayerSnapshot[];
previousAuthoritativeItemIds: ReadonlySet<string>;
}) {
const authoritativeById = new Map(
authoritativeItems.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [[id, item] as const] : [];
}),
);
const pendingIds = new Set<string>();
const merged = pendingItems.flatMap((pendingItem) => {
const id = canvasLayoutItemId(pendingItem);
if (!id) {
return [pendingItem];
}
pendingIds.add(id);
const authoritativeItem = authoritativeById.get(id);
if (authoritativeItem) {
return [mergePendingCanvasLayerLayout(authoritativeItem, pendingItem)];
}
return previousAuthoritativeItemIds.has(id) ? [] : [pendingItem];
});
for (const authoritativeItem of authoritativeItems) {
const id = canvasLayoutItemId(authoritativeItem);
if (!id || pendingIds.has(id) || previousAuthoritativeItemIds.has(id)) {
continue;
}
merged.push(authoritativeItem);
}
return merged;
}
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
@@ -394,6 +497,7 @@ export function useImageCanvasProjectPersistence({
const authoritativeProjectIdRef = useRef<string | null>(null);
const hasAuthoritativeProjectSnapshotRef = useRef(false);
const acceptedAuthoritativeSnapshotSequenceRef = useRef(0);
const authoritativeLayoutItemIdsRef = useRef<Set<string>>(new Set());
const lastAuthoritativeOwnerUserIdRef = useRef<string | null | undefined>(
undefined,
);
@@ -418,6 +522,8 @@ export function useImageCanvasProjectPersistence({
);
const isProjectLayoutSaveRunningRef = useRef(false);
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
const activeProjectLayoutSaveAttemptRef =
useRef<ActiveProjectLayoutSaveAttempt | null>(null);
const skipNextProjectLayoutSaveRef = useRef(false);
const saveTimerRef = useRef<number | null>(null);
const projectTitleRef = useRef('未命名画布');
@@ -478,6 +584,12 @@ export function useImageCanvasProjectPersistence({
...pendingSave,
attemptExpectedRevision: expectedRevision,
};
const activeAttempt: ActiveProjectLayoutSaveAttempt = {
save: attemptedSave,
authorityEpoch: saveAuthorityEpoch,
ownerUserId: saveOwnerUserId,
};
activeProjectLayoutSaveAttemptRef.current = activeAttempt;
const saveStillBelongsToCurrentAuthority = () =>
projectAuthorityEpochRef.current === saveAuthorityEpoch &&
currentUserIdRef.current === saveOwnerUserId &&
@@ -492,11 +604,15 @@ export function useImageCanvasProjectPersistence({
if (
saveStillBelongsToCurrentAuthority() &&
result &&
typeof result.revision === 'number'
typeof result.revision === 'number' &&
result.revision > (projectRevisionRef.current ?? -1)
) {
projectRevisionRef.current = Math.max(
projectRevisionRef.current ?? 0,
result.revision,
projectRevisionRef.current = result.revision;
authoritativeLayoutItemIdsRef.current = new Set(
attemptedSave.input.layers.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [id] : [];
}),
);
}
runNextSave = Boolean(pendingProjectLayoutSaveRef.current);
@@ -518,6 +634,7 @@ export function useImageCanvasProjectPersistence({
return;
}
applyProjectSnapshotRef.current?.(latestProject);
runNextSave = Boolean(pendingProjectLayoutSaveRef.current);
};
const scheduleAuthoritativeReload = (retryCount: number) => {
if (
@@ -588,6 +705,9 @@ export function useImageCanvasProjectPersistence({
}
})
.finally(() => {
if (activeProjectLayoutSaveAttemptRef.current === activeAttempt) {
activeProjectLayoutSaveAttemptRef.current = null;
}
if (activeProjectLayoutSavePromiseRef.current === savePromise) {
activeProjectLayoutSavePromiseRef.current = null;
}
@@ -1049,6 +1169,20 @@ export function useImageCanvasProjectPersistence({
}
acceptedAuthoritativeSnapshotSequenceRef.current += 1;
}
const pendingSave = pendingProjectLayoutSaveRef.current;
const activeSaveAttempt = activeProjectLayoutSaveAttemptRef.current;
const pendingLocalLayout = authoritative
? pendingSave?.projectId === project.projectId
? pendingSave
: activeSaveAttempt?.save.projectId === project.projectId &&
activeSaveAttempt.authorityEpoch ===
projectAuthorityEpochRef.current &&
activeSaveAttempt.ownerUserId === currentUserIdRef.current
? activeSaveAttempt.save
: null
: null;
const previousAuthoritativeItemIds =
authoritativeLayoutItemIdsRef.current;
clearPendingProjectLayoutSave();
skipNextProjectLayoutSaveRef.current = true;
if (projectIdRef.current !== project.projectId) {
@@ -1075,7 +1209,25 @@ export function useImageCanvasProjectPersistence({
projectTitleRef.current = nextProjectTitle;
setProjectTitle(nextProjectTitle);
setProjectRenameValue(nextProjectTitle);
const nextViewport = canvasDisplayViewportToViewport(project.viewport);
const appliedLayoutItems = pendingLocalLayout
? mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
authoritativeItems: project.layers,
pendingItems: pendingLocalLayout.input.layers,
previousAuthoritativeItemIds,
})
: project.layers;
if (authoritative) {
authoritativeLayoutItemIdsRef.current = new Set(
project.layers.flatMap((item) => {
const id = canvasLayoutItemId(item);
return id ? [id] : [];
}),
);
}
const appliedViewport = pendingLocalLayout
? pendingLocalLayout.input.viewport
: project.viewport;
const nextViewport = canvasDisplayViewportToViewport(appliedViewport);
setViewport(nextViewport);
refs.viewportRef.current = nextViewport;
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
@@ -1105,7 +1257,11 @@ export function useImageCanvasProjectPersistence({
]),
);
const { layerItems, generationDialogs, canvasBackgroundColor } =
splitCanvasLayoutItems(project.layers, resourcesById, currentUserId);
splitCanvasLayoutItems(
appliedLayoutItems,
resourcesById,
currentUserId,
);
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.filter((layer): layer is CanvasLayer => Boolean(layer));
@@ -1124,10 +1280,22 @@ export function useImageCanvasProjectPersistence({
authoritativeProjectIdRef.current === project.projectId;
if (projectIsAuthoritative) {
writeEditorProjectSessionCache(
project,
pendingLocalLayout
? {
...project,
viewport: appliedViewport,
layers: appliedLayoutItems,
}
: project,
currentUserId,
project.canvas?.revision,
);
if (pendingLocalLayout) {
queueProjectLayoutSave(project.projectId, {
viewport: appliedViewport,
layers: appliedLayoutItems,
});
}
}
return projectIsAuthoritative;
},
@@ -1135,6 +1303,7 @@ export function useImageCanvasProjectPersistence({
applyCanvasBackgroundColor,
clearPendingProjectLayoutSave,
currentUserId,
queueProjectLayoutSave,
refs,
restoreCanvasGenerationDialogs,
selectSingleLayer,
@@ -1171,6 +1340,8 @@ export function useImageCanvasProjectPersistence({
hasAuthoritativeProjectSnapshotRef.current = false;
authoritativeProjectIdRef.current = null;
projectRevisionRef.current = null;
authoritativeLayoutItemIdsRef.current = new Set();
activeProjectLayoutSaveAttemptRef.current = null;
pendingCreatedProjectResourceLayersRef.current = [];
clearPendingProjectLayoutSave();
setIsProjectReady(false);
@@ -1179,6 +1350,8 @@ export function useImageCanvasProjectPersistence({
hasAuthoritativeProjectSnapshotRef.current = false;
authoritativeProjectIdRef.current = null;
projectRevisionRef.current = null;
authoritativeLayoutItemIdsRef.current = new Set();
activeProjectLayoutSaveAttemptRef.current = null;
clearPendingProjectLayoutSave();
setIsProjectReady(false);
let cancelled = false;