修复资源画板生成事务与快速编辑边界
生成失败时持久化失败或对账状态,避免遗留 running 任务 增加候选落盘与 archive-pending 可恢复事务阶段 收紧 Host Port 必选方法并统一 unsupported-capability 合同 钳制快速编辑卡 viewport 定位并补齐回归测试与技术合同 修正认证恢复和候选生成测试断言
This commit is contained in:
@@ -184,6 +184,7 @@ enum GenerationLedgerPhase {
|
||||
CandidateReady,
|
||||
AssetDurableCommitted,
|
||||
Failed,
|
||||
ArchivePending,
|
||||
Archived,
|
||||
ReconciliationRequired,
|
||||
}
|
||||
@@ -201,6 +202,7 @@ impl GenerationLedgerPhase {
|
||||
Self::CandidateReady => "candidate-ready",
|
||||
Self::AssetDurableCommitted => "asset-durable-committed",
|
||||
Self::Failed => "failed",
|
||||
Self::ArchivePending => "archive-pending",
|
||||
Self::Archived => "archived",
|
||||
Self::ReconciliationRequired => "reconciliation-required",
|
||||
}
|
||||
@@ -855,6 +857,7 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option<AssetCanvasGenerationSt
|
||||
Some(AssetCanvasGenerationStatus::AssetDurableCommitted)
|
||||
}
|
||||
GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed),
|
||||
GenerationLedgerPhase::ArchivePending => None,
|
||||
GenerationLedgerPhase::Archived => None,
|
||||
GenerationLedgerPhase::ReconciliationRequired => {
|
||||
Some(AssetCanvasGenerationStatus::ReconciliationRequired)
|
||||
@@ -979,9 +982,15 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<()
|
||||
}
|
||||
}
|
||||
match (&ledger.phase, ledger.archived_at) {
|
||||
(GenerationLedgerPhase::Archived, Some(archived_at)) => {
|
||||
(
|
||||
GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived,
|
||||
Some(archived_at),
|
||||
) => {
|
||||
validate_safe_revision(archived_at, "archivedAt")?;
|
||||
}
|
||||
(GenerationLedgerPhase::ArchivePending, None) => {
|
||||
return Err("待归档的素材画布生成账本缺少 archivedAt".to_string());
|
||||
}
|
||||
(GenerationLedgerPhase::Archived, None) => {
|
||||
return Err("已归档的素材画布生成账本缺少 archivedAt".to_string());
|
||||
}
|
||||
@@ -2948,6 +2957,35 @@ fn preserve_submit_reconciliation(
|
||||
))
|
||||
}
|
||||
|
||||
fn finish_archive_pending_at(
|
||||
root: &Path,
|
||||
ledger: &mut AssetCanvasGenerationLedger,
|
||||
) -> Result<AssetCanvasDraft, String> {
|
||||
if ledger.phase != GenerationLedgerPhase::ArchivePending {
|
||||
return Err("素材画布生成账本不在待归档阶段".to_string());
|
||||
}
|
||||
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
|
||||
let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
|
||||
.ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?;
|
||||
if let Some(index) = draft
|
||||
.generations
|
||||
.iter()
|
||||
.position(|record| record.generation_id == ledger.generation_id)
|
||||
{
|
||||
draft.generations.remove(index);
|
||||
draft.revision = draft
|
||||
.revision
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
|
||||
validate_safe_revision(draft.revision, "草稿 revision")?;
|
||||
draft.updated_at = asset_canvas_now();
|
||||
write_asset_canvas_draft_locked(root, &draft)?;
|
||||
}
|
||||
ledger.phase = GenerationLedgerPhase::Archived;
|
||||
write_generation_ledger(root, ledger)?;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
async fn reconcile_generation(
|
||||
root: &Path,
|
||||
mut ledger: AssetCanvasGenerationLedger,
|
||||
@@ -2965,6 +3003,10 @@ async fn reconcile_generation(
|
||||
if ledger.phase == GenerationLedgerPhase::Archived {
|
||||
return Err("素材画布失败生成已归档".to_string());
|
||||
}
|
||||
if ledger.phase == GenerationLedgerPhase::ArchivePending {
|
||||
finish_archive_pending_at(root, &mut ledger)?;
|
||||
return Err("素材画布失败生成已归档".to_string());
|
||||
}
|
||||
migrate_retryable_credential_failure(root, &mut ledger)?;
|
||||
if ledger.phase == GenerationLedgerPhase::Failed {
|
||||
return Err(sanitized_generation_error(
|
||||
@@ -3039,8 +3081,7 @@ async fn reconcile_generation(
|
||||
(false, "platform-service-configuration")
|
||||
};
|
||||
if code == "authentication-required" {
|
||||
ledger.error_code = Some(code.to_string());
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
mark_generation_error(root, &mut ledger, true, code, emit)?;
|
||||
} else {
|
||||
mark_generation_error(root, &mut ledger, false, code, emit)?;
|
||||
}
|
||||
@@ -3055,8 +3096,7 @@ async fn reconcile_generation(
|
||||
{
|
||||
let code = error.code();
|
||||
if code == "authentication-required" {
|
||||
ledger.error_code = Some(code.to_string());
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
mark_generation_error(root, &mut ledger, true, code, emit)?;
|
||||
} else {
|
||||
mark_generation_error(root, &mut ledger, false, code, emit)?;
|
||||
}
|
||||
@@ -3339,13 +3379,13 @@ async fn reconcile_generation(
|
||||
"candidate-reconciliation-required",
|
||||
));
|
||||
}
|
||||
let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?;
|
||||
set_private_phase(
|
||||
root,
|
||||
&mut ledger,
|
||||
GenerationLedgerPhase::CandidateReady,
|
||||
None,
|
||||
)?;
|
||||
let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?;
|
||||
emit(generation_progress_event(
|
||||
&ledger,
|
||||
public_phase_name(&generation.phase),
|
||||
@@ -3613,11 +3653,23 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
|
||||
draft,
|
||||
});
|
||||
}
|
||||
if draft.revision != input.expected_draft_revision {
|
||||
return Err("draft-revision-conflict".to_string());
|
||||
if ledger.phase == GenerationLedgerPhase::ArchivePending && generation_index.is_none() {
|
||||
ledger.phase = GenerationLedgerPhase::Archived;
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
return Ok(ArchiveAssetCanvasGenerationResult {
|
||||
generation_id: ledger.generation_id,
|
||||
phase: GenerationLedgerPhase::Archived.as_str().to_string(),
|
||||
archived_at: ledger
|
||||
.archived_at
|
||||
.ok_or_else(|| "待归档的素材画布生成账本缺少 archivedAt".to_string())?,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
let generation_index =
|
||||
generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?;
|
||||
if draft.revision != input.expected_draft_revision {
|
||||
return Err("draft-revision-conflict".to_string());
|
||||
}
|
||||
let public_generation = &draft.generations[generation_index];
|
||||
if public_generation.intent_id != ledger.intent_id {
|
||||
return Err("待归档的素材画布公开生成记录身份无效".to_string());
|
||||
@@ -3627,10 +3679,11 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
|
||||
if public_generation.phase != AssetCanvasGenerationStatus::Failed {
|
||||
return Err("只有公开状态明确失败的素材画布生成可以归档".to_string());
|
||||
}
|
||||
ledger.phase = GenerationLedgerPhase::Archived;
|
||||
ledger.phase = GenerationLedgerPhase::ArchivePending;
|
||||
ledger.archived_at = Some(asset_canvas_now());
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
}
|
||||
GenerationLedgerPhase::ArchivePending => {}
|
||||
GenerationLedgerPhase::Archived => {}
|
||||
GenerationLedgerPhase::ReconciliationRequired => {
|
||||
return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string());
|
||||
@@ -3649,6 +3702,10 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
|
||||
validate_safe_revision(draft.revision, "草稿 revision")?;
|
||||
draft.updated_at = asset_canvas_now();
|
||||
write_asset_canvas_draft_locked(root, &draft)?;
|
||||
if ledger.phase == GenerationLedgerPhase::ArchivePending {
|
||||
ledger.phase = GenerationLedgerPhase::Archived;
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
}
|
||||
Ok(ArchiveAssetCanvasGenerationResult {
|
||||
generation_id: ledger.generation_id,
|
||||
phase: ledger.phase.as_str().to_string(),
|
||||
@@ -3722,12 +3779,19 @@ pub(crate) async fn recover_asset_canvas_generations_at(
|
||||
}
|
||||
let mut recoverable_generation_ids = Vec::new();
|
||||
for generation_id in generation_ids {
|
||||
let Some(ledger) = read_generation_ledger(root, &generation_id)? else {
|
||||
let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else {
|
||||
continue;
|
||||
};
|
||||
if ledger.project_id == input.expected_project_id
|
||||
&& ledger.draft_id == input.draft_id
|
||||
&& ledger.phase != GenerationLedgerPhase::AssetDurableCommitted
|
||||
if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id {
|
||||
continue;
|
||||
}
|
||||
if ledger.phase == GenerationLedgerPhase::ArchivePending {
|
||||
let _guard =
|
||||
generation_singleflight_lock(&input.expected_project_id, &generation_id).await;
|
||||
let _ = finish_archive_pending_at(root, &mut ledger);
|
||||
continue;
|
||||
}
|
||||
if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted
|
||||
&& (ledger.phase != GenerationLedgerPhase::Failed
|
||||
|| retryable_credential_failure_phase(&ledger).is_some())
|
||||
{
|
||||
@@ -4989,6 +5053,60 @@ mod tests {
|
||||
.generations
|
||||
.iter()
|
||||
.any(|record| record.generation_id == input.generation_id));
|
||||
|
||||
let mut pending = archived_ledger.clone();
|
||||
pending.phase = GenerationLedgerPhase::ArchivePending;
|
||||
pending.archived_at = Some(archived.archived_at);
|
||||
let mut pending_draft = archived.draft.clone();
|
||||
pending_draft.generations.push(AssetCanvasGenerationRecord {
|
||||
generation_id: input.generation_id.clone(),
|
||||
intent_id: input.intent_id.clone(),
|
||||
phase: AssetCanvasGenerationStatus::Failed,
|
||||
reference_resource_ids: Vec::new(),
|
||||
output_asset_id: None,
|
||||
source_layer_id: None,
|
||||
placeholder: None,
|
||||
error_code: Some("generation-rejected".to_string()),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
idempotency_key: None,
|
||||
status: None,
|
||||
prompt: None,
|
||||
operation_id: None,
|
||||
output_media_ids: Vec::new(),
|
||||
});
|
||||
pending_draft.revision += 1;
|
||||
write_asset_canvas_draft_locked(directory.path(), &pending_draft)
|
||||
.expect("write pending archive draft");
|
||||
write_generation_ledger(directory.path(), &mut pending)
|
||||
.expect("write pending archive ledger");
|
||||
let pending_recovery = recover_asset_canvas_generations_at(
|
||||
directory.path(),
|
||||
&RecoverAssetCanvasGenerationsInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
draft_id: draft.draft_id.clone(),
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
.expect("recover pending archive");
|
||||
assert!(pending_recovery.result.resumed_generation_ids.is_empty());
|
||||
assert_eq!(
|
||||
read_generation_ledger(directory.path(), &input.generation_id)
|
||||
.expect("read recovered pending archive ledger")
|
||||
.expect("recovered pending archive ledger exists")
|
||||
.phase,
|
||||
GenerationLedgerPhase::Archived
|
||||
);
|
||||
assert!(
|
||||
!read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
|
||||
.expect("read recovered pending archive draft")
|
||||
.expect("recovered pending archive draft exists")
|
||||
.generations
|
||||
.iter()
|
||||
.any(|record| record.generation_id == input.generation_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -6686,12 +6804,12 @@ mod tests {
|
||||
resumed.platform_owner_user_id.as_deref(),
|
||||
Some("original-login-owner")
|
||||
);
|
||||
assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted);
|
||||
assert_eq!(resumed.phase, GenerationLedgerPhase::CandidateReady);
|
||||
assert_eq!(resumed.error_code, None);
|
||||
assert!(resumed.commit_result.is_some());
|
||||
assert!(resumed.commit_result.is_none());
|
||||
let manifest =
|
||||
current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest");
|
||||
assert_eq!(manifest.assets.len(), 1);
|
||||
assert!(manifest.assets.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -185,6 +185,36 @@ export function generationAspectRatioForOriginalImage(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveQuickEditPanelPosition(input: {
|
||||
anchorX: number;
|
||||
belowTop: number;
|
||||
aboveTop: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
panelSize: { width: number; height: number };
|
||||
edgePadding?: number;
|
||||
}) {
|
||||
const edgePadding = input.edgePadding ?? 12;
|
||||
const panelWidth = Math.max(0, input.panelSize.width);
|
||||
const panelHeight = Math.max(0, input.panelSize.height);
|
||||
const minCenterX = edgePadding + panelWidth / 2;
|
||||
const maxCenterX = Math.max(
|
||||
minCenterX,
|
||||
input.canvasSize.width - edgePadding - panelWidth / 2,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
edgePadding,
|
||||
input.canvasSize.height - edgePadding - panelHeight,
|
||||
);
|
||||
const top =
|
||||
input.belowTop + panelHeight <= input.canvasSize.height - edgePadding
|
||||
? input.belowTop
|
||||
: input.aboveTop;
|
||||
return {
|
||||
left: Math.min(Math.max(input.anchorX, minCenterX), maxCenterX),
|
||||
top: Math.min(Math.max(top, edgePadding), maxTop),
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeGenerationTask = {
|
||||
generationId: string;
|
||||
sourceLayerId: string | null;
|
||||
@@ -765,7 +795,12 @@ export function AssetCanvasSurface({
|
||||
const [documentVersion, setDocumentVersion] = useState(0);
|
||||
const [recoveryReloadToken, setRecoveryReloadToken] = useState(0);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
|
||||
const [quickEditPanelSize, setQuickEditPanelSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const viewportElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const quickEditPanelRef = useRef<HTMLElement | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
@@ -1629,7 +1664,6 @@ export function AssetCanvasSurface({
|
||||
|
||||
const handleLocalImport = useCallback(async () => {
|
||||
if (
|
||||
!host.asset.importLocalImages ||
|
||||
lifecycleRef.current.kind !== 'canvas.editing' ||
|
||||
backgroundInteractionLockedRef.current
|
||||
) {
|
||||
@@ -2426,10 +2460,49 @@ export function AssetCanvasSurface({
|
||||
setQuickEditOpen(false);
|
||||
dragRef.current = null;
|
||||
setNotice('生成任务已提交,结果会作为新候选加入画布');
|
||||
const persistGenerationFailure = async (
|
||||
code: string,
|
||||
message: string,
|
||||
) => {
|
||||
const currentDraft = draftRef.current;
|
||||
if (!currentDraft) return false;
|
||||
const phase =
|
||||
code === 'reconciliation-required'
|
||||
? ('reconciliation-required' as const)
|
||||
: ('failed' as const);
|
||||
const failedDraft = {
|
||||
...currentDraft,
|
||||
status: 'editing' as const,
|
||||
generations: currentDraft.generations.map((record) =>
|
||||
record.generationId === identity.generationId
|
||||
? {
|
||||
...record,
|
||||
phase,
|
||||
errorCode: code,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: record,
|
||||
),
|
||||
};
|
||||
draftRef.current = failedDraft;
|
||||
setDraft(failedDraft);
|
||||
const persisted = await persistDraft();
|
||||
if (!persisted) {
|
||||
setNotice(`${message};失败状态保存未完成,请重新打开后对账`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const task = (async () => {
|
||||
if (needsDraftPersist) {
|
||||
const persisted = await persistDraft();
|
||||
if (!persisted) return;
|
||||
if (!persisted) {
|
||||
await persistGenerationFailure(
|
||||
'draft-save-failed',
|
||||
'生成任务占位保存失败',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const currentDraft = draftRef.current;
|
||||
if (
|
||||
@@ -2488,6 +2561,12 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
if (result.status !== 'ok') {
|
||||
const code = result.status === 'failed' ? result.code : result.status;
|
||||
await persistGenerationFailure(
|
||||
code,
|
||||
result.status === 'failed'
|
||||
? result.message
|
||||
: '图片生成发生 revision 冲突',
|
||||
);
|
||||
setGenerationTasks((current) =>
|
||||
current.map((generationTask) =>
|
||||
generationTask.generationId === identity.generationId
|
||||
@@ -2523,11 +2602,14 @@ export function AssetCanvasSurface({
|
||||
persistedDocumentVersionRef.current = documentVersionRef.current;
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: false });
|
||||
setNotice('候选图片已加入画布,请选择满意结果设为最终图');
|
||||
})().catch((error: unknown) => {
|
||||
})().catch(async (error: unknown) => {
|
||||
if (
|
||||
epoch === epochRef.current &&
|
||||
focusEpoch === generationFocusEpochRef.current
|
||||
) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
await persistGenerationFailure('canvas-generation-failed', message);
|
||||
setGenerationTasks((current) =>
|
||||
current.map((generationTask) =>
|
||||
generationTask.generationId === identity.generationId
|
||||
@@ -2541,7 +2623,7 @@ export function AssetCanvasSurface({
|
||||
),
|
||||
);
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: false });
|
||||
setNotice(error instanceof Error ? error.message : String(error));
|
||||
setNotice(message);
|
||||
}
|
||||
});
|
||||
void task.finally(() => {
|
||||
@@ -2573,7 +2655,6 @@ export function AssetCanvasSurface({
|
||||
const archiveFailedGeneration = useCallback(
|
||||
async (generationId: string) => {
|
||||
if (
|
||||
!host.generation.archiveFailedGeneration ||
|
||||
archivingGenerationIds.includes(generationId) ||
|
||||
!generationTasks.some(
|
||||
(task) =>
|
||||
@@ -2731,18 +2812,55 @@ export function AssetCanvasSurface({
|
||||
quickEditOpen && quickEditSourceLayerId
|
||||
? (layers.find((layer) => layer.id === quickEditSourceLayerId) ?? null)
|
||||
: null;
|
||||
useLayoutEffect(() => {
|
||||
const element = quickEditPanelRef.current;
|
||||
if (!element || !quickEditSourceLayer) return undefined;
|
||||
const measure = () =>
|
||||
setQuickEditPanelSize({
|
||||
width: element.offsetWidth,
|
||||
height: element.offsetHeight,
|
||||
});
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [quickEditSourceLayer, quickEditSourceLayerId, quickEditOpen]);
|
||||
const quickEditPanelStyle: CSSProperties | null = quickEditSourceLayer
|
||||
? {
|
||||
left:
|
||||
? (() => {
|
||||
const edgePadding = 12;
|
||||
const panelWidth =
|
||||
quickEditPanelSize.width ||
|
||||
Math.min(672, canvasSize.width - edgePadding * 2);
|
||||
const panelHeight =
|
||||
quickEditPanelSize.height ||
|
||||
Math.min(352, canvasSize.height - edgePadding * 2);
|
||||
const anchorX =
|
||||
viewport.x +
|
||||
(quickEditSourceLayer.x + quickEditSourceLayer.width / 2) *
|
||||
viewport.scale,
|
||||
top:
|
||||
viewport.scale;
|
||||
const belowTop =
|
||||
viewport.y +
|
||||
(quickEditSourceLayer.y + quickEditSourceLayer.height) *
|
||||
viewport.scale +
|
||||
12,
|
||||
}
|
||||
edgePadding;
|
||||
const aboveTop =
|
||||
viewport.y +
|
||||
quickEditSourceLayer.y * viewport.scale -
|
||||
panelHeight -
|
||||
edgePadding;
|
||||
return resolveQuickEditPanelPosition({
|
||||
anchorX,
|
||||
belowTop,
|
||||
aboveTop,
|
||||
canvasSize,
|
||||
panelSize: { width: panelWidth, height: panelHeight },
|
||||
edgePadding,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
const currentFinalLayer =
|
||||
(draft?.lastCommit
|
||||
@@ -2919,13 +3037,7 @@ export function AssetCanvasSurface({
|
||||
label="导入图片"
|
||||
title="导入本地图片"
|
||||
icon={<Upload size={15} aria-hidden="true" />}
|
||||
onClick={() => {
|
||||
if (host.asset.importLocalImages) {
|
||||
void handleLocalImport();
|
||||
} else {
|
||||
importInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onClick={() => void handleLocalImport()}
|
||||
disabled={backgroundInteractionLocked}
|
||||
>
|
||||
{importPending ? '正在导入…' : '导入'}
|
||||
@@ -3535,6 +3647,7 @@ export function AssetCanvasSurface({
|
||||
>
|
||||
{quickEditSourceLayer && quickEditPanelStyle ? (
|
||||
<section
|
||||
ref={quickEditPanelRef}
|
||||
className="asset-canvas-surface__quick-edit-card"
|
||||
style={quickEditPanelStyle}
|
||||
role="region"
|
||||
@@ -3698,8 +3811,7 @@ export function AssetCanvasSurface({
|
||||
<span aria-hidden="true" />
|
||||
<strong>{generationPhaseLabels[task.phase]}</strong>
|
||||
{task.errorCode ? <small>{task.errorCode}</small> : null}
|
||||
{task.phase === 'failed' &&
|
||||
host.generation.archiveFailedGeneration ? (
|
||||
{task.phase === 'failed' ? (
|
||||
<CanvasChromeButton
|
||||
label="删除失败任务"
|
||||
title="删除失败任务"
|
||||
@@ -3982,8 +4094,7 @@ export function AssetCanvasSurface({
|
||||
阶段进度 {Math.round(task.progress)}%
|
||||
</span>
|
||||
) : null}
|
||||
{task.phase === 'failed' &&
|
||||
host.generation.archiveFailedGeneration ? (
|
||||
{task.phase === 'failed' ? (
|
||||
<CanvasChromeButton
|
||||
label={`删除失败任务 ${task.generationId.slice(0, 8)}`}
|
||||
title="删除失败任务"
|
||||
|
||||
@@ -239,6 +239,7 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
|
||||
message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求',
|
||||
});
|
||||
return {
|
||||
archiveFailedGeneration: unsupported,
|
||||
generateImage: unsupported,
|
||||
recoverImages: async () => ({
|
||||
status: 'ok' as const,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
AssetCanvasSurface,
|
||||
generationAspectRatioForOriginalImage,
|
||||
type RenderAssetCanvasImage,
|
||||
resolveQuickEditPanelPosition,
|
||||
shouldApplyAssetCanvasDraftCandidate,
|
||||
} from '../src/features/asset-canvas/AssetCanvasSurface';
|
||||
import {
|
||||
@@ -822,6 +823,38 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
it('快速编辑卡位置始终钳制在 viewport 四边内并在底部翻转到上方', () => {
|
||||
const common = {
|
||||
canvasSize: { width: 320, height: 240 },
|
||||
panelSize: { width: 220, height: 120 },
|
||||
edgePadding: 12,
|
||||
};
|
||||
expect(
|
||||
resolveQuickEditPanelPosition({
|
||||
...common,
|
||||
anchorX: 4,
|
||||
belowTop: 180,
|
||||
aboveTop: 20,
|
||||
}),
|
||||
).toEqual({ left: 122, top: 20 });
|
||||
expect(
|
||||
resolveQuickEditPanelPosition({
|
||||
...common,
|
||||
anchorX: 316,
|
||||
belowTop: 40,
|
||||
aboveTop: -100,
|
||||
}),
|
||||
).toEqual({ left: 198, top: 40 });
|
||||
expect(
|
||||
resolveQuickEditPanelPosition({
|
||||
...common,
|
||||
anchorX: 160,
|
||||
belowTop: 200,
|
||||
aboveTop: -100,
|
||||
}),
|
||||
).toEqual({ left: 160, top: 12 });
|
||||
});
|
||||
|
||||
it('从精修文件名剥离历史提交后缀并保持后端合法名称', () => {
|
||||
expect(
|
||||
canonicalAssetBaseName(
|
||||
@@ -2774,6 +2807,12 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(
|
||||
within(taskSidebar).getByText(/insufficient-mud-points/),
|
||||
).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(memory.getDraft()?.generations[0]?.phase).toBe('failed'),
|
||||
);
|
||||
expect(memory.getDraft()?.generations[0]?.errorCode).toBe(
|
||||
'insufficient-mud-points',
|
||||
);
|
||||
expect(screen.getByText('泥点余额不足,请充值后重试')).toBeTruthy();
|
||||
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
|
||||
@@ -2833,6 +2872,12 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(
|
||||
within(taskSidebar).getByText(/authentication-required/),
|
||||
).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(memory.getDraft()?.generations[0]?.phase).toBe('failed'),
|
||||
);
|
||||
expect(memory.getDraft()?.generations[0]?.errorCode).toBe(
|
||||
'authentication-required',
|
||||
);
|
||||
expect(screen.getByText('登录已失效,请重新登录后重试')).toBeTruthy();
|
||||
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
|
||||
|
||||
@@ -88,6 +88,14 @@
|
||||
13. 画布带运行中 generation 重新进入时,在远端恢复完成前已经可选图、平移和继续编辑;后台恢复继续推进原 operation,不重复 POST、不重新扣费。阻断性提交/恢复失败的遮罩覆盖整个 Tauri 窗口。
|
||||
14. 已登记 refine 资产的候选设为最终图后保持原 `assetId`、原 `source.resourceId` 与新正式 PNG 路径;`revision-installed` 中断后重新加载可前向收敛为 committed。
|
||||
|
||||
## PR 176 事务与宿主合同补充
|
||||
|
||||
- 生成失败时,公开 generation 记录必须在同一草稿 revision 链路中落为 `failed` 或 `reconciliation-required`,不得留下持久化的 `generation-running` 幽灵任务。
|
||||
- 候选生成必须先幂等落盘候选媒体、候选图层和公开记录,回读成功后才能推进私有 ledger 的 `candidate-ready` 终态;两次写入之间中断时,恢复必须可重放。
|
||||
- 失败归档使用 `archive-pending` 中间态:先记录归档意图,再删除草稿公开记录,最后发布 `archived`;任一边界中断都必须在重启后收敛且保持幂等。
|
||||
- `ImageCanvasAssetPort.importLocalImages` 与 `ImageCanvasGenerationPort.archiveFailedGeneration` 是冻结合同中的必选方法;不支持的宿主返回结构化 `unsupported-capability`。
|
||||
- 快速编辑卡按自身测量宽高选择上下方位置,并在 viewport 四边保留安全留白;窄屏、滚动、缩放和 resize 后都重新计算。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -200,7 +200,7 @@ export interface ImageCanvasProjectPort {
|
||||
}
|
||||
|
||||
export interface ImageCanvasAssetPort {
|
||||
importLocalImages?(input: {
|
||||
importLocalImages(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
expectedDraftRevision: number;
|
||||
viewportSize: { width: number; height: number };
|
||||
@@ -225,7 +225,7 @@ export interface ImageCanvasAssetPort {
|
||||
}
|
||||
|
||||
export interface ImageCanvasGenerationPort {
|
||||
archiveFailedGeneration?(input: {
|
||||
archiveFailedGeneration(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
expectedDraftRevision: number;
|
||||
generationId: string;
|
||||
|
||||
Reference in New Issue
Block a user