Files
Endoleap/src/components/canvas/legacy-canvas-migration.tsx
T

137 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧画布迁往canonical模型的界面协调层。持有本页旧编辑租约,迁移前停止新编辑并排空旧writer,结果确认后重开新版。
* “升级”指数据与协议迁移;本地记录和浏览器锁用于跨页协调,服务端版本校验与幂等仍由迁移服务负责。
*/
"use client";
import {useEffect,useRef,useState} from 'react';
import type {Registry} from '@/lib/canvas-core/types';
import {claimLegacyCanvasEditing,createBrowserCanvasMigration,type LegacyMigrationContext} from '@/lib/browser-canvas-migration';
import {canvasMigrationStorageKey,migrationBlocksCanvas,readCanvasMigration,type CanvasMigrationJob} from '@/lib/canvas-migration';
import {errorText} from '@/lib/api';
import styles from './canvas.module.css';
/** 将迁移错误码翻成可操作提示,尤其区分确定拒绝与结果未知;未知结果不能当成没写入而直接开放旧编辑。 */
function message(error:unknown){
const code=error instanceof Error?error.message:String(error);
return ({migration_busy:'其他旧画布页面仍在编辑或升级,请先完成那里的保存和上传,再关闭页面重试。',migration_lock_unavailable:'此浏览器不支持安全升级,请使用支持 Web Locks 的浏览器。',migration_response_unknown:'升级结果尚未确认,编辑已暂停。可继续查询原请求。',invalid_migration_receipt:'升级回执无法确认,已保留原请求并暂停编辑。请继续查询或联系支持。',migration_pending_uploads:'请先完成或取消图片上传,再升级。',draft_recovery_required:'请先处理本地草稿恢复,再升级。',canvas_busy:'请先完成当前操作与同步,再升级。',migration_waiting_for_runs:'请等待生成任务结束,再重新准备升级。',migration_version_conflict:'画布已发生变化,请重新准备升级。',workspace_migration_pending:'本工作区还有另一画布的升级待确认,请先处理原画布。',invalid_migration_record:'无法读取本地升级进度,已保留记录并暂停编辑。',migration_interpreter_unavailable:'本地升级进度需要其他版本解释,已保留记录并暂停编辑。',migration_not_durable:'无法可靠保存升级进度,请保留页面并重试。'} as Record<string,string>)[code]??errorText(error instanceof Error?error:new Error(code));
}
/** hold控制旧编辑入口;prepare排空指定根的保存工作,check检查当前是否允许迁移。
* canLeave保护重开页面前的未同步内容,exportDraft提供本页备份,context提供账户与迁移根身份。 */
type Props={context:LegacyMigrationContext;workspaceId:string;canvasId:string;registry:Registry;held:boolean;hold(value:boolean):void;prepare(rootId:string):Promise<void>;check():void;canLeave():boolean;exportDraft():void};
/** 只保存迁移展示状态,实际请求/本地记录由createBrowserCanvasMigration管理。
* attempt是同步防重入标记,running只用于渲染,finished表示本页已进入成功或外部变更后的交接阶段。 */
export function LegacyCanvasMigration(props:Props){
// 回调经ref读取最新props,避免每次父组件渲染都重新申请共享旧编辑租约。
const port=useRef(props);port.current=props;
const [job,setJob]=useState<CanvasMigrationJob>(),[error,setError]=useState(''),[checking,setChecking]=useState(true),[running,setRunning]=useState(false),[changedElsewhere,setChangedElsewhere]=useState(false);
// lease是本页共享旧编辑锁;life取消本次挂载工作;previous用来识别其它页面删除待确认记录的变化。
const lease=useRef<{release():Promise<void>}|undefined>(undefined),life=useRef<AbortController|undefined>(undefined),attempt=useRef(false),finished=useRef(false),previous=useRef<CanvasMigrationJob|undefined>(undefined);
// localStorage迁移记录按账户+工作区隔离,canvasId用于判断该任务是否阻断当前画布。
const scope={accountId:props.context.accountId,workspaceId:props.workspaceId},key=canvasMigrationStorageKey(scope);
const refresh=useRef<()=>Promise<void>>(async()=>{});
// 挂载或作用域变化时检查记录并申请旧编辑租约;身份失效或卸载后不再采用异步结果。
useEffect(()=>{
const controller=new AbortController();life.current=controller;
/** 同时检查本页取消与账户身份,不能只因旧请求返回就更新当前页面或解锁编辑。 */
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
/** 读取并校验本作用域持久迁移记录;格式/解释版本错误也要走保留记录和暂停路径。 */
const read=()=>readCanvasMigration(localStorage,scope);
/** 迁移尝试进行中不抢租约;已有pending或finished就保持hold,正常情形取得共享锁后开放旧编辑。 */
async function inspect(){
if(attempt.current||!current())return;
setChecking(true);
try{
const next=read();previous.current=next;setJob(next);
if(migrationBlocksCanvas(next,props.canvasId)||finished.current){
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();setError('');return;
}
// 已持有共享锁时复用;申请新锁前先暂停编辑,避免没有锁的空档接收修改。
if(lease.current){port.current.hold(false);setError('');return;}
port.current.hold(true);
const acquired=await claimLegacyCanvasEditing({scope,canvasId:props.canvasId,signal:controller.signal,isCurrentIdentity:current});
// 等锁期间页面可能卸载或换账户,拿到过期锁立即释放,不再发布可编辑状态。
if(!current()){await acquired.release();return;}
lease.current=acquired;port.current.hold(false);setError('');
}catch(error){
if(!current())return;
port.current.hold(true);const held=lease.current;lease.current=undefined;await held?.release();
// Without Web Locks this browser cannot upgrade, but existing legacy
// editing remains supported unless there is an unresolved identity job.
// 不支持Web Locks仅禁止安全迁移;没有阻断记录时仍允许旧编辑,不能把这类环境错误当数据已迁移。
if(error instanceof Error&&error.message==='migration_lock_unavailable'){
try{if(!migrationBlocksCanvas(read(),props.canvasId))port.current.hold(false);}catch{}
}
setError(message(error));
}finally{if(current())setChecking(false);}
}
refresh.current=inspect;void inspect();
/** 其它页改变同一记录时重新检查;本页迁移中忽略事件,由当前尝试负责收尾。
* 待确认记录消失只说明外部变化,本页不能丢掉内存内容,先冻结并提示导出/重新打开。 */
const storage=(event:StorageEvent)=>{
if(event.key!==key&&event.key!==null||!current()||attempt.current)return;
// Never discard another view's in-memory content because a record vanished.
if(previous.current?.phase==='pending'&&event.newValue===null){
finished.current=true;port.current.hold(true);setChangedElsewhere(true);setError('升级进度已在其他页面改变。本页内容保留,可导出后重新打开。');
const held=lease.current;lease.current=undefined;void held?.release();return;
}
void inspect();
};
window.addEventListener('storage',storage);
// 卸载取消检查、拆storage监听并释放本页共享锁,不删除尚待确认的迁移记录。
return()=>{controller.abort();window.removeEventListener('storage',storage);const held=lease.current;lease.current=undefined;void held?.release();};
// The port ref follows renders without re-acquiring a shared editing lease.
// eslint-disable-next-line react-hooks/exhaustive-deps
},[key,props.canvasId]);
/** 新尝试先check并hold,在共享锁仍持有时prepare;释放共享锁后由迁移器申请独占锁。
* pending复用原记录resume,其它情况按rootId重新准备,不能把每次点击都变成新的迁移请求。 */
async function upgrade(){
const controller=life.current;if(!controller||controller.signal.aborted||attempt.current)return;
attempt.current=true;setRunning(true);setError('');
const current=()=>!controller.signal.aborted&&port.current.context.isCurrentIdentity();
let failure='';
try{
const pending=readCanvasMigration(localStorage,scope);
// 工作区已有另一根pending时禁止挤占;同一根结果未知则继续原请求查询,不重复做初始准备。
if(pending?.phase==='pending'&&!migrationBlocksCanvas(pending,props.canvasId))throw new Error('workspace_migration_pending');
if(pending?.phase!=='pending')port.current.check();
port.current.hold(true);
// Drain while still holding our shared lease, so losing the subsequent
// exclusive-lock race never abandons a dirty old writer between locks.
if(pending?.phase!=='pending'&&props.context.rootId){await port.current.prepare(props.context.rootId);if(!current())return;}
const held=lease.current;lease.current=undefined;await held?.release();if(!current())return;
const migration=createBrowserCanvasMigration({scope,registry:props.registry,signal:controller.signal,isCurrentIdentity:current});
// start独占阶段再次check/prepare/check,覆盖共享锁释放到独占锁取得之间可能出现的旧写入。
const result=pending?.phase==='pending'?await migration.resume():props.context.rootId?await migration.start(props.context.rootId,async()=>{port.current.check();await port.current.prepare(props.context.rootId!);port.current.check();}):undefined;
if(!current())return;
// 只有确认applied或已迁移才通知外层重开;拒绝/未知保留记录,交finally重新决定是否能恢复旧编辑。
if(result?.status==='applied'||result?.status==='already-migrated'){
finished.current=true;port.current.context.onApplied();return;
}
if(result&&'error'in result){failure=message(result.error);setError(failure);}
const next=migration.read();previous.current=next;setJob(next);
}catch(error){if(current()){failure=message(error);setError(failure);}}
// 不论尝试结果都清重入标记;仅当前身份有效才更新UI,并从持久记录重新判断是否仍需hold。
finally{
attempt.current=false;
if(current()){
setRunning(false);
try{const next=readCanvasMigration(localStorage,scope);previous.current=next;setJob(next);if(!finished.current&&!migrationBlocksCanvas(next,props.canvasId))await refresh.current();}catch(error){port.current.hold(true);failure=message(error);}
if(failure)setError(failure);
}
}
}
// pending表示本页在迁移范围内;other表示工作区另一画布待确认,按钮禁用但不篡改其任务。
const pending=migrationBlocksCanvas(job,props.canvasId),other=job?.phase==='pending'&&!pending;
if(!props.context.rootId&&!pending&&!error&&!props.held)return null;
// 入口依次呈现检查中/执行中/外部变更重开/开始或继续迁移;旧撤销记录不迁移,提示写在按钮title。
// 本页被hold时提供备份;外部变化后的重开仍须check及canLeave,不能直接丢弃尚未同步的内存内容。
return <div className={styles.migrationPanel} aria-label="画布升级">
{checking?<span role="status">正在检查画布编辑权限…</span>:<>
{running?<span role="status">正在保存并升级画布…</span>:changedElsewhere?<button onClick={()=>{try{port.current.check();if(!port.current.canLeave())throw new Error('本页有未同步内容,请先导出备份并手动重新打开画布。');port.current.context.onApplied();}catch(error){setError(message(error));}}}>重新打开画布</button>:props.context.rootId||pending?<button disabled={other} title="保存内容并使用新版编辑器;旧撤销记录不会带入" onClick={()=>void upgrade()}>{pending?'继续查询升级':job?.phase==='rejected'?'重新准备升级':'升级画布'}</button>:props.held?<button onClick={()=>void refresh.current()}>重新检查</button>:null}
{pending&&!running&&<span role="status">结果待确认,旧编辑已暂停。</span>}
{error&&<span role="alert">{error}</span>}
{props.held&&!running&&<button onClick={props.exportDraft}>导出本页备份</button>}
</>}
</div>;
}