diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index d46d1aa46..723b4cb46 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -109,16 +109,15 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.navShortcuts": "快捷键", "settings.navBackup": "备份与同步", "settings.backupLocalTitle": "本地备份", - "settings.backupLocalDesc": - "把服务商、MCP、系统设置和技能启用状态导出为单个 JSON 文件;导入会整域覆盖对应配置。", "settings.backupExport": "导出配置", + "settings.backupExportHint": "保存为单个 JSON 文件", "settings.backupImport": "导入配置", + "settings.backupImportHint": "从备份文件整域恢复", "settings.backupExportDone": "已导出到:", "settings.backupExportFailed": "导出失败", "settings.backupImportDone": "导入完成:", "settings.backupImportFailed": "导入失败", - "settings.backupAutoBackupHint": - "导入前会自动把当前配置备份到 ~/.liveagent/backups/,保留最近 10 份。", + "settings.backupAutoBackupHint": "导入前自动备份当前配置,保留最近 10 份。", "settings.backupImportConfirmTitle": "确认导入配置", "settings.backupImportConfirmSubtitle": "备份文件中包含的配置域会被整域覆盖,此操作不可撤销。", "settings.backupImportConfirmAction": "覆盖导入", @@ -131,11 +130,21 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.backupDomainSystem": "系统设置", "settings.backupDomainSkills": "技能", "settings.backupScopeTitle": "备份范围", - "settings.backupScopeDesc": - "仅覆盖服务商、MCP、系统设置和技能启用状态。对话历史、记忆库、上传文件和 SSH 私钥不在备份范围内。", + "settings.backupScopeIncluded": "包含", + "settings.backupScopeExcluded": "不包含", + "settings.backupScopeChat": "对话历史", + "settings.backupScopeMemory": "记忆库", + "settings.backupScopeUploads": "上传文件", + "settings.backupScopeSshKeys": "SSH 私钥", "settings.backupSyncTitle": "WebDAV 同步", - "settings.backupSyncDesc": - "把同一份配置同步到你自己的 WebDAV 网盘,在多台设备之间共用。WebDAV 账号密码只保存在本机,不会随配置同步出去。", + "settings.backupSyncCredentialNote": "账号密码仅保存在本机", + "settings.backupSyncLoading": "正在读取同步配置…", + "settings.backupSyncStatusReady": "云同步已就绪", + "settings.backupSyncStatusNeverSynced": "尚未同步,点击「上传」推送本机配置", + "settings.backupSyncStatusNotConfigured": "未配置云同步", + "settings.backupSyncStatusNotConfiguredHint": "填写 WebDAV 信息,在多台设备间同步配置", + "settings.backupSyncAutoOn": "自动同步已开启", + "settings.backupSyncAutoOff": "手动同步", "settings.backupSyncPreset": "服务商预设", "settings.backupSyncPreset_jianguoyun": "坚果云", "settings.backupSyncPreset_nextcloud": "Nextcloud", @@ -150,8 +159,7 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.backupSyncProfileHint": "同一账号下可用不同配置档名隔离多套配置,留空则使用 liveagent / default。", "settings.backupSyncAuto": "自动同步", - "settings.backupSyncAutoHint": - "配置变更后自动上传到远端。自动同步只上传,不会自动覆盖本机配置。", + "settings.backupSyncAutoHint": "配置变更后自动上传,不会覆盖本机", "settings.backupSyncAutoDone": "已自动同步到远端", "settings.backupSyncAutoConfirmTitle": "开启自动同步?", "settings.backupSyncAutoConfirmSubtitle": "此后每次配置变更都会自动上传到远端,不再逐次询问。", @@ -359,16 +367,16 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.navShortcuts": "Shortcuts", "settings.navBackup": "Backup & Sync", "settings.backupLocalTitle": "Local backup", - "settings.backupLocalDesc": - "Export providers, MCP, system settings and skill activation to a single JSON file. Importing replaces each included domain wholesale.", "settings.backupExport": "Export config", + "settings.backupExportHint": "Save as a single JSON file", "settings.backupImport": "Import config", + "settings.backupImportHint": "Restore from a backup file", "settings.backupExportDone": "Exported to: ", "settings.backupExportFailed": "Export failed", "settings.backupImportDone": "Import complete: ", "settings.backupImportFailed": "Import failed", "settings.backupAutoBackupHint": - "Before importing, the current config is backed up to ~/.liveagent/backups/ (last 10 kept).", + "The current config is auto-backed up before import (last 10 kept).", "settings.backupImportConfirmTitle": "Confirm config import", "settings.backupImportConfirmSubtitle": "Every domain present in the backup file will be replaced wholesale. This cannot be undone.", @@ -382,11 +390,23 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.backupDomainSystem": "System", "settings.backupDomainSkills": "Skills", "settings.backupScopeTitle": "Backup scope", - "settings.backupScopeDesc": - "Only providers, MCP, system settings and skill activation are covered. Chat history, memory, uploads and SSH private keys are not included.", + "settings.backupScopeIncluded": "Included", + "settings.backupScopeExcluded": "Not included", + "settings.backupScopeChat": "Chat history", + "settings.backupScopeMemory": "Memory", + "settings.backupScopeUploads": "Uploads", + "settings.backupScopeSshKeys": "SSH keys", "settings.backupSyncTitle": "WebDAV Sync", - "settings.backupSyncDesc": - "Sync one configuration across devices through your own WebDAV storage. The WebDAV account and password stay on this machine and are never included in synced snapshots.", + "settings.backupSyncCredentialNote": "Credentials never leave this device", + "settings.backupSyncLoading": "Loading sync settings…", + "settings.backupSyncStatusReady": "Cloud sync ready", + "settings.backupSyncStatusNeverSynced": + "Not synced yet — use Upload to push this machine's config", + "settings.backupSyncStatusNotConfigured": "Cloud sync not set up", + "settings.backupSyncStatusNotConfiguredHint": + "Fill in the WebDAV details to sync your config across devices", + "settings.backupSyncAutoOn": "Auto sync on", + "settings.backupSyncAutoOff": "Manual sync", "settings.backupSyncPreset": "Provider Preset", "settings.backupSyncPreset_jianguoyun": "Jianguoyun", "settings.backupSyncPreset_nextcloud": "Nextcloud", @@ -401,8 +421,7 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.backupSyncProfileHint": "Use different profiles to keep separate configuration sets under one account. Defaults to liveagent / default when left blank.", "settings.backupSyncAuto": "Auto Sync", - "settings.backupSyncAutoHint": - "Upload automatically after configuration changes. Auto sync only uploads and never overwrites this machine.", + "settings.backupSyncAutoHint": "Upload after changes; never overwrites this machine", "settings.backupSyncAutoDone": "Auto-synced to remote", "settings.backupSyncAutoConfirmTitle": "Enable auto sync?", "settings.backupSyncAutoConfirmSubtitle": diff --git a/crates/agent-gui/src/pages/settings/BackupSyncSection.tsx b/crates/agent-gui/src/pages/settings/BackupSyncSection.tsx index 25d5a91aa..439c0d011 100644 --- a/crates/agent-gui/src/pages/settings/BackupSyncSection.tsx +++ b/crates/agent-gui/src/pages/settings/BackupSyncSection.tsx @@ -1,31 +1,38 @@ import { AlertTriangle, - Archive, ArchiveRestore, + Brain, + CheckCircle2, + CircleHelp, Cloud, CloudDownload, Download, + FileText, + HardDrive, + Key, Loader2, + Lock, + McpLogo, + MessageSquare, Plug, Save, + Server, + Settings2, Shield, + SkillIcon, Upload, + XCircle, + Zap, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { Input } from "@liveagent/ui/components/ui/input"; import { Label } from "@liveagent/ui/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@liveagent/ui/components/ui/select"; +import { LabelTooltip } from "@liveagent/ui/components/ui/label-tooltip"; import { Switch } from "@liveagent/ui/components/ui/switch"; import { useLocale } from "@liveagent/ui/i18n/index"; import { listen } from "@tauri-apps/api/event"; -import { useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { applyBackupImport, BACKUP_SYNC_STATUS_EVENT, @@ -107,6 +114,180 @@ function describeSource(manifest: BackupManifest, t: (key: string) => string) { ); } +/** 即时反馈条:成功绿 / 失败红,替代裸文本。 */ +function FeedbackStrip({ status }: { status: Status }) { + if (!status) return null; + const ok = status.kind === "ok"; + return ( +
+ {ok ? ( + + ) : ( + + )} + {status.text} +
+ ); +} + +function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) { + return ( + + ); +} + +/** 顶部状态横幅:未配置 / 已就绪 / 自动同步失败,聚合上次同步时间与自动同步开关态。 */ +function SyncStatusBanner({ + view, + loading, + t, +}: { + view: BackupSyncConfigView | null; + loading: boolean; + t: (key: string) => string; +}) { + if (loading && !view) { + return ( +
+ + {t("settings.backupSyncLoading")} +
+ ); + } + + // 加载失败时 view 为 null:按「未配置」展示,具体错误由表单区的反馈条给出。 + const configured = view ? canTestSyncConnection(view) : false; + const failed = Boolean(view?.lastError); + + const iconWrap = failed + ? "bg-destructive/10 text-destructive" + : configured + ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" + : "bg-muted text-muted-foreground"; + + return ( +
+
+
+ {failed ? : } +
+
+
+ {failed + ? t("settings.backupSyncAutoErrorTitle") + : configured + ? t("settings.backupSyncStatusReady") + : t("settings.backupSyncStatusNotConfigured")} +
+

+ {configured + ? view?.lastSyncAt + ? `${t("settings.backupSyncLastAt")}${formatTimestamp(view.lastSyncAt)}` + : t("settings.backupSyncStatusNeverSynced") + : t("settings.backupSyncStatusNotConfiguredHint")} +

+
+ {configured ? ( + + + {view?.autoSync ? t("settings.backupSyncAutoOn") : t("settings.backupSyncAutoOff")} + + ) : null} +
+ {failed && view?.lastError ? ( +

+ {view.lastError} +

+ ) : null} +
+ ); +} + +/** 本地备份的大号操作磁贴。 */ +function ActionTile({ + icon, + busy, + title, + hint, + disabled, + onClick, +}: { + icon: ReactNode; + busy: boolean; + title: string; + hint: string; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** 备份范围条目:包含项常色,排除项弱化。 */ +function ScopeItem({ + icon, + label, + excluded = false, +}: { + icon: ReactNode; + label: string; + excluded?: boolean; +}) { + return ( +
+ + {icon} + + + {label} + +
+ ); +} + export function BackupSyncSection(props: SettingsSectionProps) { const { settings, setSettings, reloadSettings } = props; const { t } = useLocale(); @@ -191,10 +372,9 @@ export function BackupSyncSection(props: SettingsSectionProps) { }, []); const handlePresetChange = useCallback( - (value: string) => { - const next = value as PresetId; - setPreset(next); - const matched = SYNC_PRESETS.find((item) => item.id === next); + (value: PresetId) => { + setPreset(value); + const matched = SYNC_PRESETS.find((item) => item.id === value); // 选「自定义」时保留当前 URL,只有选到具体预设才覆写。 if (matched) patchForm({ url: matched.url }); }, @@ -403,282 +583,322 @@ export function BackupSyncSection(props: SettingsSectionProps) { } }, [confirm, syncStateAfterRestore, t]); - return ( -
-
-
- - {t("settings.backupLocalTitle")} -
-

- {t("settings.backupLocalDesc")} -

- -
- - -
+ const presetOptions: { id: PresetId }[] = [...SYNC_PRESETS, { id: "custom" as const }]; -
- - {t("settings.backupAutoBackupHint")} -
- - {status ? ( -
- {status.text} -
- ) : null} -
- -
-
- - {t("settings.backupSyncTitle")} -
-

- {t("settings.backupSyncDesc")} -

- -
-
- - -
- -
- - patchForm({ url: event.target.value })} - /> -
+ return ( +
+ + + {/* 两栏等高拉伸(默认 stretch),保证左右卡片底边始终对齐。 */} +
+ {/* 左栏:WebDAV 同步配置。弹性布局把底部操作区钉在底边,撑高时中间留白。 */} +
+
+
+ + + +

+ {t("settings.backupSyncTitle")} +

+
+ + + {t("settings.backupSyncCredentialNote")} + +
-
+
- - patchForm({ username: event.target.value })} - /> + {t("settings.backupSyncPreset")} +
+ {presetOptions.map((item) => { + const active = preset === item.id; + return ( + + ); + })} +
+
- - { - const password = event.target.value; - // 清空密码框视为「没动过」,而不是「把密码改成空」。 - // 后端只在 passwordTouched 时采用新值,若这里对空串也置 true, - // 用户输入几个字符再全删掉就会静默抹掉已存的密码 —— 与本框 - // 自己的「留空则不修改」占位提示直接矛盾,且此后自动同步因 - // 凭据不全而永久静默跳过(auto_upload 的 credentials 分支)。 - // 真要清空密码就关掉同步或改用户名,不该由删字符触发。 - patchForm({ password, passwordTouched: password.length > 0 }); - }} - /> + {t("settings.backupSyncUrl")} +
+ + patchForm({ url: event.target.value })} + /> +
-
-
-
- - patchForm({ remoteDir: event.target.value })} - /> +
+
+ {t("settings.backupSyncUsername")} + patchForm({ username: event.target.value })} + /> +
+
+ {t("settings.backupSyncPassword")} +
+ + { + const password = event.target.value; + // 清空密码框视为「没动过」,而不是「把密码改成空」。 + // 后端只在 passwordTouched 时采用新值,若这里对空串也置 true, + // 用户输入几个字符再全删掉就会静默抹掉已存的密码 —— 与本框 + // 自己的「留空则不修改」占位提示直接矛盾,且此后自动同步因 + // 凭据不全而永久静默跳过(auto_upload 的 credentials 分支)。 + // 真要清空密码就关掉同步或改用户名,不该由删字符触发。 + patchForm({ password, passwordTouched: password.length > 0 }); + }} + /> +
+
-
- - +
+ {t("settings.backupSyncRemoteDir")} + patchForm({ remoteDir: event.target.value })} + /> +
+
+ + {t("settings.backupSyncProfile")} + + patchForm({ profile: event.target.value })} + /> +
+
+ +
+
+ +
+
+ {t("settings.backupSyncAuto")} +
+

+ {t("settings.backupSyncAutoHint")} +

+
+
+ patchForm({ profile: event.target.value })} + title={t("settings.backupSyncAuto")} + aria-label={t("settings.backupSyncAuto")} + onCheckedChange={(checked) => void handleAutoSyncChange(checked)} />
-

