mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-26 16:28:37 +08:00
feat(portable): 便携模式支持迁移回本机——双向迁移闭环
migrate_to_portable 的反向:便携模式下面板设置新增「迁移回本机」。 - 语义:以 U 盘数据为准,本机已有 OpenClaw / Hermes 数据先整体改名 备份(.backup-时间戳,rename 即时完成;空目录直接复用不备份), 再全新复制,避免新旧数据合并出难排查的混合状态 - 覆盖:OpenClaw 数据目录(含模型渠道、创作中心数据)、Hermes 数据 目录、面板配置(与正向迁移同理清洗指向 U 盘的绝对路径字段) - 不迁移引擎与运行时(本机按常规方式安装),报告卡片明示备份位置 与下一步操作;重叠路径防呆;确认弹窗列出影响清单 - Web 模式明确拒绝并提示用桌面端;i18n 简/繁/英 测试:migrate_to_local_impl 备份/复制/清洗/重叠拒绝 2 个单元测试; cargo test --lib 316 通过,node --test 428 通过,clippy/fmt 干净。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
|
||||
### 新功能 (Features)
|
||||
|
||||
- **便携模式双向迁移** — 便携模式下面板设置新增「迁移回本机」:把 U 盘上的配置、Agent、记忆、模型渠道与媒体数据迁回本机默认位置;本机已有数据先整体改名备份(.backup-时间戳)再替换,避免新旧数据混合,引擎与运行时按常规方式在本机安装
|
||||
- **统一模型渠道** — 新增「模型渠道」页面(通用分组):一处维护模型接入配置(服务商预设 / 接口地址 / API Key / 模型列表),一键同步到 OpenClaw、Hermes 与晴辰助手,不再需要在三处重复配置
|
||||
- 同步为显式推送并逐项确认:写入 OpenClaw 只更新对应 provider 且保留未知字段(自动备份);写入 Hermes 走环境变量与配置命令(自动备份);同步助手为一次性拷贝
|
||||
- 支持从 OpenClaw 现有模型配置一键导入渠道、从服务商在线拉取模型列表、渠道修改后的「有未同步变更」提醒
|
||||
|
||||
@@ -8784,7 +8784,7 @@ const ALWAYS_LOCAL = new Set([
|
||||
'cancel_media_job', 'list_media_jobs', 'delete_media_job',
|
||||
'reveal_media_asset', 'reveal_media_output_dir', 'load_media_asset',
|
||||
// 便携模式是本进程属性,不能代理到远程实例
|
||||
'get_portable_status', 'migrate_to_portable',
|
||||
'get_portable_status', 'migrate_to_portable', 'migrate_to_local',
|
||||
])
|
||||
|
||||
// === 工具函数 ===
|
||||
@@ -13585,6 +13585,10 @@ const handlers = {
|
||||
throw new Error('Web 模式不支持迁移为便携式,请使用桌面客户端执行')
|
||||
},
|
||||
|
||||
migrate_to_local() {
|
||||
throw new Error('Web 模式不支持便携迁移,请使用桌面客户端执行')
|
||||
},
|
||||
|
||||
get_openclaw_dir() {
|
||||
const panelConfig = readPanelConfig()
|
||||
const info = applyOpenclawPathConfig(panelConfig)
|
||||
|
||||
@@ -689,6 +689,11 @@ fn hermes_home() -> PathBuf {
|
||||
if let Some(ctx) = crate::commands::portable::portable_context() {
|
||||
return ctx.hermes_home.clone();
|
||||
}
|
||||
local_hermes_home_default()
|
||||
}
|
||||
|
||||
/// 本机(非便携)Hermes 数据目录默认值;便携模式迁移回本机时也用它定位目标
|
||||
pub(crate) fn local_hermes_home_default() -> PathBuf {
|
||||
if let Ok(h) = std::env::var("HERMES_HOME") {
|
||||
return PathBuf::from(h);
|
||||
}
|
||||
|
||||
@@ -517,6 +517,125 @@ fn migrate_to_portable_impl(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 目录存在且非空才值得备份;空目录直接复用,避免产生噪音备份
|
||||
fn needs_backup(path: &Path) -> bool {
|
||||
if path.is_file() {
|
||||
return true;
|
||||
}
|
||||
std::fs::read_dir(path)
|
||||
.map(|mut entries| entries.next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 目标已存在时的备份路径(同级、带时间戳),rename 即时完成
|
||||
fn backup_sibling_path(path: &Path, timestamp: &str) -> PathBuf {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "data".into());
|
||||
path.with_file_name(format!("{name}.backup-{timestamp}"))
|
||||
}
|
||||
|
||||
/// 将便携数据迁移回本机默认位置(migrate_to_portable 的反向)。
|
||||
/// 语义:以 U 盘数据为准——本机已有数据先整体改名备份(.backup-<时间戳>),
|
||||
/// 再全新复制,避免新旧数据合并出难排查的混合状态。
|
||||
/// 引擎与运行时不迁移(本机按常规方式安装),只搬用户数据。
|
||||
fn migrate_to_local_impl(
|
||||
source_openclaw_dir: &Path,
|
||||
source_hermes_home: &Path,
|
||||
source_panel_config: Option<Value>,
|
||||
target_openclaw_dir: &Path,
|
||||
target_hermes_home: &Path,
|
||||
) -> Result<Value, String> {
|
||||
// 防呆:目标不能位于便携源内部或与其相同(自定义路径可能指回 U 盘)
|
||||
if path_is_inside_or_same(target_openclaw_dir, source_openclaw_dir)
|
||||
|| path_is_inside_or_same(source_openclaw_dir, target_openclaw_dir)
|
||||
{
|
||||
return Err("本机 OpenClaw 目录与便携目录重叠,无法迁移".into());
|
||||
}
|
||||
if path_is_inside_or_same(target_hermes_home, source_hermes_home)
|
||||
|| path_is_inside_or_same(source_hermes_home, target_hermes_home)
|
||||
{
|
||||
return Err("本机 Hermes 目录与便携目录重叠,无法迁移".into());
|
||||
}
|
||||
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let mut backups: Vec<String> = Vec::new();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
// OpenClaw 数据(含面板级 clawpanel/ 子目录:模型渠道、媒体数据随之带回)
|
||||
let mut copied_openclaw = false;
|
||||
if source_openclaw_dir.is_dir() {
|
||||
if target_openclaw_dir.exists() && needs_backup(target_openclaw_dir) {
|
||||
let bak = backup_sibling_path(target_openclaw_dir, ×tamp);
|
||||
std::fs::rename(target_openclaw_dir, &bak).map_err(|e| {
|
||||
format!("备份本机 OpenClaw 数据失败(若本机 Gateway 正在运行请先停止): {e}")
|
||||
})?;
|
||||
backups.push(bak.to_string_lossy().to_string());
|
||||
}
|
||||
copy_dir_recursive(source_openclaw_dir, target_openclaw_dir)?;
|
||||
copied_openclaw = true;
|
||||
} else {
|
||||
warnings.push("portable-openclaw-missing".into());
|
||||
}
|
||||
|
||||
// Hermes 数据
|
||||
let mut copied_hermes = false;
|
||||
if source_hermes_home.is_dir() {
|
||||
if target_hermes_home.exists() && needs_backup(target_hermes_home) {
|
||||
let bak = backup_sibling_path(target_hermes_home, ×tamp);
|
||||
std::fs::rename(target_hermes_home, &bak)
|
||||
.map_err(|e| format!("备份本机 Hermes 数据失败: {e}"))?;
|
||||
backups.push(bak.to_string_lossy().to_string());
|
||||
}
|
||||
copy_dir_recursive(source_hermes_home, target_hermes_home)?;
|
||||
copied_hermes = true;
|
||||
}
|
||||
|
||||
// 面板配置:写入本机 openclaw 目录下的 clawpanel.json;
|
||||
// 与正向迁移同理清洗绝对路径字段(便携配置里可能残留指向 U 盘的路径)
|
||||
let (panel, removed_keys) = sanitized_panel_config(source_panel_config);
|
||||
let target_panel_config = target_openclaw_dir.join("clawpanel.json");
|
||||
std::fs::create_dir_all(target_openclaw_dir)
|
||||
.map_err(|e| format!("创建本机数据目录失败: {e}"))?;
|
||||
let panel_text = serde_json::to_string_pretty(&panel)
|
||||
.map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?;
|
||||
std::fs::write(&target_panel_config, panel_text)
|
||||
.map_err(|e| format!("写入本机 clawpanel.json 失败: {e}"))?;
|
||||
|
||||
Ok(json!({
|
||||
"openclawDir": target_openclaw_dir.to_string_lossy(),
|
||||
"hermesHome": target_hermes_home.to_string_lossy(),
|
||||
"panelConfigPath": target_panel_config.to_string_lossy(),
|
||||
"copiedOpenclaw": copied_openclaw,
|
||||
"copiedHermesHome": copied_hermes,
|
||||
"backups": backups,
|
||||
"removedPanelKeys": removed_keys,
|
||||
// 引擎与运行时不迁移:本机没有安装时需按常规方式安装
|
||||
"enginesNotMigrated": true,
|
||||
"warnings": warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 便携模式 → 本机:把 U 盘上的用户数据迁移回本机默认位置。
|
||||
/// 当前进程仍是便携模式;迁移后请从本机安装的 ClawPanel 启动查看
|
||||
#[tauri::command]
|
||||
pub fn migrate_to_local() -> Result<Value, String> {
|
||||
let Some(ctx) = portable_context() else {
|
||||
return Err("当前不是便携模式,无需迁移回本机".into());
|
||||
};
|
||||
let target_openclaw = crate::commands::default_openclaw_dir();
|
||||
let target_hermes = crate::commands::hermes::local_hermes_home_default();
|
||||
let panel_config = crate::commands::read_panel_config_from(&ctx.panel_config_path);
|
||||
migrate_to_local_impl(
|
||||
&ctx.openclaw_dir,
|
||||
&ctx.hermes_home,
|
||||
panel_config,
|
||||
&target_openclaw,
|
||||
&target_hermes,
|
||||
)
|
||||
}
|
||||
|
||||
fn active_standalone_engine_dir() -> Option<PathBuf> {
|
||||
let cli_path = crate::utils::resolve_openclaw_cli_path()?;
|
||||
if crate::utils::classify_cli_source(&cli_path) != "standalone" {
|
||||
@@ -785,6 +904,91 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_to_local_backs_up_existing_and_copies_portable_data() {
|
||||
let usb_openclaw = temp_root("to-local-src-oc");
|
||||
let usb_hermes = temp_root("to-local-src-hm");
|
||||
let local_openclaw = temp_root("to-local-dst-oc");
|
||||
let local_hermes = temp_root("to-local-dst-hm");
|
||||
|
||||
// U 盘数据
|
||||
std::fs::write(usb_openclaw.join("openclaw.json"), br#"{ "from": "usb" }"#).unwrap();
|
||||
std::fs::create_dir_all(usb_openclaw.join("clawpanel").join("media")).unwrap();
|
||||
std::fs::write(
|
||||
usb_openclaw.join("clawpanel").join("model-channels.json"),
|
||||
br#"{ "version": 1 }"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(usb_hermes.join("config.yaml"), b"model: usb\n").unwrap();
|
||||
|
||||
// 本机已有旧数据(应被整体备份而非覆盖合并)
|
||||
std::fs::write(
|
||||
local_openclaw.join("openclaw.json"),
|
||||
br#"{ "from": "local" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(local_openclaw.join("local-only.txt"), b"keep-in-backup").unwrap();
|
||||
|
||||
let panel = json!({
|
||||
"accessPassword": "usb-pw",
|
||||
"nodePath": "F:\\ClawPanelPortable\\runtimes\\node"
|
||||
});
|
||||
|
||||
let report = migrate_to_local_impl(
|
||||
&usb_openclaw,
|
||||
&usb_hermes,
|
||||
Some(panel),
|
||||
&local_openclaw,
|
||||
&local_hermes,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// U 盘数据落到本机
|
||||
let restored = std::fs::read_to_string(local_openclaw.join("openclaw.json")).unwrap();
|
||||
assert!(restored.contains("usb"));
|
||||
assert!(local_openclaw
|
||||
.join("clawpanel")
|
||||
.join("model-channels.json")
|
||||
.is_file());
|
||||
assert!(local_hermes.join("config.yaml").is_file());
|
||||
// 面板配置写入且绝对路径字段被清洗
|
||||
let panel_restored: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(local_openclaw.join("clawpanel.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(panel_restored["accessPassword"], "usb-pw");
|
||||
assert!(panel_restored.get("nodePath").is_none());
|
||||
// 本机旧数据完整备份
|
||||
let backups = report["backups"].as_array().unwrap();
|
||||
assert_eq!(backups.len(), 1);
|
||||
let backup_dir = PathBuf::from(backups[0].as_str().unwrap());
|
||||
assert!(backup_dir.join("local-only.txt").is_file());
|
||||
let old = std::fs::read_to_string(backup_dir.join("openclaw.json")).unwrap();
|
||||
assert!(old.contains("local"));
|
||||
assert_eq!(report["enginesNotMigrated"], true);
|
||||
|
||||
for dir in [
|
||||
&usb_openclaw,
|
||||
&usb_hermes,
|
||||
&local_openclaw,
|
||||
&local_hermes,
|
||||
&backup_dir,
|
||||
] {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_to_local_rejects_overlapping_paths() {
|
||||
let usb = temp_root("to-local-overlap");
|
||||
let nested = usb.join("data").join("openclaw");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
let err = migrate_to_local_impl(&nested, &usb.join("hm"), None, &usb, &usb.join("hm2"))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("重叠"));
|
||||
let _ = std::fs::remove_dir_all(&usb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_creates_portable_layout_and_sanitizes_host_paths() {
|
||||
let target = temp_root("migrate-target");
|
||||
|
||||
@@ -116,6 +116,7 @@ pub fn run() {
|
||||
config::write_panel_config,
|
||||
portable::get_portable_status,
|
||||
portable::migrate_to_portable,
|
||||
portable::migrate_to_local,
|
||||
config::test_proxy,
|
||||
config::get_npm_registry,
|
||||
config::set_npm_registry,
|
||||
|
||||
@@ -382,6 +382,10 @@ export const api = {
|
||||
getOpenclawDir: () => invoke('get_openclaw_dir'),
|
||||
// 便携模式状态(启动期固定不变,只读)
|
||||
getPortableStatus: () => invoke('get_portable_status'),
|
||||
migrateToLocal: () => {
|
||||
invalidate('get_portable_status', 'read_panel_config', 'check_installation', 'get_version_info')
|
||||
return invoke('migrate_to_local')
|
||||
},
|
||||
migrateToPortable: (targetRoot) => {
|
||||
invalidate('get_portable_status', 'read_panel_config', 'check_installation', 'get_version_info')
|
||||
return invoke('migrate_to_portable', { targetRoot })
|
||||
|
||||
@@ -28,6 +28,16 @@ export default {
|
||||
portableDesktopOnly: _('便携模式迁移仅支持桌面客户端', 'Portable migration is only available in the desktop client', '便攜模式遷移僅支援桌面客戶端', 'ポータブル移行はデスクトップクライアントでのみ利用できます', '휴대용 마이그레이션은 데스크톱 클라이언트에서만 사용할 수 있습니다'),
|
||||
portableAlreadyEnabled: _('已启用便携模式', 'Portable mode enabled', '已啟用便攜模式', 'ポータブルモード有効', '휴대용 모드 활성화됨'),
|
||||
portableEnabledHint: _('当前会话已从便携目录启动,配置和 OpenClaw 数据会优先写入该目录。', 'This session was started from a portable directory. Config and OpenClaw data are written there first.', '目前工作階段已從便攜目錄啟動,設定與 OpenClaw 資料會優先寫入該目錄。'),
|
||||
portableRestoreBtn: _('迁移回本机', 'Migrate to This Computer', '遷移回本機'),
|
||||
portableRestoreHint: _('把 U 盘上的配置、Agent、记忆等用户数据迁回本机默认位置;本机已有数据会先整体备份。', 'Copies your config, agents, and memory from the portable drive back to this computer; existing local data is backed up first.', '把 USB 上的設定、Agent、記憶等使用者資料遷回本機預設位置;本機已有資料會先整體備份。'),
|
||||
portableRestoreConfirm: _('确定把便携数据迁移回本机?', 'Migrate portable data back to this computer?', '確定把便攜資料遷移回本機?'),
|
||||
portableRestoreImpactBackup: _('本机已有的 OpenClaw / Hermes 数据将整体改名备份(.backup-时间戳),然后以 U 盘数据替换', 'Existing local OpenClaw / Hermes data is renamed to a timestamped backup, then replaced with the portable data', '本機已有的 OpenClaw / Hermes 資料將整體改名備份(.backup-時間戳),然後以 USB 資料取代'),
|
||||
portableRestoreImpactEngines: _('引擎与运行时不迁移:本机未安装 OpenClaw / Hermes 时需按常规方式安装', 'Engines and runtimes are not migrated: install OpenClaw / Hermes locally if not present', '引擎與執行環境不遷移:本機未安裝 OpenClaw / Hermes 時需按常規方式安裝'),
|
||||
portableRestoreImpactRestart: _('迁移完成后请从本机安装的 ClawPanel 启动查看(当前会话仍是便携模式)', 'After migration, launch the locally installed ClawPanel (this session stays in portable mode)', '遷移完成後請從本機安裝的 ClawPanel 啟動查看(目前工作階段仍是便攜模式)'),
|
||||
portableRestoring: _('正在迁移回本机...', 'Migrating to this computer...', '正在遷移回本機...'),
|
||||
portableRestoreDone: _('已迁移回本机', 'Migrated to this computer', '已遷移回本機'),
|
||||
portableRestoreBackups: _('本机原数据备份位置', 'Local data backed up to', '本機原資料備份位置'),
|
||||
portableRestoreNext: _('下一步:从本机安装的 ClawPanel 启动即可使用迁回的数据;确认无误后可自行删除备份目录。', 'Next: launch the locally installed ClawPanel to use the restored data; delete the backup folders once verified.', '下一步:從本機安裝的 ClawPanel 啟動即可使用遷回的資料;確認無誤後可自行刪除備份目錄。'),
|
||||
portableModeHint: _('选择一个新的 U 盘目录,面板会创建 portable.json,复制当前面板配置和 OpenClaw 数据,并清理本机绝对路径。完成后从目标目录里的程序重新启动。', 'Choose a new USB directory. ClawPanel will create portable.json, copy current panel config and OpenClaw data, and remove host-specific absolute paths. Restart from the target directory when done.', '選擇一個新的 U 盤目錄,面板會建立 portable.json,複製目前面板設定與 OpenClaw 資料,並清理本機絕對路徑。完成後從目標目錄裡的程式重新啟動。'),
|
||||
portableMigrateBtn: _('迁移为便携式', 'Migrate to Portable', '遷移為便攜式', 'ポータブルへ移行', '휴대용으로 마이그레이션'),
|
||||
portableTargetRequired: _('请先填写目标目录', 'Please enter a target directory', '請先填寫目標目錄', '対象ディレクトリを入力してください', '대상 디렉터리를 입력하세요'),
|
||||
|
||||
@@ -509,6 +509,9 @@ function bindEvents(page) {
|
||||
case 'migrate-portable':
|
||||
await handleMigrateToPortable(page)
|
||||
break
|
||||
case 'migrate-local':
|
||||
await handleMigrateToLocal(page)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.toString(), 'error')
|
||||
@@ -825,6 +828,11 @@ async function loadPortableMigration(page) {
|
||||
<code style="font-size:var(--font-size-xs)">${escapeHtml(status.root || '')}</code>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.portableEnabledHint')}</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm)">
|
||||
<button class="btn btn-secondary btn-sm" data-action="migrate-local">${t('settings.portableRestoreBtn')}</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.portableRestoreHint')}</div>
|
||||
<div id="portable-restore-result" style="margin-top:var(--space-sm);display:none"></div>
|
||||
`
|
||||
return
|
||||
}
|
||||
@@ -870,6 +878,47 @@ function renderPortableMigrationResult(report) {
|
||||
`
|
||||
}
|
||||
|
||||
// 便携模式 → 本机:本机已有数据整体备份后以 U 盘数据替换,引擎不迁移
|
||||
async function handleMigrateToLocal(page) {
|
||||
const resultEl = page.querySelector('#portable-restore-result')
|
||||
const ok = await showConfirm({
|
||||
message: t('settings.portableRestoreConfirm'),
|
||||
impact: [
|
||||
t('settings.portableRestoreImpactBackup'),
|
||||
t('settings.portableRestoreImpactEngines'),
|
||||
t('settings.portableRestoreImpactRestart'),
|
||||
],
|
||||
})
|
||||
if (!ok) return
|
||||
if (resultEl) {
|
||||
resultEl.style.display = 'block'
|
||||
resultEl.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.portableRestoring')}</div>`
|
||||
}
|
||||
let report
|
||||
try {
|
||||
report = await api.migrateToLocal()
|
||||
} catch (e) {
|
||||
// 失败必须把结果框切到错误态,不能停留在"迁移中"
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">${escapeHtml(e?.message || String(e))}</div>`
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if (resultEl) {
|
||||
const backups = Array.isArray(report?.backups) ? report.backups : []
|
||||
resultEl.innerHTML = `
|
||||
<div style="padding:10px 12px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);font-size:var(--font-size-sm);line-height:1.6">
|
||||
<div style="font-weight:600;color:var(--success);margin-bottom:4px">${t('settings.portableRestoreDone')}</div>
|
||||
<div>${t('settings.portableMigrateOpenclaw')}: <code>${escapeHtml(report?.openclawDir || '')}</code></div>
|
||||
<div>${t('settings.portableMigrateHermes')}: <code>${escapeHtml(report?.hermesHome || '')}</code></div>
|
||||
${backups.length ? `<div style="margin-top:4px">${t('settings.portableRestoreBackups')}:</div><ul style="margin:2px 0 0 18px;padding:0">${backups.map(b => `<li><code style="font-size:var(--font-size-xs)">${escapeHtml(b)}</code></li>`).join('')}</ul>` : ''}
|
||||
<div style="margin-top:6px;color:var(--text-secondary)">${t('settings.portableRestoreNext')}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
toast(t('settings.portableRestoreDone'), 'success')
|
||||
}
|
||||
|
||||
async function handleMigrateToPortable(page) {
|
||||
const input = page.querySelector('[data-name="portable-target-root"]')
|
||||
const resultEl = page.querySelector('#portable-migrate-result')
|
||||
|
||||
Reference in New Issue
Block a user