137 lines
12 KiB
TypeScript
137 lines
12 KiB
TypeScript
/**
|
||
* 旧画布迁往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>;
|
||
}
|