feat(settings): 便携迁移界面重设计——方向示意、状态可见、操作忙碌态

便携模式区块从裸输入框+按钮升级为完整迁移卡片:

- 状态行:模式指示点(本机灰点 / 便携绿点)+ 当前模式与便携根路径
- 方向示意图:本机 ⇄ U 盘 节点 + 高亮箭头,当前可执行的迁移方向
  一眼可读(本机模式显示 本机→U 盘,便携模式显示 U 盘→本机)
- 操作区:按钮带图标与说明文字并排;迁移期间按钮文字切换为
  「正在迁移...」忙碌态,结束后恢复(成功/失败都不遗留状态)
- 双向共用同一卡片结构与结果卡片样式,交互一致

i18n 补充 本机/U 盘/当前为本机模式 三键(简/繁/英)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
晴天
2026-07-05 22:23:30 -07:00
parent b65296a489
commit fc4313d613
2 changed files with 53 additions and 10 deletions

View File

@@ -28,6 +28,9 @@ 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 資料會優先寫入該目錄。'),
portableThisPc: _('本机', 'This PC', '本機'),
portableUsb: _('U 盘', 'USB Drive', 'USB'),
portableStatusLocal: _('当前为本机模式', 'Currently in local mode', '目前為本機模式'),
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?', '確定把便攜資料遷移回本機?'),

View File

@@ -9,6 +9,7 @@ import { t, getLang, setLang, getAvailableLangs, onLangChange } from '../lib/i18
import { isMacPlatform } from '../lib/app-state.js'
import { renderSidebar } from '../components/sidebar.js'
import { getActiveEngineId } from '../lib/engine-manager.js'
import { icon } from '../lib/icons.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -812,6 +813,22 @@ function portableTargetPlaceholder() {
return isWin ? 'E:\\ClawPanelPortable' : '/Volumes/USB/ClawPanelPortable'
}
// 迁移方向示意:高亮当前迁移方向(本机 ⇄ U 盘),一眼看懂数据往哪走
function renderPortableDirection(toPortable) {
const node = (label, iconName, active) => `
<span style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:var(--radius-md,8px);border:1px solid ${active ? 'color-mix(in srgb,var(--primary) 45%,var(--border-primary))' : 'var(--border-primary)'};background:${active ? 'color-mix(in srgb,var(--primary) 8%,var(--bg-primary))' : 'var(--bg-primary)'};font-size:var(--font-size-sm);font-weight:600;color:var(--text-primary)">
${icon(iconName, 14)} ${label}
</span>`
const arrow = `<span style="display:inline-flex;align-items:center;color:var(--primary)">
<svg width="34" height="14" viewBox="0 0 34 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="7" x2="28" y2="7"/><polyline points="23 2 28 7 23 12"/></svg>
</span>`
const host = node(t('settings.portableThisPc'), 'monitor', !toPortable)
const usb = node(t('settings.portableUsb'), 'inbox', toPortable)
return `<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:10px 0 14px">
${toPortable ? host + arrow + usb : usb + arrow + host}
</div>`
}
async function loadPortableMigration(page) {
const bar = page.querySelector('#portable-bar')
if (!bar) return
@@ -821,29 +838,42 @@ async function loadPortableMigration(page) {
}
try {
const status = await api.getPortableStatus()
const card = (inner) => `
<div style="border:1px solid var(--border-primary);border-radius:var(--radius-md,10px);background:var(--bg-secondary);padding:14px 16px">${inner}</div>`
if (status?.enabled) {
bar.innerHTML = `
// 便携模式U 盘 → 本机
bar.innerHTML = card(`
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<span class="clawhub-badge" style="background:rgba(16,185,129,0.14);color:#059669">${t('settings.portableAlreadyEnabled')}</span>
<code style="font-size:var(--font-size-xs)">${escapeHtml(status.root || '')}</code>
<span style="width:8px;height:8px;border-radius:999px;background:var(--success);flex:0 0 auto"></span>
<span style="font-weight:600;color:var(--text-primary)">${t('settings.portableAlreadyEnabled')}</span>
<code style="font-size:var(--font-size-xs);color:var(--text-secondary)">${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>
${renderPortableDirection(false)}
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<button class="btn btn-primary btn-sm" data-action="migrate-local">${icon('download', 13)} ${t('settings.portableRestoreBtn')}</button>
<span class="form-hint" style="margin:0">${t('settings.portableRestoreHint')}</span>
</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
}
bar.innerHTML = `
// 本机模式:本机 → U 盘
bar.innerHTML = card(`
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<span style="width:8px;height:8px;border-radius:999px;background:var(--text-tertiary);flex:0 0 auto"></span>
<span style="font-weight:600;color:var(--text-primary)">${t('settings.portableStatusLocal')}</span>
</div>
${renderPortableDirection(true)}
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<input class="form-input" data-name="portable-target-root" placeholder="${escapeHtml(portableTargetPlaceholder())}" style="max-width:420px;min-width:260px">
<button class="btn btn-primary btn-sm" data-action="migrate-portable">${t('settings.portableMigrateBtn')}</button>
<button class="btn btn-primary btn-sm" data-action="migrate-portable">${icon('upload', 13)} ${t('settings.portableMigrateBtn')}</button>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.portableModeHint')}</div>
<div id="portable-migrate-result" style="margin-top:var(--space-sm);display:none"></div>
`
`)
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
}
@@ -890,6 +920,9 @@ async function handleMigrateToLocal(page) {
],
})
if (!ok) return
const btn = page.querySelector('[data-action="migrate-local"]')
const originalLabel = btn?.innerHTML
if (btn) btn.innerHTML = t('settings.portableRestoring')
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.portableRestoring')}</div>`
@@ -903,6 +936,8 @@ async function handleMigrateToLocal(page) {
resultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">${escapeHtml(e?.message || String(e))}</div>`
}
throw e
} finally {
if (btn && originalLabel) btn.innerHTML = originalLabel
}
if (resultEl) {
const backups = Array.isArray(report?.backups) ? report.backups : []
@@ -929,6 +964,9 @@ async function handleMigrateToPortable(page) {
}
const ok = await showConfirm(t('settings.portableMigrateConfirm'))
if (!ok) return
const btn = page.querySelector('[data-action="migrate-portable"]')
const originalLabel = btn?.innerHTML
if (btn) btn.innerHTML = t('settings.portableMigrating')
if (resultEl) {
resultEl.style.display = 'block'
resultEl.innerHTML = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.portableMigrating')}</div>`
@@ -942,6 +980,8 @@ async function handleMigrateToPortable(page) {
resultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">${escapeHtml(e?.message || String(e))}</div>`
}
throw e
} finally {
if (btn && originalLabel) btn.innerHTML = originalLabel
}
if (resultEl) {
resultEl.innerHTML = renderPortableMigrationResult(report)