- {t("settings.backupSyncProfileHint")} -

-
-
-
- {t("settings.backupSyncAuto")} +
+
+ + +
+ + +
+ + {dirty && !syncLocked ? ( +
+ + {t("settings.backupSyncDirtyHint")}
-

- {t("settings.backupSyncAutoHint")} -

+ ) : null} + + +
+
+ + {/* 右栏:本地备份 + 备份范围。范围卡弹性补足高度,与左栏底边对齐。 */} +
-
-
- - - - -
+
+ } + busy={busy === "export"} + title={t("settings.backupExport")} + hint={t("settings.backupExportHint")} + disabled={busy !== null} + onClick={() => void handleExport()} + /> + } + busy={busy === "import"} + title={t("settings.backupImport")} + hint={t("settings.backupImportHint")} + disabled={busy !== null} + onClick={() => void handleImport()} + /> +
- {dirty && !syncLocked ? ( -

- {t("settings.backupSyncDirtyHint")} -

- ) : null} +
+ + {t("settings.backupAutoBackupHint")} +
- {syncView?.lastSyncAt ? ( -

- {t("settings.backupSyncLastAt")} - {formatTimestamp(syncView.lastSyncAt)} -

- ) : null} + +
+ +
+
+ + + +

+ {t("settings.backupScopeTitle")} +

+
- {/* - 自动同步失败的常驻横幅。区别于下面那条 syncStatus —— 后者是本次交互的 - 即时反馈,切走页面就没了;这条来自库里的 last_error,只要故障没修好, - 每次进设置页都还在。用户不会在后台同步失败时正好盯着这个页面。 - */} - {syncView?.lastError ? ( -
- -
-
- {t("settings.backupSyncAutoErrorTitle")} +
+
+ {t("settings.backupScopeIncluded")} +
+
+ } + label={t("settings.backupDomainProviders")} + /> + } + label={t("settings.backupDomainMcp")} + /> + } + label={t("settings.backupDomainSystem")} + /> + } + label={t("settings.backupDomainSkills")} + />
-

- {syncView.lastError} -

-
- ) : null} - - {syncStatus ? ( -
- {syncStatus.text} -
- ) : null} -
-
-
- - {t("settings.backupScopeTitle")} -
-

- {t("settings.backupScopeDesc")} -

-
+
+
+ {t("settings.backupScopeExcluded")} +
+
+ } + label={t("settings.backupScopeChat")} + /> + } + label={t("settings.backupScopeMemory")} + /> + } + label={t("settings.backupScopeUploads")} + /> + } + label={t("settings.backupScopeSshKeys")} + /> +
+
+ + +
{dialog}