@logictan/dsh-config-manager 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/cordis.patch.yml +21 -0
- package/lib/adapters/agent-instructions.d.ts +13 -0
- package/lib/adapters/agent-instructions.js +46 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/credentials.d.ts +37 -0
- package/lib/adapters/credentials.js +122 -0
- package/lib/adapters/file-collection.d.ts +15 -0
- package/lib/adapters/file-collection.js +127 -0
- package/lib/adapters/index.d.ts +57 -0
- package/lib/adapters/index.js +55 -0
- package/lib/adapters/link-report.d.ts +16 -0
- package/lib/adapters/link-report.js +47 -0
- package/lib/adapters/mcp.d.ts +28 -0
- package/lib/adapters/mcp.js +178 -0
- package/lib/adapters/plugin-files.d.ts +18 -0
- package/lib/adapters/plugin-files.js +142 -0
- package/lib/adapters/plugins.d.ts +41 -0
- package/lib/adapters/plugins.js +603 -0
- package/lib/adapters/pnpm-workspace.d.ts +26 -0
- package/lib/adapters/pnpm-workspace.js +147 -0
- package/lib/adapters/prompts.d.ts +30 -0
- package/lib/adapters/prompts.js +231 -0
- package/lib/adapters/providers.d.ts +34 -0
- package/lib/adapters/providers.js +155 -0
- package/lib/adapters/self.d.ts +17 -0
- package/lib/adapters/self.js +71 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/settings.d.ts +28 -0
- package/lib/adapters/settings.js +165 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/test-helpers.d.ts +125 -0
- package/lib/adapters/test-helpers.js +236 -0
- package/lib/adapters/ui.d.ts +27 -0
- package/lib/adapters/ui.js +89 -0
- package/lib/adapters/workspaces.d.ts +13 -0
- package/lib/adapters/workspaces.js +106 -0
- package/lib/client.d.ts +1026 -0
- package/lib/client.js +10047 -0
- package/lib/core/analyzer.d.ts +99 -0
- package/lib/core/analyzer.js +934 -0
- package/lib/core/backup.d.ts +138 -0
- package/lib/core/backup.js +513 -0
- package/lib/core/boot-rescue.d.ts +146 -0
- package/lib/core/boot-rescue.js +618 -0
- package/lib/core/cache-cleaner.d.ts +31 -0
- package/lib/core/cache-cleaner.js +95 -0
- package/lib/core/config-lifecycle.d.ts +180 -0
- package/lib/core/config-lifecycle.js +481 -0
- package/lib/core/config-snapshot.d.ts +135 -0
- package/lib/core/config-snapshot.js +424 -0
- package/lib/core/config-state.d.ts +70 -0
- package/lib/core/config-state.js +177 -0
- package/lib/core/consult-source.d.ts +58 -0
- package/lib/core/consult-source.js +260 -0
- package/lib/core/crash-report.d.ts +84 -0
- package/lib/core/crash-report.js +317 -0
- package/lib/core/exporter.d.ts +68 -0
- package/lib/core/exporter.js +375 -0
- package/lib/core/ghost-sweep.d.ts +19 -0
- package/lib/core/ghost-sweep.js +52 -0
- package/lib/core/importer.d.ts +60 -0
- package/lib/core/importer.js +29 -0
- package/lib/core/index.d.ts +14 -0
- package/lib/core/index.js +14 -0
- package/lib/core/journal.d.ts +197 -0
- package/lib/core/journal.js +418 -0
- package/lib/core/local-plugin-host.d.ts +35 -0
- package/lib/core/local-plugin-host.js +120 -0
- package/lib/core/local-plugin-pack.d.ts +135 -0
- package/lib/core/local-plugin-pack.js +278 -0
- package/lib/core/messages.d.ts +310 -0
- package/lib/core/messages.js +621 -0
- package/lib/core/migration-consult.d.ts +117 -0
- package/lib/core/migration-consult.js +363 -0
- package/lib/core/migration-history.d.ts +153 -0
- package/lib/core/migration-history.js +411 -0
- package/lib/core/model-tools.d.ts +50 -0
- package/lib/core/model-tools.js +148 -0
- package/lib/core/msg-types.d.ts +8 -0
- package/lib/core/msg-types.js +6 -0
- package/lib/core/phase3-child-crash.d.ts +1 -0
- package/lib/core/phase3-child-crash.js +87 -0
- package/lib/core/phase3-host.d.ts +138 -0
- package/lib/core/phase3-host.js +359 -0
- package/lib/core/phase3-prod-child.d.ts +1 -0
- package/lib/core/phase3-prod-child.js +35 -0
- package/lib/core/phase4-crash-child.d.ts +1 -0
- package/lib/core/phase4-crash-child.js +103 -0
- package/lib/core/plugin-cli.d.ts +150 -0
- package/lib/core/plugin-cli.js +497 -0
- package/lib/core/reconcile.d.ts +120 -0
- package/lib/core/reconcile.js +389 -0
- package/lib/core/recovery-orchestrator.d.ts +94 -0
- package/lib/core/recovery-orchestrator.js +327 -0
- package/lib/core/restore.d.ts +162 -0
- package/lib/core/restore.js +703 -0
- package/lib/core/rollback.d.ts +12 -0
- package/lib/core/rollback.js +145 -0
- package/lib/core/run-registry.d.ts +85 -0
- package/lib/core/run-registry.js +157 -0
- package/lib/core/startup-barrier.d.ts +53 -0
- package/lib/core/startup-barrier.js +77 -0
- package/lib/core/types.d.ts +515 -0
- package/lib/core/types.js +27 -0
- package/lib/core/undo.d.ts +73 -0
- package/lib/core/undo.js +87 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/verify-recovery.d.ts +21 -0
- package/lib/core/verify-recovery.js +256 -0
- package/lib/core/watcher.d.ts +101 -0
- package/lib/core/watcher.js +219 -0
- package/lib/index.d.ts +238 -0
- package/lib/index.js +2764 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/profile-manager.d.ts +106 -0
- package/lib/profiles/profile-manager.js +543 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +165 -0
- package/lib/schema/index.d.ts +24 -0
- package/lib/schema/index.js +26 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/tombstones.d.ts +46 -0
- package/lib/schema/tombstones.js +64 -0
- package/lib/schema/types.d.ts +232 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/security/encryption.d.ts +85 -0
- package/lib/security/encryption.js +279 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/secret-scanner.d.ts +86 -0
- package/lib/security/secret-scanner.js +404 -0
- package/lib/security/vault.d.ts +59 -0
- package/lib/security/vault.js +151 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/sync/autosync-config.d.ts +39 -0
- package/lib/sync/autosync-config.js +151 -0
- package/lib/sync/autosync-scheduler.d.ts +139 -0
- package/lib/sync/autosync-scheduler.js +564 -0
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +49 -0
- package/lib/sync/git/git-transport.d.ts +100 -0
- package/lib/sync/git/git-transport.js +437 -0
- package/lib/sync/github-auth.d.ts +131 -0
- package/lib/sync/github-auth.js +264 -0
- package/lib/sync/layout.d.ts +43 -0
- package/lib/sync/layout.js +203 -0
- package/lib/sync/snapshot-json.d.ts +9 -0
- package/lib/sync/snapshot-json.js +107 -0
- package/lib/sync/sync-config.d.ts +81 -0
- package/lib/sync/sync-config.js +281 -0
- package/lib/sync/sync-engine.d.ts +269 -0
- package/lib/sync/sync-engine.js +591 -0
- package/lib/sync/sync-history.d.ts +42 -0
- package/lib/sync/sync-history.js +78 -0
- package/lib/sync/sync-selection.d.ts +29 -0
- package/lib/sync/sync-selection.js +127 -0
- package/lib/sync/sync-session.d.ts +40 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-state.d.ts +37 -0
- package/lib/sync/sync-state.js +108 -0
- package/lib/sync/transport.d.ts +75 -0
- package/lib/sync/transport.js +55 -0
- package/lib/sync/ui-prefs.d.ts +34 -0
- package/lib/sync/ui-prefs.js +109 -0
- package/lib/sync/webdav/webdav-transport.d.ts +77 -0
- package/lib/sync/webdav/webdav-transport.js +451 -0
- package/lib/ui/errors.d.ts +19 -0
- package/lib/ui/errors.js +81 -0
- package/lib/ui/export-flow.d.ts +82 -0
- package/lib/ui/export-flow.js +98 -0
- package/lib/ui/i18n.d.ts +289 -0
- package/lib/ui/i18n.js +581 -0
- package/lib/ui/migration-consult-view.d.ts +91 -0
- package/lib/ui/migration-consult-view.js +115 -0
- package/lib/ui/progress.d.ts +38 -0
- package/lib/ui/progress.js +87 -0
- package/lib/ui/report.d.ts +23 -0
- package/lib/ui/report.js +153 -0
- package/lib/ui/test-helpers.d.ts +69 -0
- package/lib/ui/test-helpers.js +154 -0
- package/lib/ui/types.d.ts +257 -0
- package/lib/ui/types.js +21 -0
- package/lib/utils/atomic-write.d.ts +88 -0
- package/lib/utils/atomic-write.js +399 -0
- package/lib/utils/env-lock.d.ts +393 -0
- package/lib/utils/env-lock.js +930 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/paths.d.ts +42 -0
- package/lib/utils/paths.js +189 -0
- package/lib/utils/proxy.d.ts +69 -0
- package/lib/utils/proxy.js +455 -0
- package/lib/utils/recursive-walk.d.ts +24 -0
- package/lib/utils/recursive-walk.js +134 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/package.json +188 -0
- package/src/adapters/agent-instructions.ts +48 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +143 -0
- package/src/adapters/file-collection.ts +137 -0
- package/src/adapters/files.test.ts +353 -0
- package/src/adapters/index.ts +94 -0
- package/src/adapters/link-report.test.ts +65 -0
- package/src/adapters/link-report.ts +61 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +185 -0
- package/src/adapters/plugin-files.ts +146 -0
- package/src/adapters/plugins.test.ts +326 -0
- package/src/adapters/plugins.ts +615 -0
- package/src/adapters/pnpm-workspace.test.ts +87 -0
- package/src/adapters/pnpm-workspace.ts +150 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +230 -0
- package/src/adapters/providers.test.ts +126 -0
- package/src/adapters/providers.ts +173 -0
- package/src/adapters/roundtrip.test.ts +190 -0
- package/src/adapters/self.test.ts +92 -0
- package/src/adapters/self.ts +75 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +100 -0
- package/src/adapters/settings.ts +187 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +248 -0
- package/src/adapters/ui.test.ts +57 -0
- package/src/adapters/ui.ts +108 -0
- package/src/adapters/workspaces.test.ts +106 -0
- package/src/adapters/workspaces.ts +109 -0
- package/src/client/ConfigManagerSection.tsx +90 -0
- package/src/client/api.ts +66 -0
- package/src/client/client-types.ts +18 -0
- package/src/client/common/ErrorBanner.tsx +73 -0
- package/src/client/common/Icon.tsx +129 -0
- package/src/client/common/Modal.tsx +173 -0
- package/src/client/common/ToastViewport.tsx +73 -0
- package/src/client/common/toast-store.test.ts +192 -0
- package/src/client/common/toast-store.ts +181 -0
- package/src/client/common/toast-types.ts +7 -0
- package/src/client/common/ui.tsx +342 -0
- package/src/client/config-manager.module.css +2971 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +56 -0
- package/src/client/lucide-icons.d.ts +38 -0
- package/src/client/run-store.test.ts +223 -0
- package/src/client/run-store.ts +372 -0
- package/src/client/sync/SyncConfirmView.tsx +379 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +220 -0
- package/src/client/sync/SyncSettingsView.tsx +1535 -0
- package/src/client/sync/history-model.test.ts +194 -0
- package/src/client/sync/history-model.ts +195 -0
- package/src/client/sync/sync-api.test.ts +585 -0
- package/src/client/sync/sync-api.ts +543 -0
- package/src/client/sync/sync-locales.ts +416 -0
- package/src/client/sync/sync-push-preview.test.ts +52 -0
- package/src/client/sync/sync-view-v2.test.ts +213 -0
- package/src/client/sync/sync-view.test.ts +487 -0
- package/src/client/sync/sync-view.ts +759 -0
- package/src/core/analyzer.ts +1068 -0
- package/src/core/backup.ts +605 -0
- package/src/core/boot-rescue.test.ts +847 -0
- package/src/core/boot-rescue.ts +737 -0
- package/src/core/cache-cleaner.test.ts +169 -0
- package/src/core/cache-cleaner.ts +121 -0
- package/src/core/config-lifecycle.test.ts +727 -0
- package/src/core/config-lifecycle.ts +573 -0
- package/src/core/config-snapshot.test.ts +369 -0
- package/src/core/config-snapshot.ts +522 -0
- package/src/core/config-state.test.ts +381 -0
- package/src/core/config-state.ts +207 -0
- package/src/core/consult-source.test.ts +199 -0
- package/src/core/consult-source.ts +293 -0
- package/src/core/crash-report.test.ts +380 -0
- package/src/core/crash-report.ts +369 -0
- package/src/core/exporter.ts +417 -0
- package/src/core/ghost-sweep.test.ts +94 -0
- package/src/core/ghost-sweep.ts +67 -0
- package/src/core/importer.ts +83 -0
- package/src/core/index.ts +56 -0
- package/src/core/journal.test.ts +257 -0
- package/src/core/journal.ts +518 -0
- package/src/core/local-plugin-host.ts +152 -0
- package/src/core/local-plugin-pack.test.ts +359 -0
- package/src/core/local-plugin-pack.ts +363 -0
- package/src/core/messages.test.ts +54 -0
- package/src/core/messages.ts +664 -0
- package/src/core/migration-consult.test.ts +315 -0
- package/src/core/migration-consult.ts +494 -0
- package/src/core/migration-history.test.ts +319 -0
- package/src/core/migration-history.ts +484 -0
- package/src/core/model-tools.test.ts +190 -0
- package/src/core/model-tools.ts +208 -0
- package/src/core/msg-types.ts +10 -0
- package/src/core/phase3-child-crash.ts +95 -0
- package/src/core/phase3-host.ts +411 -0
- package/src/core/phase3-p1.test.ts +212 -0
- package/src/core/phase3-p2b-fingerprint.test.ts +189 -0
- package/src/core/phase3-prod-child.ts +36 -0
- package/src/core/phase3-production-integration.test.ts +286 -0
- package/src/core/phase4-crash-child.ts +101 -0
- package/src/core/phase4-crash-injection.test.ts +166 -0
- package/src/core/plugin-cli.fs.test.ts +184 -0
- package/src/core/plugin-cli.test.ts +137 -0
- package/src/core/plugin-cli.ts +563 -0
- package/src/core/reconcile.test.ts +392 -0
- package/src/core/reconcile.ts +498 -0
- package/src/core/recovery-orchestrator.test.ts +145 -0
- package/src/core/recovery-orchestrator.ts +366 -0
- package/src/core/restore-manage.test.ts +94 -0
- package/src/core/restore.ts +849 -0
- package/src/core/rollback.ts +151 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +232 -0
- package/src/core/run-registry.ts +209 -0
- package/src/core/smoke.test.ts +842 -0
- package/src/core/startup-barrier.ts +99 -0
- package/src/core/types.ts +542 -0
- package/src/core/undo.ts +116 -0
- package/src/core/validator.ts +84 -0
- package/src/core/verify-recovery.ts +265 -0
- package/src/core/watcher.test.ts +326 -0
- package/src/core/watcher.ts +263 -0
- package/src/index.facade.test.ts +265 -0
- package/src/index.sync.test.ts +217 -0
- package/src/index.ts +2975 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +282 -0
- package/src/profiles/profile-manager.ts +630 -0
- package/src/schema/config.ts +165 -0
- package/src/schema/index.ts +71 -0
- package/src/schema/manifest-schema.test.ts +519 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/tombstones.test.ts +131 -0
- package/src/schema/tombstones.ts +89 -0
- package/src/schema/types.ts +245 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +335 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +445 -0
- package/src/security/security.test.ts +1215 -0
- package/src/security/vault.test.ts +164 -0
- package/src/security/vault.ts +198 -0
- package/src/security/zip-security.ts +180 -0
- package/src/sync/autosync-config.test.ts +147 -0
- package/src/sync/autosync-config.ts +179 -0
- package/src/sync/autosync-scheduler.test.ts +490 -0
- package/src/sync/autosync-scheduler.ts +646 -0
- package/src/sync/fs.ts +48 -0
- package/src/sync/git/git-transport.test.ts +673 -0
- package/src/sync/git/git-transport.ts +491 -0
- package/src/sync/github-auth.test.ts +280 -0
- package/src/sync/github-auth.ts +356 -0
- package/src/sync/layout.test.ts +270 -0
- package/src/sync/layout.ts +222 -0
- package/src/sync/snapshot-json.test.ts +119 -0
- package/src/sync/snapshot-json.ts +128 -0
- package/src/sync/sync-config.test.ts +310 -0
- package/src/sync/sync-config.ts +299 -0
- package/src/sync/sync-engine.test.ts +1074 -0
- package/src/sync/sync-engine.ts +754 -0
- package/src/sync/sync-history.test.ts +116 -0
- package/src/sync/sync-history.ts +120 -0
- package/src/sync/sync-selection.test.ts +186 -0
- package/src/sync/sync-selection.ts +143 -0
- package/src/sync/sync-session.test.ts +143 -0
- package/src/sync/sync-session.ts +77 -0
- package/src/sync/sync-state.test.ts +164 -0
- package/src/sync/sync-state.ts +131 -0
- package/src/sync/transport.test.ts +111 -0
- package/src/sync/transport.ts +118 -0
- package/src/sync/ui-prefs.test.ts +152 -0
- package/src/sync/ui-prefs.ts +135 -0
- package/src/sync/webdav/webdav-transport.test.ts +871 -0
- package/src/sync/webdav/webdav-transport.ts +535 -0
- package/src/ui/errors.test.ts +47 -0
- package/src/ui/errors.ts +100 -0
- package/src/ui/export-flow.test.ts +87 -0
- package/src/ui/export-flow.ts +156 -0
- package/src/ui/i18n.ts +590 -0
- package/src/ui/migration-consult-view.test.ts +151 -0
- package/src/ui/migration-consult-view.ts +178 -0
- package/src/ui/progress.test.ts +47 -0
- package/src/ui/progress.ts +102 -0
- package/src/ui/report.test.ts +101 -0
- package/src/ui/report.ts +146 -0
- package/src/ui/test-helpers.ts +184 -0
- package/src/ui/types.ts +325 -0
- package/src/utils/atomic-write.test.ts +312 -0
- package/src/utils/atomic-write.ts +422 -0
- package/src/utils/env-lock.test.ts +999 -0
- package/src/utils/env-lock.ts +1086 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +176 -0
- package/src/utils/proxy.test.ts +465 -0
- package/src/utils/proxy.ts +493 -0
- package/src/utils/recursive-walk.test.ts +148 -0
- package/src/utils/recursive-walk.ts +145 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,1535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程同步面板(备份与迁移页的第 4 个 tab 内容)。
|
|
3
|
+
*
|
|
4
|
+
* 独立设置页壳(sectionHeader/close/自身 tab)已移除 —— tab 容器由
|
|
5
|
+
* ConfigManagerSection 统一渲染,本组件只输出内容体:
|
|
6
|
+
* - **同步通道入口卡**:展示当前通道 + 配置状态 + 凭据徽章;点「配置同步通道」
|
|
7
|
+
* → 弹出**通道配置弹窗**(弹窗体系与市场操作弹窗一致,DESIGN.md §8.12:
|
|
8
|
+
* dialogMask + dialogCard dialogWide + dialogHeaderRow + dialogClose +
|
|
9
|
+
* dialogBodyScroll,零新增样式);
|
|
10
|
+
* - **通道配置弹窗**:通道子 tab(GitHub(git)/ WebDAV)切换,两个通道的
|
|
11
|
+
* 配置表单、自动同步、同步模式、是否加密、远端快照**各自独立**;关闭弹窗
|
|
12
|
+
* = 放弃本次操作(GitHub 登录流程进行中则一并取消,§8.12 约定);
|
|
13
|
+
* - GitHub 子 tab:repoUrl(必填)+ 认证 token(可选,写入 DSH credentials 的提示)
|
|
14
|
+
* + **GitHub OAuth device flow 登录**(登录块跟随 git 通道配置放在弹窗内:
|
|
15
|
+
* 未登录/失效时显示;git 可执行文件固定使用系统 PATH 中的 git);
|
|
16
|
+
* - WebDAV 子 tab:url + username + password(密码写入 DSH credentials)+ 常见服务器预设;
|
|
17
|
+
* - 私有仓库强制提示横幅(仅 git 子 tab 常驻);
|
|
18
|
+
* - 推送按钮 → SyncPushReport;拉取按钮 → SyncPullReport.changes 差异摘要;
|
|
19
|
+
* - 一键同步主按钮:拉取 → 差异确认会话(SyncConfirmView 逐项确认)→ 确认导入
|
|
20
|
+
* (apply-items)→ 执行结果 + 一键回滚(restoreId);「选择历史快照」下拉;
|
|
21
|
+
* - 自动同步设置(按通道):总开关 + 间隔下拉 + 状态(上次运行 / 下次倒计时);
|
|
22
|
+
* - 状态行:凭据配置 + 上次同步时间 + 通道(来自 GET /sync/status,组件挂载时加载)。
|
|
23
|
+
*
|
|
24
|
+
* 全部渲染模型来自 ./sync-view.ts 纯函数(node 单测覆盖),组件只做装配;
|
|
25
|
+
* 状态组件内自持(useState),同时经 toSyncStoreSlice() 镜像进模块级 runStore:
|
|
26
|
+
* 模块级单例保证「切 tab 不丢」,sessionStorage 白名单保证「刷新恢复」;
|
|
27
|
+
* token/webdav 密码/加密与解密密码仅内存(state),成功后清空(已写入 DSH
|
|
28
|
+
* credentials),持久化白名单硬性剔除(含 byChannel 内密码类字段),刷新后
|
|
29
|
+
* 清空、需要时重新输入。
|
|
30
|
+
*/
|
|
31
|
+
import { useEffect, useRef, useState } from 'react'
|
|
32
|
+
import type { ChangeEvent } from 'react'
|
|
33
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
34
|
+
import type { SyncPullReport, SyncPushPreview, SyncPushReport } from '../../sync/sync-engine.ts'
|
|
35
|
+
import type { UiT } from '../../ui/i18n.ts'
|
|
36
|
+
import type { SectionId } from '../../schema/types.ts'
|
|
37
|
+
import { Badge, Banner, Button, Card, Checkbox, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
38
|
+
import { ErrorBanner } from '../common/ErrorBanner.tsx'
|
|
39
|
+
import { toast } from '../common/toast-store.ts'
|
|
40
|
+
import { redact } from '../../security/redaction.ts'
|
|
41
|
+
import { Modal } from '../common/Modal.tsx'
|
|
42
|
+
import { RefreshIcon } from '../common/Icon.tsx'
|
|
43
|
+
import { runStore, toSyncStoreSlice, type SyncConfirmDecisions, type SyncStoreSlice } from '../run-store.ts'
|
|
44
|
+
import { SYNC_CREDENTIAL_REF, SYNC_WEBDAV_CREDENTIAL_REF } from './sync-api.ts'
|
|
45
|
+
import type {
|
|
46
|
+
AutosyncInterval, AutosyncStatusResponse, SyncApi, SyncPushPayload, SyncSectionInfo, SyncSnapshotLite,
|
|
47
|
+
SyncStartResponse, SyncStatusResponse,
|
|
48
|
+
} from './sync-api.ts'
|
|
49
|
+
import {
|
|
50
|
+
autosyncIntervalMs, autosyncStatusText, channelTabModels, computeAutosyncCountdown,
|
|
51
|
+
computeGithubLoginView, computeRemoteReady, computeSyncButtons,
|
|
52
|
+
defaultChannelSyncState, formatIntervalDuration, githubPollMessage, kindLabel, presetById,
|
|
53
|
+
presetIdForUrl, privateRepoHint, pullReportView, pushPreviewView, pushReportView, readStoredChannel,
|
|
54
|
+
recommendedSyncSections, severityLabel, syncSectionGroups, syncSectionOptions,
|
|
55
|
+
WEBDAV_PRESETS, writeStoredChannel,
|
|
56
|
+
} from './sync-view.ts'
|
|
57
|
+
import type {
|
|
58
|
+
ChannelSyncState, GithubLoginPhase, SyncChannel, SyncMode, SyncSectionOption,
|
|
59
|
+
} from './sync-view.ts'
|
|
60
|
+
import { SyncHistoryView } from './SyncHistoryView.tsx'
|
|
61
|
+
import { SyncConfirmView } from './SyncConfirmView.tsx'
|
|
62
|
+
import css from '../config-manager.module.css'
|
|
63
|
+
|
|
64
|
+
export interface SyncSettingsViewProps {
|
|
65
|
+
api: SyncApi
|
|
66
|
+
t: TranslateNS<'config-manager-sync'>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** P0-②:push 前预览弹窗的视图数据(持久化切片;非敏感,刷新后可恢复确认态) */
|
|
70
|
+
export interface PushPreviewSlice {
|
|
71
|
+
/** 预览结果(null = 尚未取到 */
|
|
72
|
+
preview: SyncPushPreview | null
|
|
73
|
+
/** 弹窗是否打开(预览完成后自动打开;确认/关闭后关闭) */
|
|
74
|
+
open: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface SyncUiState {
|
|
78
|
+
loading: boolean
|
|
79
|
+
loadError: string | null
|
|
80
|
+
statusInfo: SyncStatusResponse | null
|
|
81
|
+
/** 当前激活通道子 tab(git 默认;webdav 切换显示 WebDAV 表单) */
|
|
82
|
+
channel: SyncChannel
|
|
83
|
+
/** git 通道表单 */
|
|
84
|
+
repoUrl: string
|
|
85
|
+
/** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化 */
|
|
86
|
+
token: string
|
|
87
|
+
/** webdav 通道表单 */
|
|
88
|
+
webdavUrl: string
|
|
89
|
+
webdavUsername: string
|
|
90
|
+
/** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化/回显 */
|
|
91
|
+
webdavPassword: string
|
|
92
|
+
/** git/webdav 各自独立的设置状态(自动同步 / 同步模式 / 加密 / 快照) */
|
|
93
|
+
byChannel: {
|
|
94
|
+
git: ChannelSyncState
|
|
95
|
+
webdav: ChannelSyncState
|
|
96
|
+
}
|
|
97
|
+
/** 可同步分区目录(status.syncSections 回填;高级模式勾选列表数据源;两通道共用目录) */
|
|
98
|
+
catalog: SyncSectionOption[]
|
|
99
|
+
/** 通道配置保存中(「保存配置」按钮 spinner;自动保存同用) */
|
|
100
|
+
savingConfig: boolean
|
|
101
|
+
busy: 'sync' | 'push' | 'pull' | 'rollback' | null
|
|
102
|
+
pushReport: SyncPushReport | null
|
|
103
|
+
pullReport: SyncPullReport | null
|
|
104
|
+
/** P0-②:push 前只读预览弹窗(preview 结果 + 打开状态;非敏感,切 tab/刷新不丢) */
|
|
105
|
+
pushPreview: PushPreviewSlice
|
|
106
|
+
/** 一键同步差异确认会话(POST /sync/sync 结果;非空时渲染 SyncConfirmView) */
|
|
107
|
+
confirmSession: SyncStartResponse | null
|
|
108
|
+
/** 一键同步差异确认的逐项决策(adopted/resolution;与 confirmSession 生命周期绑定,切 tab/刷新不丢) */
|
|
109
|
+
confirmDecisions: SyncConfirmDecisions | null
|
|
110
|
+
/** 最近一次一键同步执行结果(回滚入口) */
|
|
111
|
+
lastRestoreId: string | null
|
|
112
|
+
/**
|
|
113
|
+
* 动作失败文案(R-20 后**不再作为展示通道**)。
|
|
114
|
+
* 保留该字段仅因 run-store 的 SyncStoreSlice 结构契约要求(toSyncStoreSlice 读取它,
|
|
115
|
+
* 而 run-store.ts 不在本任务改动范围);失败反馈已全部改走 Toast(见下方各 catch 分支),
|
|
116
|
+
* 因此不再写入消息,页面也没有对应渲染点。
|
|
117
|
+
*/
|
|
118
|
+
error: string | null
|
|
119
|
+
/** GitHub OAuth device flow 状态(flowId/userCode 仅内存,token 只存宿主) */
|
|
120
|
+
github: GithubUiState
|
|
121
|
+
/**
|
|
122
|
+
* GitHub token 是否有效(「已登录」判定:token 存在且 GitHub API 接受)。
|
|
123
|
+
* null = 尚未校验(不显示登录块,避免已登录用户看到闪烁);true = 已登录
|
|
124
|
+
* (隐藏 GitHub 登录块);false = 未配置或已失效(显示登录块)。
|
|
125
|
+
* 仅内存瞬态(不进 store 切片):切 tab/刷新后重新校验,保证新鲜。
|
|
126
|
+
*/
|
|
127
|
+
githubSignedIn: boolean | null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface GithubUiState {
|
|
131
|
+
phase: GithubLoginPhase
|
|
132
|
+
flowId: string
|
|
133
|
+
userCode: string
|
|
134
|
+
verificationUri: string
|
|
135
|
+
/** GitHub 建议轮询间隔秒数(pending 重排时兜底用) */
|
|
136
|
+
interval: number
|
|
137
|
+
error: string | null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const initialGithub: GithubUiState = {
|
|
141
|
+
phase: 'idle', flowId: '', userCode: '', verificationUri: '', interval: 5, error: null,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const AUTOSYNC_INTERVAL_OPTIONS: AutosyncInterval[] = ['5m', '15m', '30m', '60m', '6h', '12h', '24h'];
|
|
145
|
+
|
|
146
|
+
const initial: SyncUiState = {
|
|
147
|
+
loading: true,
|
|
148
|
+
loadError: null,
|
|
149
|
+
statusInfo: null,
|
|
150
|
+
// 用户最近选择的通道优先(localStorage 记住);无则缺省 git,由 loadStatus 按配置回填
|
|
151
|
+
channel: readStoredChannel() ?? 'git',
|
|
152
|
+
repoUrl: '',
|
|
153
|
+
token: '',
|
|
154
|
+
webdavUrl: '',
|
|
155
|
+
webdavUsername: '',
|
|
156
|
+
webdavPassword: '',
|
|
157
|
+
byChannel: {
|
|
158
|
+
git: defaultChannelSyncState(),
|
|
159
|
+
webdav: defaultChannelSyncState(),
|
|
160
|
+
},
|
|
161
|
+
catalog: [],
|
|
162
|
+
savingConfig: false,
|
|
163
|
+
busy: null,
|
|
164
|
+
pushReport: null,
|
|
165
|
+
pullReport: null,
|
|
166
|
+
pushPreview: { preview: null, open: false },
|
|
167
|
+
confirmSession: null,
|
|
168
|
+
confirmDecisions: null,
|
|
169
|
+
lastRestoreId: null,
|
|
170
|
+
error: null,
|
|
171
|
+
github: initialGithub,
|
|
172
|
+
githubSignedIn: null,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 从 runStore 恢复上次的同步 UI 状态(切 tab 回 / 刷新后挂载)。
|
|
177
|
+
* 敏感字段(token/webdav 密码/加密与解密密码)只在内存切片里保留:切 tab 保留;
|
|
178
|
+
* 刷新后已被持久化白名单清空(applyPersisted 强制归零)→ 需要时重新输入。
|
|
179
|
+
* busy/savingConfig 为瞬态:切 tab 由模块级单例保留(切回仍显示进行中);
|
|
180
|
+
* 刷新后白名单剔除 → 回复空闲。
|
|
181
|
+
*/
|
|
182
|
+
function initFromStore(): SyncUiState {
|
|
183
|
+
const s: SyncStoreSlice = runStore.getSnapshot().sync
|
|
184
|
+
return {
|
|
185
|
+
...initial,
|
|
186
|
+
// 通道:store 切片缺省为 'git',无法区分「持久化过 git」与「从未持久化」;
|
|
187
|
+
// 无明确记录(== 'git')时回退 localStorage 记住的选择(initial.channel),
|
|
188
|
+
// 避免升级后把用户此前记住的 webdav 通道冲掉
|
|
189
|
+
channel: s.channel !== 'git' ? s.channel : initial.channel,
|
|
190
|
+
repoUrl: s.repoUrl,
|
|
191
|
+
token: s.token,
|
|
192
|
+
webdavUrl: s.webdavUrl,
|
|
193
|
+
webdavUsername: s.webdavUsername,
|
|
194
|
+
webdavPassword: s.webdavPassword,
|
|
195
|
+
byChannel: {
|
|
196
|
+
git: { ...defaultChannelSyncState(), ...s.byChannel.git },
|
|
197
|
+
webdav: { ...defaultChannelSyncState(), ...s.byChannel.webdav },
|
|
198
|
+
},
|
|
199
|
+
busy: s.busy,
|
|
200
|
+
savingConfig: s.savingConfig,
|
|
201
|
+
pushReport: s.pushReport,
|
|
202
|
+
pullReport: s.pullReport,
|
|
203
|
+
pushPreview: s.pushPreview ?? { preview: null, open: false },
|
|
204
|
+
confirmSession: s.confirmSession,
|
|
205
|
+
confirmDecisions: s.confirmDecisions,
|
|
206
|
+
lastRestoreId: s.lastRestoreId,
|
|
207
|
+
error: s.error,
|
|
208
|
+
loadError: s.loadError,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
213
|
+
const [state, setState] = useState<SyncUiState>(initFromStore)
|
|
214
|
+
const uiT = api.t // 客户端展示层翻译器(zh/en,见 ui/i18n.ts)
|
|
215
|
+
/** 最新 state 镜像(commit/自动保存 flush 读取,避免闭包过期值) */
|
|
216
|
+
const stateRef = useRef<SyncUiState>(state)
|
|
217
|
+
/** 挂载守卫:卸载后不再 setState(store 镜像仍执行,异步结果照常落库) */
|
|
218
|
+
const mountedRef = useRef(true)
|
|
219
|
+
/** 通道配置弹窗开关(瞬态 UI:切 tab/刷新不持久化,弹窗不自动重开;DESIGN.md §8.12 约定) */
|
|
220
|
+
const [channelOpen, setChannelOpen] = useState(false)
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 统一提交入口:更新 stateRef → 挂载时 setState → **总是**镜像进 runStore。
|
|
224
|
+
* 关键:镜像不依赖 effect flush —— 异步操作(push/pull/sync)完成回调在组件
|
|
225
|
+
* 已卸载(切走 tab)时也能把结果写进 store,切回 tab 时 initFromStore 恢复。
|
|
226
|
+
*/
|
|
227
|
+
const commit = (next: SyncUiState): void => {
|
|
228
|
+
stateRef.current = next
|
|
229
|
+
if (mountedRef.current) setState(next)
|
|
230
|
+
runStore.patch({ sync: toSyncStoreSlice(next) })
|
|
231
|
+
}
|
|
232
|
+
const patch = (p: Partial<SyncUiState>): void => commit({ ...stateRef.current, ...p })
|
|
233
|
+
/** 更新指定通道的 byChannel 状态(子 tab 切换后 loadSnapshots 等场景用)。 */
|
|
234
|
+
const patchChannelState = (ch: SyncChannel, p: Partial<ChannelSyncState>): void => commit({
|
|
235
|
+
...stateRef.current,
|
|
236
|
+
byChannel: {
|
|
237
|
+
...stateRef.current.byChannel,
|
|
238
|
+
[ch]: { ...stateRef.current.byChannel[ch], ...p },
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
/** 更新当前激活通道的 byChannel 状态。 */
|
|
242
|
+
const patchChannel = (p: Partial<ChannelSyncState>): void => patchChannelState(state.channel, p)
|
|
243
|
+
/** 当前激活通道的设置状态(自动同步/模式/加密/快照)。 */
|
|
244
|
+
const chState: ChannelSyncState = state.byChannel[state.channel]
|
|
245
|
+
/** GitHub 流程态(不进 store 切片;commit 的镜像写幂等无害)。 */
|
|
246
|
+
const patchGithub = (p: Partial<GithubUiState>): void => commit({
|
|
247
|
+
...stateRef.current,
|
|
248
|
+
github: { ...stateRef.current.github, ...p },
|
|
249
|
+
})
|
|
250
|
+
/** GitHub 轮询定时器(卸载/取消时清理,防止泄漏与跨流程串扰) */
|
|
251
|
+
const githubPollTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
252
|
+
/** 通道配置自动保存:防抖 timer + 待发 payload(关闭设置页前 flush,不丢输入) */
|
|
253
|
+
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
254
|
+
const pendingSave = useRef<SyncPushPayload | null>(null)
|
|
255
|
+
/** 保存请求在途(防重入:保存中又排入新改动 → 完成后补发最新 payload) */
|
|
256
|
+
const savingRef = useRef(false)
|
|
257
|
+
/** 正在拉取远端快照列表(按通道独立防抖/防并发) */
|
|
258
|
+
const loadingSnapshotsRef = useRef<Record<SyncChannel, boolean>>({ git: false, webdav: false })
|
|
259
|
+
|
|
260
|
+
/** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态 + 两通道 autosync/selection) */
|
|
261
|
+
const loadStatus = async (): Promise<void> => {
|
|
262
|
+
patch({ loading: true, loadError: null })
|
|
263
|
+
try {
|
|
264
|
+
const info = await api.status()
|
|
265
|
+
// 通道回填:优先磁盘持久化的选择(status.lastSyncChannel,ui-prefs.json);未记录过则
|
|
266
|
+
// 回退 localStorage 记忆(升级前遗留)→ 最后按配置(sync-config.transport)
|
|
267
|
+
const savedChannel: SyncChannel = info.transport?.type === 'webdav' ? 'webdav' : 'git'
|
|
268
|
+
const remembered = info.lastSyncChannel ?? readStoredChannel()
|
|
269
|
+
// 可同步分区目录回填(host adapters 唯一事实源;两通道共用)
|
|
270
|
+
const catalog = info.syncSections !== undefined ? syncSectionOptions(info.syncSections) : []
|
|
271
|
+
// 每通道回填:优先该通道的持久化配置(syncSelectionByChannel / autosyncByChannel);
|
|
272
|
+
// 无持久化 → 默认模式 + 推荐分区
|
|
273
|
+
const selByCh = info.syncSelectionByChannel
|
|
274
|
+
const autoByCh = info.autosyncByChannel
|
|
275
|
+
const backfill = (ch: SyncChannel, cur: ChannelSyncState): Partial<ChannelSyncState> => {
|
|
276
|
+
const sel = selByCh?.[ch]
|
|
277
|
+
const auto = autoByCh?.[ch]
|
|
278
|
+
const persistedMode: SyncMode = sel?.mode === 'advanced' ? 'advanced' : 'default'
|
|
279
|
+
const persistedSections = sel !== undefined
|
|
280
|
+
? sel.sections
|
|
281
|
+
: recommendedSyncSections(info.syncSections ?? [])
|
|
282
|
+
return {
|
|
283
|
+
syncMode: persistedMode,
|
|
284
|
+
syncSections: persistedSections,
|
|
285
|
+
autosyncEnabled: auto?.enabled ?? false,
|
|
286
|
+
autosyncInterval: auto?.interval ?? '30m',
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
patch({
|
|
290
|
+
loading: false,
|
|
291
|
+
statusInfo: info,
|
|
292
|
+
channel: remembered ?? savedChannel,
|
|
293
|
+
repoUrl: info.repoUrl ?? '',
|
|
294
|
+
webdavUrl: info.webdav?.url ?? '',
|
|
295
|
+
webdavUsername: info.webdav?.username ?? '',
|
|
296
|
+
catalog,
|
|
297
|
+
byChannel: {
|
|
298
|
+
git: { ...stateRef.current.byChannel.git, ...backfill('git', stateRef.current.byChannel.git) },
|
|
299
|
+
webdav: { ...stateRef.current.byChannel.webdav, ...backfill('webdav', stateRef.current.byChannel.webdav) },
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
// 独立拉取 autosync(若 status 未带按通道状态则补一次)
|
|
303
|
+
if (autoByCh === undefined) {
|
|
304
|
+
void loadAutosync()
|
|
305
|
+
}
|
|
306
|
+
// 当前激活通道已配置且远端地址就绪时,自动拉取远端快照填充下拉(无需先点一键同步);
|
|
307
|
+
// 直接传 info 的地址(state.patch 尚未生效),避免竞态
|
|
308
|
+
const activeCh = remembered ?? savedChannel
|
|
309
|
+
const preset = activeCh === 'webdav' ? (info.webdav?.url ?? '') : (info.repoUrl ?? '')
|
|
310
|
+
if (info.configured && preset.trim() !== '') {
|
|
311
|
+
void loadSnapshots(preset, activeCh)
|
|
312
|
+
}
|
|
313
|
+
// 校验 GitHub token 有效性:已登录(有效)→ 隐藏 GitHub 登录块;未配置/失效 → 显示
|
|
314
|
+
void validateGithub()
|
|
315
|
+
} catch (err) {
|
|
316
|
+
patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 读取全部通道的自动同步状态(GET /sync/autosync 返回 { git, webdav })。 */
|
|
321
|
+
const loadAutosync = async (): Promise<void> => {
|
|
322
|
+
try {
|
|
323
|
+
const all = await api.autosyncStatusAll()
|
|
324
|
+
patchChannelState('git', {
|
|
325
|
+
autosync: all.git, autosyncEnabled: all.git.enabled, autosyncInterval: all.git.interval,
|
|
326
|
+
})
|
|
327
|
+
patchChannelState('webdav', {
|
|
328
|
+
autosync: all.webdav, autosyncEnabled: all.webdav.enabled, autosyncInterval: all.webdav.interval,
|
|
329
|
+
})
|
|
330
|
+
} catch (err) {
|
|
331
|
+
// R-20:读取自动同步状态失败(此前写入页面最底部的共享 error Banner,常滚出视野)
|
|
332
|
+
toast.error(`${t('toast.autosyncLoadFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 读取指定通道的远端历史快照列表(「选择历史快照」下拉数据源)。
|
|
338
|
+
* urlOverride / channelOverride:挂载时地址刚从 status 回填、state.patch 尚未生效,
|
|
339
|
+
* 直接传 info 的地址与通道避免竞态读旧值;缺省读当前 state。
|
|
340
|
+
* 无远端地址时静默跳过(不发无效请求,下拉留空)。
|
|
341
|
+
* announce:仅用户主动点「刷新快照」时为 true —— 成功给出回执,避免用户
|
|
342
|
+
* 在「远端确实没有快照」与「刷新没生效」之间无从判断(M-18)。 */
|
|
343
|
+
const loadSnapshots = async (urlOverride?: string, channelOverride?: SyncChannel, announce = false): Promise<void> => {
|
|
344
|
+
const ch = channelOverride ?? stateRef.current.channel
|
|
345
|
+
if (loadingSnapshotsRef.current[ch]) return
|
|
346
|
+
loadingSnapshotsRef.current[ch] = true
|
|
347
|
+
patchChannelState(ch, { loadingSnapshots: true })
|
|
348
|
+
try {
|
|
349
|
+
const s = stateRef.current
|
|
350
|
+
if (ch === 'webdav') {
|
|
351
|
+
const url = urlOverride ?? s.webdavUrl
|
|
352
|
+
if (url.trim() === '') return
|
|
353
|
+
// 带 username/password(非空时):挂载早期 state 未回填 → undefined,Host 端回退持久化配置补 username
|
|
354
|
+
const res = await api.snapshotsList({
|
|
355
|
+
transport: 'webdav',
|
|
356
|
+
url: url.trim(),
|
|
357
|
+
username: s.webdavUsername.trim() !== '' ? s.webdavUsername.trim() : undefined,
|
|
358
|
+
password: s.webdavPassword !== '' ? s.webdavPassword : undefined,
|
|
359
|
+
})
|
|
360
|
+
patchChannelState('webdav', { snapshots: res.snapshots })
|
|
361
|
+
} else {
|
|
362
|
+
const repo = urlOverride ?? s.repoUrl
|
|
363
|
+
if (repo.trim() === '') return
|
|
364
|
+
const res = await api.snapshotsList({
|
|
365
|
+
transport: 'git',
|
|
366
|
+
repoUrl: repo.trim(),
|
|
367
|
+
token: s.token.trim() !== '' ? s.token.trim() : undefined,
|
|
368
|
+
})
|
|
369
|
+
patchChannelState('git', { snapshots: res.snapshots })
|
|
370
|
+
}
|
|
371
|
+
// M-18:用户主动点「刷新快照」成功后的回执(自动拉取/切换通道时不打扰)
|
|
372
|
+
if (announce) toast.ok(t('toast.snapshotsRefreshed'))
|
|
373
|
+
} catch (err) {
|
|
374
|
+
// M-18/M-21:拉取远端快照失败原先完全静默(下拉留空),用户无法区分
|
|
375
|
+
// 「远端确实没有快照」与「读取失败」。不阻断主流程,仅以 Toast 如实告知。
|
|
376
|
+
toast.error(`${t('toast.snapshotsLoadFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
377
|
+
} finally {
|
|
378
|
+
loadingSnapshotsRef.current[ch] = false
|
|
379
|
+
patchChannelState(ch, { loadingSnapshots: false })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
void loadStatus()
|
|
385
|
+
// api 为注入单例(注册时创建),生命周期内稳定;仅挂载时加载一次
|
|
386
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
387
|
+
}, [])
|
|
388
|
+
|
|
389
|
+
/** 卸载时置挂载守卫 + 清理轮询定时器 + 补发未落盘的通道配置改动 + 最后镜像一次状态
|
|
390
|
+
* (组件销毁后不得再 setState/发请求;store 镜像为纯内存/白名单写,安全)。
|
|
391
|
+
* 异步操作完成回调仍会走 commit 写 store(见 commit 注释),结果不丢。 */
|
|
392
|
+
useEffect(() => () => {
|
|
393
|
+
mountedRef.current = false
|
|
394
|
+
if (githubPollTimer.current !== null) clearTimeout(githubPollTimer.current)
|
|
395
|
+
if (saveTimer.current !== null) {
|
|
396
|
+
clearTimeout(saveTimer.current)
|
|
397
|
+
saveTimer.current = null
|
|
398
|
+
}
|
|
399
|
+
// 关闭设置页前若还有未保存的改动:立即补发(host 侧落盘;此路径只发请求不 setState)
|
|
400
|
+
const pending = pendingSave.current
|
|
401
|
+
if (pending !== null) {
|
|
402
|
+
void api.saveConfig(pending).catch(() => { /* 已卸载:静默,不打扰用户 */ })
|
|
403
|
+
}
|
|
404
|
+
// 最后镜像一次(防止「最后一次改动后立即切 tab」时 commit 之前的瞬态丢失)
|
|
405
|
+
runStore.patch({ sync: toSyncStoreSlice(stateRef.current) })
|
|
406
|
+
}, [])
|
|
407
|
+
|
|
408
|
+
/** 表单快照 → 请求体(按当前通道构建;空串不携带;password 仅内存不发回显) */
|
|
409
|
+
const payload = (): SyncPushPayload => {
|
|
410
|
+
if (state.channel === 'webdav') {
|
|
411
|
+
return {
|
|
412
|
+
transport: 'webdav',
|
|
413
|
+
url: state.webdavUrl.trim() !== '' ? state.webdavUrl.trim() : undefined,
|
|
414
|
+
username: state.webdavUsername.trim() !== '' ? state.webdavUsername.trim() : undefined,
|
|
415
|
+
password: state.webdavPassword !== '' ? state.webdavPassword : undefined,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
transport: 'git',
|
|
420
|
+
repoUrl: state.repoUrl.trim(),
|
|
421
|
+
token: state.token.trim() !== '' ? state.token : undefined,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* ------------------------------------------------ 通道配置持久化(自动保存 + 显式保存) */
|
|
426
|
+
|
|
427
|
+
/** 按给定 state 构建「保存配置」请求体;当前通道远端地址未就绪(webdav url / git repoUrl 为空)
|
|
428
|
+
* → 返回 null(无可保存内容,自动保存跳过)。password/token 仅非空携带(空 = 沿用已保存凭据)。 */
|
|
429
|
+
const buildConfigPayload = (s: SyncUiState): SyncPushPayload | null => {
|
|
430
|
+
if (s.channel === 'webdav') {
|
|
431
|
+
const url = s.webdavUrl.trim()
|
|
432
|
+
if (url === '') return null
|
|
433
|
+
return {
|
|
434
|
+
transport: 'webdav',
|
|
435
|
+
url,
|
|
436
|
+
username: s.webdavUsername.trim() !== '' ? s.webdavUsername.trim() : undefined,
|
|
437
|
+
password: s.webdavPassword !== '' ? s.webdavPassword : undefined,
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const repoUrl = s.repoUrl.trim()
|
|
441
|
+
if (repoUrl === '') return null
|
|
442
|
+
return {
|
|
443
|
+
transport: 'git',
|
|
444
|
+
repoUrl,
|
|
445
|
+
token: s.token.trim() !== '' ? s.token.trim() : undefined,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 实际发送保存请求:成功清空已入库的 password/token(与 push 一致)并刷新凭据徽章;
|
|
450
|
+
* 失败保留表单值以便重试。防重入:保存中又排入新改动 → 完成后自动补发最新 payload。
|
|
451
|
+
* announce:仅「手动点保存」为 true —— 自动保存(输入防抖)成功时不弹 Toast,
|
|
452
|
+
* 否则每次停顿改字段都会刷一条通知;但**失败必须始终提示**(用户的改动没落盘)。 */
|
|
453
|
+
const doSaveConfig = async (payloadToSave: SyncPushPayload, announce = false): Promise<void> => {
|
|
454
|
+
if (savingRef.current) {
|
|
455
|
+
pendingSave.current = payloadToSave
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
savingRef.current = true
|
|
459
|
+
patch({ savingConfig: true })
|
|
460
|
+
try {
|
|
461
|
+
const saved = await api.saveConfig(payloadToSave)
|
|
462
|
+
// 基于 stateRef 计算(同步权威),经 commit 落库:即使保存完成时组件已卸载
|
|
463
|
+
// (切走 tab),savingConfig 复位与凭据清空仍会镜像进 store,切回后一致
|
|
464
|
+
const s = stateRef.current
|
|
465
|
+
const next: SyncUiState = { ...s, savingConfig: false }
|
|
466
|
+
// 只清空「本次已写入的」password/token:若保存期间用户已改输入则保留新值
|
|
467
|
+
next.webdavPassword = s.webdavPassword !== '' && s.webdavPassword !== payloadToSave.password
|
|
468
|
+
? s.webdavPassword
|
|
469
|
+
: ''
|
|
470
|
+
next.token = s.token !== '' && s.token !== payloadToSave.token ? s.token : ''
|
|
471
|
+
// 凭据徽章合并(响应只含布尔,无 secret 值)
|
|
472
|
+
if (s.statusInfo !== null) {
|
|
473
|
+
const info: SyncStatusResponse = {
|
|
474
|
+
...s.statusInfo,
|
|
475
|
+
configured: true,
|
|
476
|
+
credentialConfigured: saved.credentialConfigured,
|
|
477
|
+
}
|
|
478
|
+
if (saved.webdav !== undefined) {
|
|
479
|
+
info.webdav = {
|
|
480
|
+
url: s.statusInfo.webdav?.url,
|
|
481
|
+
username: s.statusInfo.webdav?.username,
|
|
482
|
+
usernameConfigured: saved.webdav.usernameConfigured,
|
|
483
|
+
passwordConfigured: saved.webdav.passwordConfigured,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
next.statusInfo = info
|
|
487
|
+
}
|
|
488
|
+
commit(next)
|
|
489
|
+
// M-17:手动保存成功给出回执(自动保存静默,避免输入防抖刷屏)
|
|
490
|
+
if (announce) toast.ok(t('toast.configSaved'))
|
|
491
|
+
// 手动填入的 git token 保存成功 → 校验有效性(有效则隐藏 GitHub 登录块)
|
|
492
|
+
if (payloadToSave.transport !== 'webdav' && saved.credentialConfigured) {
|
|
493
|
+
void validateGithub()
|
|
494
|
+
}
|
|
495
|
+
} catch (err) {
|
|
496
|
+
// R-20:保存失败**始终**提示(无论手动还是自动)——用户的改动没有落盘
|
|
497
|
+
toast.error(`${t('toast.configSaveFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
498
|
+
patch({ savingConfig: false })
|
|
499
|
+
} finally {
|
|
500
|
+
savingRef.current = false
|
|
501
|
+
// 保存期间又排入的新改动 → 立即补发(保底,不丢输入)
|
|
502
|
+
if (pendingSave.current !== null) {
|
|
503
|
+
const p = pendingSave.current
|
|
504
|
+
pendingSave.current = null
|
|
505
|
+
void doSaveConfig(p, announce)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** 表单改动 → 防抖 600ms 自动保存(取最新 state;地址未就绪时跳过)。 */
|
|
511
|
+
const scheduleConfigSave = (): void => {
|
|
512
|
+
const payloadToSave = buildConfigPayload(stateRef.current)
|
|
513
|
+
pendingSave.current = payloadToSave
|
|
514
|
+
if (saveTimer.current !== null) clearTimeout(saveTimer.current)
|
|
515
|
+
if (payloadToSave === null) {
|
|
516
|
+
saveTimer.current = null
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
saveTimer.current = setTimeout(() => {
|
|
520
|
+
saveTimer.current = null
|
|
521
|
+
flushConfigSave()
|
|
522
|
+
}, 600)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** 立即保存(「保存配置」按钮 / 防抖到点):优先待发改动,否则按当前表单值。
|
|
526
|
+
* announce:仅手动点按钮为 true —— 自动保存成功不弹回执(见 doSaveConfig)。 */
|
|
527
|
+
const flushConfigSave = (announce = false): void => {
|
|
528
|
+
if (saveTimer.current !== null) {
|
|
529
|
+
clearTimeout(saveTimer.current)
|
|
530
|
+
saveTimer.current = null
|
|
531
|
+
}
|
|
532
|
+
const pending = pendingSave.current
|
|
533
|
+
pendingSave.current = null
|
|
534
|
+
const payloadToSave = pending ?? buildConfigPayload(stateRef.current)
|
|
535
|
+
if (payloadToSave === null) {
|
|
536
|
+
// M-17:手动点保存但地址未填写 → 此前直接 return(按钮看起来无反应),现给出明确提示
|
|
537
|
+
if (announce) toast.info(t('toast.configNothingToSave'))
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
void doSaveConfig(payloadToSave, announce)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* ------------------------------------------------ GitHub OAuth device flow */
|
|
544
|
+
|
|
545
|
+
/** 发起 GitHub 登录:取设备码 → 展示一次性用户码 + 授权页 → 开始轮询 */
|
|
546
|
+
const runGithubStart = async (): Promise<void> => {
|
|
547
|
+
patchGithub({ phase: 'starting', error: null })
|
|
548
|
+
try {
|
|
549
|
+
const info = await api.githubStart()
|
|
550
|
+
patchGithub({
|
|
551
|
+
phase: 'waiting',
|
|
552
|
+
flowId: info.flowId,
|
|
553
|
+
userCode: info.userCode,
|
|
554
|
+
verificationUri: info.verificationUri,
|
|
555
|
+
interval: info.interval,
|
|
556
|
+
})
|
|
557
|
+
scheduleGithubPoll(info.flowId, Math.max(info.interval, 1) * 1000)
|
|
558
|
+
} catch (err) {
|
|
559
|
+
patchGithub({ phase: 'error', error: err instanceof Error ? err.message : String(err) })
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** 排定一次 GitHub 轮询(先清旧定时器,避免重复轮询) */
|
|
564
|
+
const scheduleGithubPoll = (flowId: string, delayMs: number): void => {
|
|
565
|
+
if (githubPollTimer.current !== null) clearTimeout(githubPollTimer.current)
|
|
566
|
+
githubPollTimer.current = setTimeout(() => { void runGithubPoll(flowId) }, delayMs)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 轮询 GitHub 授权结果:pending 继续等;success 刷新凭据状态;终止态展示结果 */
|
|
570
|
+
const runGithubPoll = async (flowId: string): Promise<void> => {
|
|
571
|
+
patchGithub({ phase: 'polling' })
|
|
572
|
+
try {
|
|
573
|
+
const poll = await api.githubPoll(flowId)
|
|
574
|
+
if (poll.status === 'pending') {
|
|
575
|
+
patchGithub({ phase: 'waiting' })
|
|
576
|
+
scheduleGithubPoll(flowId, poll.pollDelayMs ?? Math.max(state.github.interval, 1) * 1000)
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
const message = githubPollMessage(poll, uiT)
|
|
580
|
+
if (poll.status === 'success') {
|
|
581
|
+
// token 已由宿主写入 DSH credentials:标记已登录(隐藏登录块)+ 刷新状态/凭据徽章
|
|
582
|
+
patch({ githubSignedIn: true, github: { ...stateRef.current.github, phase: 'success', error: null } })
|
|
583
|
+
void loadStatus()
|
|
584
|
+
} else {
|
|
585
|
+
patchGithub({ phase: 'error', error: message })
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
patchGithub({ phase: 'error', error: err instanceof Error ? err.message : String(err) })
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 取消登录:停轮询 + 通知宿主丢弃设备码登记 + 复位 UI */
|
|
593
|
+
const runGithubCancel = async (): Promise<void> => {
|
|
594
|
+
if (githubPollTimer.current !== null) {
|
|
595
|
+
clearTimeout(githubPollTimer.current)
|
|
596
|
+
githubPollTimer.current = null
|
|
597
|
+
}
|
|
598
|
+
const flowId = state.github.flowId
|
|
599
|
+
patchGithub(initialGithub)
|
|
600
|
+
if (flowId !== '') {
|
|
601
|
+
try { await api.githubCancel(flowId) } catch { /* 取消失败无需打扰用户 */ }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 校验 GitHub token 是否有效(「已登录」判定 → 决定登录块显隐)。
|
|
607
|
+
* 挂载 / 登录成功 / 保存凭据后调用;已确认登录(githubSignedIn===true)时跳过
|
|
608
|
+
* (避免反复网络调用)。401 → 未登录:显示登录块并提示重新登录;网络等其余
|
|
609
|
+
* 错误 → 保持现状(不误判登出,已登录用户不被打扰;下次进入页面会再校验)。
|
|
610
|
+
*/
|
|
611
|
+
const validateGithub = async (): Promise<void> => {
|
|
612
|
+
if (stateRef.current.githubSignedIn === true) return
|
|
613
|
+
try {
|
|
614
|
+
const res = await api.githubValidate()
|
|
615
|
+
patch({ githubSignedIn: res.configured && res.valid })
|
|
616
|
+
} catch {
|
|
617
|
+
// 校验失败(网络/限流等):不可知 → 维持现状(null 隐藏 / 既有值不变)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* ------------------------------------------------ 通道配置弹窗(弹窗驱动,DESIGN.md §8.12 同体系) */
|
|
622
|
+
|
|
623
|
+
/** 打开通道配置弹窗:登录态尚未校验时补一次校验(决定 Git 子 tab 登录块显隐)。 */
|
|
624
|
+
const openChannelDialog = (): void => {
|
|
625
|
+
setChannelOpen(true)
|
|
626
|
+
if (stateRef.current.githubSignedIn === null) void validateGithub()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* 关闭通道配置弹窗 = 放弃本次操作(§8.12 约定):GitHub 登录流程进行中则取消
|
|
631
|
+
* (停轮询 + 通知宿主丢弃设备码登记),保存中(savingConfig)时禁止关闭。
|
|
632
|
+
*/
|
|
633
|
+
const closeChannelDialog = (): void => {
|
|
634
|
+
if (stateRef.current.savingConfig) return
|
|
635
|
+
const phase = stateRef.current.github.phase
|
|
636
|
+
if (phase === 'starting' || phase === 'waiting' || phase === 'polling') {
|
|
637
|
+
void runGithubCancel()
|
|
638
|
+
}
|
|
639
|
+
setChannelOpen(false)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 组装 push/preview 的公共载荷(分区选择;快照恒为明文) */
|
|
643
|
+
const buildPushPayload = (): SyncPushPayload => {
|
|
644
|
+
// 默认模式:不传 sections(= 全部推荐分区);高级模式:传勾选分区
|
|
645
|
+
const selection =
|
|
646
|
+
chState.syncMode === 'advanced' && chState.syncSections.length > 0
|
|
647
|
+
? { sections: chState.syncSections }
|
|
648
|
+
: {}
|
|
649
|
+
return { ...payload(), ...selection }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** P0-②:push 前只读预览(弹窗确认流程第一步)——不写远端,只展示「将推送什么」。 */
|
|
653
|
+
const runPushPreview = async (): Promise<void> => {
|
|
654
|
+
patch({ busy: 'push', pushReport: null, pullReport: null })
|
|
655
|
+
try {
|
|
656
|
+
const preview = await api.pushPreview(buildPushPayload())
|
|
657
|
+
patch({ busy: null, pushPreview: { preview, open: true } })
|
|
658
|
+
} catch (err) {
|
|
659
|
+
// R-20:预览失败(此前写共享 error Banner,渲染在页面最底部而按钮在中部)
|
|
660
|
+
patch({ busy: null })
|
|
661
|
+
toast.error(`${t('toast.pushPreviewFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** P0-②:确认弹窗里点「确认推送」→ 真正推送(复用既有 push 语义)。 */
|
|
666
|
+
const runPush = async (): Promise<void> => {
|
|
667
|
+
patch({ busy: 'push', pushReport: null, pullReport: null })
|
|
668
|
+
try {
|
|
669
|
+
const report = await api.push(buildPushPayload())
|
|
670
|
+
// 成功即清空 token/webdavPassword(已安全使用完;绝不持久化);失败保留以便重试
|
|
671
|
+
patch({
|
|
672
|
+
busy: null, pushReport: report, pushPreview: { preview: null, open: false },
|
|
673
|
+
...(report.ok
|
|
674
|
+
? { token: '', webdavPassword: '' }
|
|
675
|
+
: {}),
|
|
676
|
+
})
|
|
677
|
+
if (report.ok) {
|
|
678
|
+
// M-19:推送终局回执(结果弹窗关闭后不再有任何痕迹)
|
|
679
|
+
toast.ok(t('toast.pushDone'))
|
|
680
|
+
void loadSnapshots()
|
|
681
|
+
} else {
|
|
682
|
+
// 失败保留结果弹窗(K-15,含告警明细);同时给出不依赖弹窗的回执
|
|
683
|
+
toast.error(t('toast.pushFailed'))
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
patch({ busy: null })
|
|
687
|
+
toast.error(`${t('toast.pushFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const runPull = async (): Promise<void> => {
|
|
692
|
+
patch({ busy: 'pull', pullReport: null, pushReport: null })
|
|
693
|
+
try {
|
|
694
|
+
const report = await api.pull({ ...payload() })
|
|
695
|
+
patch({ busy: null, pullReport: report, token: '', webdavPassword: '' })
|
|
696
|
+
// M-19:拉取终局回执(弹窗关闭后无痕迹)
|
|
697
|
+
toast.ok(t('toast.pullDone'))
|
|
698
|
+
} catch (err) {
|
|
699
|
+
patch({ busy: null })
|
|
700
|
+
toast.error(`${t('toast.pullFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/* ------------------------------------------------ 同步模式(默认/高级) */
|
|
705
|
+
|
|
706
|
+
/** 保存当前通道的分区选择到 Host(持久化;自动同步与手动 push 共用;失败提示但不阻断本地 UI)。 */
|
|
707
|
+
const saveSelection = async (mode: SyncMode, sections: SectionId[]): Promise<void> => {
|
|
708
|
+
try {
|
|
709
|
+
await api.saveSelection({ transport: state.channel, mode, sections })
|
|
710
|
+
} catch (err) {
|
|
711
|
+
// R-20/M-22:同步设置持久化失败(此前写共享 error Banner,与刚点的模式页签相距整屏)
|
|
712
|
+
toast.error(`${t('toast.selectionSaveFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** 切换当前通道的同步模式并持久化(高级模式勾选沿用当前勾选,切回时保留)。 */
|
|
717
|
+
const setSyncMode = (mode: SyncMode): void => {
|
|
718
|
+
patchChannel({ syncMode: mode })
|
|
719
|
+
void saveSelection(mode, state.byChannel[state.channel].syncSections)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 当前通道高级模式勾选分区开关(增删 byChannel 勾选并立即持久化)。 */
|
|
723
|
+
const toggleSyncSection = (id: SectionId, checked: boolean): void => {
|
|
724
|
+
const cur = state.byChannel[state.channel].syncSections
|
|
725
|
+
const next = checked
|
|
726
|
+
? (cur.includes(id) ? cur : [...cur, id])
|
|
727
|
+
: cur.filter((s) => s !== id)
|
|
728
|
+
patchChannel({ syncSections: next })
|
|
729
|
+
void saveSelection(state.byChannel[state.channel].syncMode, next)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** 默认模式的推荐分区数(渲染计数用)。 */
|
|
733
|
+
const recommendedSectionCount = state.catalog.filter((c) => c.defaultIncluded).length
|
|
734
|
+
|
|
735
|
+
/* ------------------------------------------------ 一键同步(方案 A) */
|
|
736
|
+
|
|
737
|
+
/** 一键同步:拉取 → 差异确认会话(先取消旧会话,再发起新会话)。 */
|
|
738
|
+
const runSync = async (snapshotId?: string): Promise<void> => {
|
|
739
|
+
// 清理旧的差异确认会话(避免残留临时 ZIP / 同 key 冲突)
|
|
740
|
+
if (state.confirmSession !== null) {
|
|
741
|
+
try { await api.cancel(state.confirmSession.syncSessionId) } catch { /* 尽力清理 */ }
|
|
742
|
+
}
|
|
743
|
+
patch({ busy: 'sync', confirmSession: null, confirmDecisions: null, lastRestoreId: null })
|
|
744
|
+
try {
|
|
745
|
+
const session = await api.sync({
|
|
746
|
+
...payload(),
|
|
747
|
+
...(snapshotId !== undefined && snapshotId !== '' ? { snapshotId } : {}),
|
|
748
|
+
})
|
|
749
|
+
if (!session.ok) {
|
|
750
|
+
// R-20:一键同步启动失败(宿主明确回报 message,无异常抛出)
|
|
751
|
+
patch({ busy: null })
|
|
752
|
+
toast.error(`${t('toast.syncStartFailed')}:${redact(session.message ?? t('syncflow.syncFailed'))}`)
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
patch({ busy: null, confirmSession: session, confirmDecisions: null, token: '', webdavPassword: '' })
|
|
756
|
+
void loadSnapshots()
|
|
757
|
+
} catch (err) {
|
|
758
|
+
patch({ busy: null })
|
|
759
|
+
toast.error(`${t('toast.syncStartFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** 用户取消差异确认:清除会话,复位到空闲。 */
|
|
764
|
+
const cancelConfirm = (): void => {
|
|
765
|
+
patch({ confirmSession: null, confirmDecisions: null })
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** 从 SyncConfirmView 透传的一键回滚完成信号。 */
|
|
769
|
+
const onRollbackApplied = (): void => {
|
|
770
|
+
patch({ lastRestoreId: null })
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/* ------------------------------------------------ 通道子 tab 切换 */
|
|
774
|
+
|
|
775
|
+
/** 切换通道子 tab:记录偏好 + 拉取目标通道远端快照。busy 时禁用切换(防并发操作)。 */
|
|
776
|
+
const switchChannel = (ch: SyncChannel): void => {
|
|
777
|
+
if (ch === state.channel || state.busy !== null) return
|
|
778
|
+
patch({ channel: ch })
|
|
779
|
+
writeStoredChannel(ch) // 同步写 localStorage 立即生效(status 未带回填时的兜底)
|
|
780
|
+
// 异步持久化到磁盘(ui-prefs.json,随 self 分区进导出备份);失败静默降级
|
|
781
|
+
void api.saveUiPrefs({ lastSyncChannel: ch }).catch(() => { /* 保存失败不阻断切换 */ })
|
|
782
|
+
void loadSnapshots(undefined, ch)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/* ------------------------------------------------ 自动同步(按通道) */
|
|
786
|
+
|
|
787
|
+
const toggleAutosync = async (enabled: boolean): Promise<void> => {
|
|
788
|
+
patchChannel({ autosyncEnabled: enabled })
|
|
789
|
+
try {
|
|
790
|
+
const updated = await api.autosyncUpdate({ transport: state.channel, enabled, interval: chState.autosyncInterval })
|
|
791
|
+
patchChannel({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
|
|
792
|
+
// M-20:乐观更新已生效,补一条终局回执确认宿主已受理
|
|
793
|
+
toast.ok(t('toast.autosyncUpdated'))
|
|
794
|
+
} catch (err) {
|
|
795
|
+
// R-20/M-20:失败必须提示 —— 界面是乐观更新,用户会以为开关已生效
|
|
796
|
+
toast.error(`${t('toast.autosyncUpdateFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const updateAutosyncInterval = async (interval: AutosyncInterval): Promise<void> => {
|
|
801
|
+
patchChannel({ autosyncInterval: interval })
|
|
802
|
+
try {
|
|
803
|
+
const updated = await api.autosyncUpdate({ transport: state.channel, enabled: chState.autosyncEnabled, interval })
|
|
804
|
+
patchChannel({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
|
|
805
|
+
toast.ok(t('toast.autosyncUpdated'))
|
|
806
|
+
} catch (err) {
|
|
807
|
+
toast.error(`${t('toast.autosyncUpdateFailed')}:${redact(err instanceof Error ? err.message : String(err))}`)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** 活动通道的远端地址是否就绪(git=repoUrl 非空;webdav=webdavUrl 非空) */
|
|
812
|
+
const remoteReady = computeRemoteReady(state.channel, state.repoUrl, state.webdavUrl)
|
|
813
|
+
const buttons = computeSyncButtons(state.busy, remoteReady, uiT)
|
|
814
|
+
const pushView = pushReportView(state.pushReport, uiT)
|
|
815
|
+
const pullView = pullReportView(state.pullReport, uiT)
|
|
816
|
+
const githubView = computeGithubLoginView(
|
|
817
|
+
state.github.phase, state.github.userCode, state.github.verificationUri, state.github.error, uiT,
|
|
818
|
+
)
|
|
819
|
+
/** GitHub 流程进行中(请求设备码 / 等待授权 / 轮询):禁用 push/pull,避免无凭据操作 */
|
|
820
|
+
const githubBusy =
|
|
821
|
+
state.github.phase === 'starting' || state.github.phase === 'waiting' || state.github.phase === 'polling'
|
|
822
|
+
|
|
823
|
+
/** 高级模式勾选为空 → 禁止推送(默认模式不受限)。 */
|
|
824
|
+
const pushSelectionReady = chState.syncMode !== 'advanced' || chState.syncSections.length > 0
|
|
825
|
+
|
|
826
|
+
const autosyncText = chState.autosync !== null ? autosyncStatusText(chState.autosync, uiT) : t('autosync.statusNever')
|
|
827
|
+
/** 距下次自动同步剩余 ms(null = 从未运行;0 = 已到期) */
|
|
828
|
+
const autosyncCountdownMs = chState.autosync !== null && chState.autosync.elapsedMs >= 0
|
|
829
|
+
? computeAutosyncCountdown(chState.autosync.elapsedMs, autosyncIntervalMs(chState.autosync.interval))
|
|
830
|
+
: null
|
|
831
|
+
|
|
832
|
+
return (
|
|
833
|
+
<div className={css.viewBody}>
|
|
834
|
+
<SectionTitle title={t('section.label')} subtitle={t('section.description')} />
|
|
835
|
+
|
|
836
|
+
{/* M-16:页面级加载失败就地提示(此前 loadError 只写不读,宿主不可达时整页显示
|
|
837
|
+
「未配置」假象)。此处保留可就地重试的 ErrorBanner —— 它是**页面是否可用**
|
|
838
|
+
的前提条件,必须常驻到用户重试成功,不能交给会自动消失的 Toast。 */}
|
|
839
|
+
{state.loadError !== null && (
|
|
840
|
+
<ErrorBanner
|
|
841
|
+
error={`${t('load.failed')}:${redact(state.loadError)}`}
|
|
842
|
+
onRetry={() => { void loadStatus() }}
|
|
843
|
+
retrying={state.loading}
|
|
844
|
+
t={api.t}
|
|
845
|
+
/>
|
|
846
|
+
)}
|
|
847
|
+
|
|
848
|
+
{/* 同步通道入口卡:通道配置改为弹窗驱动(点按钮 → 弹窗内配置 Git/WebDAV 通道;
|
|
849
|
+
弹窗样式复用市场操作弹窗体系,DESIGN.md §8.12) */}
|
|
850
|
+
<Card>
|
|
851
|
+
<span className={css.groupLabel}>{t('channel.title')}</span>
|
|
852
|
+
<span className={css.hint}>{t('channel.openHint')}</span>
|
|
853
|
+
<div className={css.statRow}>
|
|
854
|
+
<Badge kind="info">{state.channel === 'webdav' ? t('channel.webdav') : t('channel.git')}</Badge>
|
|
855
|
+
<Badge kind={remoteReady ? 'ok' : 'warn'}>
|
|
856
|
+
{remoteReady ? t('channel.configured') : t('channel.notConfigured')}
|
|
857
|
+
</Badge>
|
|
858
|
+
{state.channel === 'git' && state.statusInfo?.credentialConfigured === true && (
|
|
859
|
+
<Badge kind="ok">{t('config.tokenSaved')}</Badge>
|
|
860
|
+
)}
|
|
861
|
+
{state.channel === 'webdav' && state.statusInfo?.webdav?.passwordConfigured === true && (
|
|
862
|
+
<Badge kind="ok">{t('webdav.passwordSaved')}</Badge>
|
|
863
|
+
)}
|
|
864
|
+
</div>
|
|
865
|
+
{/* 状态事实行(Workbench:配置状态/上次同步/可同步分区——未配置时也要给硬事实) */}
|
|
866
|
+
<div className={css.factGrid} style={{ marginTop: 8 }}>
|
|
867
|
+
<div className={css.factCell}>
|
|
868
|
+
<span className={css.factLabel}>{t('syncStatus.state')}</span>
|
|
869
|
+
<span className={css.factValue}>
|
|
870
|
+
{state.statusInfo?.configured === true ? t('channel.configured') : t('channel.notConfigured')}
|
|
871
|
+
</span>
|
|
872
|
+
</div>
|
|
873
|
+
<div className={css.factCell}>
|
|
874
|
+
<span className={css.factLabel}>{t('syncStatus.lastSync')}</span>
|
|
875
|
+
<span className={css.factValue}>
|
|
876
|
+
{state.statusInfo?.lastSyncAt !== undefined
|
|
877
|
+
? new Date(state.statusInfo.lastSyncAt).toLocaleString()
|
|
878
|
+
: '—'}
|
|
879
|
+
</span>
|
|
880
|
+
</div>
|
|
881
|
+
{state.statusInfo?.sectionCount !== undefined && (
|
|
882
|
+
<div className={css.factCell}>
|
|
883
|
+
<span className={css.factLabel}>{t('syncStatus.sections')}</span>
|
|
884
|
+
<span className={`${css.factValue} ${css.mono}`}>{String(state.statusInfo.sectionCount)}</span>
|
|
885
|
+
</div>
|
|
886
|
+
)}
|
|
887
|
+
{remoteReady && (
|
|
888
|
+
<div className={css.factCell} style={{ gridColumn: '1 / -1' }}>
|
|
889
|
+
<span className={css.factLabel}>{t('channel.currentUrl')}</span>
|
|
890
|
+
<span className={css.factValue}>
|
|
891
|
+
<span className={css.mono}>
|
|
892
|
+
{(state.channel === 'webdav' ? state.webdavUrl : state.repoUrl).slice(0, 60)}
|
|
893
|
+
</span>
|
|
894
|
+
</span>
|
|
895
|
+
</div>
|
|
896
|
+
)}
|
|
897
|
+
</div>
|
|
898
|
+
<div className={css.actionRowTop}>
|
|
899
|
+
<Button variant="primary" onClick={openChannelDialog}>
|
|
900
|
+
{t('channel.open')}
|
|
901
|
+
</Button>
|
|
902
|
+
</div>
|
|
903
|
+
</Card>
|
|
904
|
+
|
|
905
|
+
{/* 通道配置弹窗(Radix Modal 统一 a11y:focus-trap / Esc / 焦点还原 / 滚动锁;
|
|
906
|
+
内含推送预览/推送结果/拉取结果/一键同步确认四个嵌套 Modal,Radix 支持嵌套弹窗) */}
|
|
907
|
+
<Modal
|
|
908
|
+
open={channelOpen}
|
|
909
|
+
onClose={closeChannelDialog}
|
|
910
|
+
title={t('channel.title')}
|
|
911
|
+
wide
|
|
912
|
+
busy={state.savingConfig}
|
|
913
|
+
>
|
|
914
|
+
<Modal.Header
|
|
915
|
+
title={t('channel.title')}
|
|
916
|
+
onClose={closeChannelDialog}
|
|
917
|
+
closeDisabled={state.savingConfig}
|
|
918
|
+
/>
|
|
919
|
+
<Modal.Body scroll>
|
|
920
|
+
|
|
921
|
+
{/* 通道子 tab:GitHub / WebDAV(modeTabs 样式;两通道设置各自独立) */}
|
|
922
|
+
<div className={css.modeTabs} role="tablist">
|
|
923
|
+
{channelTabModels(state.channel, state.busy !== null || state.savingConfig).map((tab) => (
|
|
924
|
+
<button
|
|
925
|
+
key={tab.channel}
|
|
926
|
+
type="button"
|
|
927
|
+
role="tab"
|
|
928
|
+
aria-selected={tab.active}
|
|
929
|
+
data-active={tab.active ? '' : undefined}
|
|
930
|
+
className={css.modeTab}
|
|
931
|
+
disabled={tab.disabled}
|
|
932
|
+
onClick={() => { switchChannel(tab.channel) }}
|
|
933
|
+
>
|
|
934
|
+
{tab.channel === 'webdav' ? t('channel.webdav') : t('channel.git')}
|
|
935
|
+
</button>
|
|
936
|
+
))}
|
|
937
|
+
</div>
|
|
938
|
+
<div className={css.modeHint}>{t('channel.perChannelHint')}</div>
|
|
939
|
+
|
|
940
|
+
{/* 私有仓库强制提示:仅 git 通道适用 */}
|
|
941
|
+
{state.channel === 'git' && <Banner kind="warn">{privateRepoHint(uiT)}</Banner>}
|
|
942
|
+
|
|
943
|
+
{/* git 通道分支 */}
|
|
944
|
+
{state.channel === 'git' && (
|
|
945
|
+
<>
|
|
946
|
+
<span className={css.groupLabel}>{t('config.title')}</span>
|
|
947
|
+
<label className={css.field}>
|
|
948
|
+
<span className={css.fieldLabel}>{t('config.repoUrl')}</span>
|
|
949
|
+
<input
|
|
950
|
+
type="text"
|
|
951
|
+
className={css.input}
|
|
952
|
+
value={state.repoUrl}
|
|
953
|
+
placeholder="https://github.com/user/private-repo.git"
|
|
954
|
+
disabled={state.busy !== null}
|
|
955
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
956
|
+
patch({ repoUrl: e.target.value })
|
|
957
|
+
scheduleConfigSave() // 改动自动保存(防抖;关闭设置页不丢输入)
|
|
958
|
+
}}
|
|
959
|
+
/>
|
|
960
|
+
<span className={css.hint}>{t('config.repoUrlHint')}</span>
|
|
961
|
+
</label>
|
|
962
|
+
<label className={css.field}>
|
|
963
|
+
<span className={css.fieldLabel}>
|
|
964
|
+
{t('config.token')}
|
|
965
|
+
{' '}
|
|
966
|
+
{state.statusInfo?.credentialConfigured === true && <Badge kind="ok">{t('config.tokenSaved')}</Badge>}
|
|
967
|
+
</span>
|
|
968
|
+
<input
|
|
969
|
+
type="password"
|
|
970
|
+
className={css.input}
|
|
971
|
+
value={state.token}
|
|
972
|
+
autoComplete="off"
|
|
973
|
+
placeholder={t('config.tokenPlaceholder')}
|
|
974
|
+
disabled={state.busy !== null}
|
|
975
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
976
|
+
patch({ token: e.target.value })
|
|
977
|
+
scheduleConfigSave()
|
|
978
|
+
}}
|
|
979
|
+
/>
|
|
980
|
+
<span className={css.hint}>{t('config.tokenHint', { ref: SYNC_CREDENTIAL_REF })}</span>
|
|
981
|
+
</label>
|
|
982
|
+
|
|
983
|
+
{/* GitHub OAuth 登录(device flow):弹窗内仅 git 通道显示;已登录(token 有效)时整块隐藏 */}
|
|
984
|
+
{state.githubSignedIn === false && (
|
|
985
|
+
<>
|
|
986
|
+
{state.statusInfo?.credentialConfigured === true && (
|
|
987
|
+
<Banner kind="warn">{t('github.tokenInvalid')}</Banner>
|
|
988
|
+
)}
|
|
989
|
+
<span className={css.groupLabel}>{t('github.title')}</span>
|
|
990
|
+
<span className={css.hint}>{t('github.description')}</span>
|
|
991
|
+
{githubView.showCode && (
|
|
992
|
+
<div className={css.statRow}>
|
|
993
|
+
<Badge kind="info">{t('github.userCode')}:<strong>{githubView.userCode}</strong></Badge>
|
|
994
|
+
<a
|
|
995
|
+
className={css.ghostButton}
|
|
996
|
+
href={githubView.verificationUri}
|
|
997
|
+
target="_blank"
|
|
998
|
+
rel="noreferrer"
|
|
999
|
+
style={{ textDecoration: 'none' }}
|
|
1000
|
+
>
|
|
1001
|
+
{t('github.openAuth')}
|
|
1002
|
+
</a>
|
|
1003
|
+
</div>
|
|
1004
|
+
)}
|
|
1005
|
+
<div className={css.actionRow}>
|
|
1006
|
+
<Button
|
|
1007
|
+
variant="primary"
|
|
1008
|
+
disabled={!githubView.canStart || state.busy !== null}
|
|
1009
|
+
onClick={() => { void runGithubStart() }}
|
|
1010
|
+
>
|
|
1011
|
+
{githubView.startLabel}
|
|
1012
|
+
</Button>
|
|
1013
|
+
{githubView.canCancel && (
|
|
1014
|
+
<Button disabled={state.busy !== null} onClick={() => { void runGithubCancel() }}>
|
|
1015
|
+
{t('github.cancel')}
|
|
1016
|
+
</Button>
|
|
1017
|
+
)}
|
|
1018
|
+
</div>
|
|
1019
|
+
<div className={css.statRow}>
|
|
1020
|
+
<Badge kind={githubView.phase === 'success' ? 'ok' : githubView.phase === 'error' ? 'error' : 'warn'}>
|
|
1021
|
+
{githubView.statusText}
|
|
1022
|
+
</Badge>
|
|
1023
|
+
</div>
|
|
1024
|
+
{githubView.phase === 'error' && (
|
|
1025
|
+
<span className={css.hint}>{t('config.tokenHint', { ref: SYNC_CREDENTIAL_REF })}</span>
|
|
1026
|
+
)}
|
|
1027
|
+
</>
|
|
1028
|
+
)}
|
|
1029
|
+
</>
|
|
1030
|
+
)}
|
|
1031
|
+
|
|
1032
|
+
{/* webdav 通道分支 */}
|
|
1033
|
+
{state.channel === 'webdav' && (
|
|
1034
|
+
<>
|
|
1035
|
+
<span className={css.groupLabel}>{t('webdav.title')}</span>
|
|
1036
|
+
{/* 常见 WebDAV 服务器预设:选择后填充 url 模板(含占位符待替换) */}
|
|
1037
|
+
<span className={css.hint}>{t('webdav.presetHint')}</span>
|
|
1038
|
+
<select
|
|
1039
|
+
className={css.select}
|
|
1040
|
+
value={presetIdForUrl(state.webdavUrl)}
|
|
1041
|
+
disabled={state.busy !== null}
|
|
1042
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
1043
|
+
const p = presetById(e.target.value)
|
|
1044
|
+
patch({ webdavUrl: p.url })
|
|
1045
|
+
scheduleConfigSave()
|
|
1046
|
+
}}
|
|
1047
|
+
>
|
|
1048
|
+
{WEBDAV_PRESETS.map((p) => (
|
|
1049
|
+
<option key={p.id} value={p.id}>{p.label}</option>
|
|
1050
|
+
))}
|
|
1051
|
+
</select>
|
|
1052
|
+
<label className={css.field}>
|
|
1053
|
+
<span className={css.fieldLabel}>{t('webdav.url')}</span>
|
|
1054
|
+
<input
|
|
1055
|
+
type="text"
|
|
1056
|
+
className={css.input}
|
|
1057
|
+
value={state.webdavUrl}
|
|
1058
|
+
placeholder="https://dav.example.com/dav/config"
|
|
1059
|
+
disabled={state.busy !== null}
|
|
1060
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
1061
|
+
patch({ webdavUrl: e.target.value })
|
|
1062
|
+
scheduleConfigSave()
|
|
1063
|
+
}}
|
|
1064
|
+
/>
|
|
1065
|
+
<span className={css.hint}>{t('webdav.urlHint')}</span>
|
|
1066
|
+
</label>
|
|
1067
|
+
<label className={css.field}>
|
|
1068
|
+
<span className={css.fieldLabel}>
|
|
1069
|
+
{t('webdav.username')}
|
|
1070
|
+
{' '}
|
|
1071
|
+
{state.statusInfo?.webdav?.usernameConfigured === true && <Badge kind="ok">{t('config.tokenSaved')}</Badge>}
|
|
1072
|
+
</span>
|
|
1073
|
+
<input
|
|
1074
|
+
type="text"
|
|
1075
|
+
className={css.input}
|
|
1076
|
+
value={state.webdavUsername}
|
|
1077
|
+
autoComplete="off"
|
|
1078
|
+
placeholder="alice"
|
|
1079
|
+
disabled={state.busy !== null}
|
|
1080
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
1081
|
+
patch({ webdavUsername: e.target.value })
|
|
1082
|
+
scheduleConfigSave()
|
|
1083
|
+
}}
|
|
1084
|
+
/>
|
|
1085
|
+
<span className={css.hint}>{t('webdav.usernameHint')}</span>
|
|
1086
|
+
</label>
|
|
1087
|
+
<label className={css.field}>
|
|
1088
|
+
<span className={css.fieldLabel}>
|
|
1089
|
+
{t('webdav.password')}
|
|
1090
|
+
{' '}
|
|
1091
|
+
{state.statusInfo?.webdav?.passwordConfigured === true && <Badge kind="ok">{t('webdav.passwordSaved')}</Badge>}
|
|
1092
|
+
</span>
|
|
1093
|
+
<input
|
|
1094
|
+
type="password"
|
|
1095
|
+
className={css.input}
|
|
1096
|
+
value={state.webdavPassword}
|
|
1097
|
+
autoComplete="off"
|
|
1098
|
+
placeholder={t('webdav.passwordPlaceholder')}
|
|
1099
|
+
disabled={state.busy !== null}
|
|
1100
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
1101
|
+
patch({ webdavPassword: e.target.value })
|
|
1102
|
+
scheduleConfigSave()
|
|
1103
|
+
}}
|
|
1104
|
+
/>
|
|
1105
|
+
<span className={css.hint}>{t('webdav.passwordHint', { ref: SYNC_WEBDAV_CREDENTIAL_REF })}</span>
|
|
1106
|
+
</label>
|
|
1107
|
+
</>
|
|
1108
|
+
)}
|
|
1109
|
+
|
|
1110
|
+
{/* 配置保存:改动自动保存(防抖,静默);按钮立即保存并给出 Toast 回执(announce=true) */}
|
|
1111
|
+
<div className={css.actionRow}>
|
|
1112
|
+
<Button
|
|
1113
|
+
variant="primary"
|
|
1114
|
+
disabled={state.busy !== null || state.savingConfig || !remoteReady}
|
|
1115
|
+
onClick={() => { flushConfigSave(true) }}
|
|
1116
|
+
>
|
|
1117
|
+
{state.savingConfig ? <Spinner label={t('config.saving')} /> : t('config.save')}
|
|
1118
|
+
</Button>
|
|
1119
|
+
</div>
|
|
1120
|
+
<span className={css.hint}>{t('config.saveHint')}</span>
|
|
1121
|
+
</Modal.Body>
|
|
1122
|
+
</Modal>
|
|
1123
|
+
|
|
1124
|
+
{/* 同步模式(当前通道):默认(快速导出)/ 高级(自定义导出) */}
|
|
1125
|
+
<Card>
|
|
1126
|
+
<span className={css.groupLabel}>{t('mode.title')}</span>
|
|
1127
|
+
<span className={css.hint}>{t('mode.hint')}</span>
|
|
1128
|
+
<div className={css.tabRow} role="tablist">
|
|
1129
|
+
<button
|
|
1130
|
+
type="button"
|
|
1131
|
+
role="tab"
|
|
1132
|
+
aria-selected={chState.syncMode === 'default'}
|
|
1133
|
+
data-active={chState.syncMode === 'default' ? '' : undefined}
|
|
1134
|
+
className={css.modeTab}
|
|
1135
|
+
disabled={state.busy !== null}
|
|
1136
|
+
onClick={() => { setSyncMode('default') }}
|
|
1137
|
+
>
|
|
1138
|
+
{t('mode.default')}
|
|
1139
|
+
</button>
|
|
1140
|
+
<button
|
|
1141
|
+
type="button"
|
|
1142
|
+
role="tab"
|
|
1143
|
+
aria-selected={chState.syncMode === 'advanced'}
|
|
1144
|
+
data-active={chState.syncMode === 'advanced' ? '' : undefined}
|
|
1145
|
+
className={css.modeTab}
|
|
1146
|
+
disabled={state.busy !== null}
|
|
1147
|
+
onClick={() => { setSyncMode('advanced') }}
|
|
1148
|
+
>
|
|
1149
|
+
{t('mode.advanced')}
|
|
1150
|
+
</button>
|
|
1151
|
+
</div>
|
|
1152
|
+
<div className={css.modeHint}>
|
|
1153
|
+
{chState.syncMode === 'default' ? t('mode.defaultHint') : t('mode.advancedHint')}
|
|
1154
|
+
</div>
|
|
1155
|
+
<span className={css.hint}>{t('mode.persistHint')}</span>
|
|
1156
|
+
|
|
1157
|
+
{chState.syncMode === 'advanced' && (
|
|
1158
|
+
<>
|
|
1159
|
+
<span className={css.groupLabel}>{t('mode.sectionsTitle')}</span>
|
|
1160
|
+
{state.catalog.length === 0 ? (
|
|
1161
|
+
<span className={css.hint}>{t('common.loading')}</span>
|
|
1162
|
+
) : (
|
|
1163
|
+
/* 分组勾选目录:与「导出备份·自定义模式」同构(分组 Card + 名称/描述/徽章) */
|
|
1164
|
+
<div className={css.groupList}>
|
|
1165
|
+
{syncSectionGroups(state.catalog).map((g) => (
|
|
1166
|
+
<Card key={g.group} className={css.groupCard}>
|
|
1167
|
+
<div className={css.groupHeader}>
|
|
1168
|
+
<span className={css.groupLabel}>{g.label}</span>
|
|
1169
|
+
{g.note !== undefined && <span className={css.groupNote}>{g.note}</span>}
|
|
1170
|
+
</div>
|
|
1171
|
+
<div className={css.groupItems}>
|
|
1172
|
+
{g.items.map((s) => (
|
|
1173
|
+
<Checkbox
|
|
1174
|
+
key={s.id}
|
|
1175
|
+
checked={chState.syncSections.includes(s.id)}
|
|
1176
|
+
onChange={(checked) => { toggleSyncSection(s.id, checked) }}
|
|
1177
|
+
label={
|
|
1178
|
+
<span className={css.categoryItem}>
|
|
1179
|
+
<span className={css.categoryName}>{s.label}</span>
|
|
1180
|
+
<span className={css.categoryDesc}>{s.description}</span>
|
|
1181
|
+
{s.defaultIncluded && <Badge kind="ok">{t('mode.sectionRecommended')}</Badge>}
|
|
1182
|
+
</span>
|
|
1183
|
+
}
|
|
1184
|
+
/>
|
|
1185
|
+
))}
|
|
1186
|
+
</div>
|
|
1187
|
+
</Card>
|
|
1188
|
+
))}
|
|
1189
|
+
</div>
|
|
1190
|
+
)}
|
|
1191
|
+
<span className={css.hint}>{t('mode.sectionsHint')}</span>
|
|
1192
|
+
{chState.syncSections.length === 0 && <Banner kind="warn">{t('mode.atLeastOne')}</Banner>}
|
|
1193
|
+
</>
|
|
1194
|
+
)}
|
|
1195
|
+
|
|
1196
|
+
{chState.syncMode === 'default' && (
|
|
1197
|
+
<span className={css.hint}>
|
|
1198
|
+
{state.catalog.length === 0
|
|
1199
|
+
? t('common.loading')
|
|
1200
|
+
: t('mode.defaultCount', { n: String(recommendedSectionCount) })}
|
|
1201
|
+
</span>
|
|
1202
|
+
)}
|
|
1203
|
+
</Card>
|
|
1204
|
+
|
|
1205
|
+
{/* 一键同步 + 手动推送/拉取(当前通道) */}
|
|
1206
|
+
<div className={css.actionRow}>
|
|
1207
|
+
<Button
|
|
1208
|
+
variant="primary"
|
|
1209
|
+
disabled={state.busy !== null || !remoteReady}
|
|
1210
|
+
onClick={() => { void runSync() }}
|
|
1211
|
+
>
|
|
1212
|
+
{state.busy === 'sync' ? <Spinner label={t('syncflow.syncing')} /> : t('syncflow.button')}
|
|
1213
|
+
</Button>
|
|
1214
|
+
<Button disabled={!buttons.canPush || githubBusy || !pushSelectionReady} onClick={() => { void runPushPreview() }}>
|
|
1215
|
+
{state.busy === 'push' ? <Spinner label={buttons.pushLabel} /> : buttons.pushLabel}
|
|
1216
|
+
</Button>
|
|
1217
|
+
<Button disabled={!buttons.canPull || githubBusy} onClick={() => { void runPull() }}>
|
|
1218
|
+
{state.busy === 'pull' ? <Spinner label={buttons.pullLabel} /> : buttons.pullLabel}
|
|
1219
|
+
</Button>
|
|
1220
|
+
</div>
|
|
1221
|
+
|
|
1222
|
+
{/* 选择历史快照下拉(当前通道远端快照,支持点击/展开即刷新与主动刷新按钮)
|
|
1223
|
+
—— 下拉与刷新按钮强制同一行:容器 nowrap + label 可收缩(minWidth:0),
|
|
1224
|
+
按钮包 flex:none 的 pickerAction 保宽度,窄画布下不再换行。
|
|
1225
|
+
行距归属:空态 hint 存在时由它承担底部 10px,不存在时由本行承担,
|
|
1226
|
+
保证与下一张卡片的间距在任何状态下都不丢 */}
|
|
1227
|
+
<div
|
|
1228
|
+
className={css.snapshotPickerRow}
|
|
1229
|
+
style={{ marginBottom: chState.snapshots.length === 0 && !chState.loadingSnapshots ? 0 : 10 }}
|
|
1230
|
+
>
|
|
1231
|
+
{/* 同基线依赖两点,缺一仍差 10px(实测):
|
|
1232
|
+
① 空态 hint 移出本 label(见下方独立一行),否则 label 高 = fieldLabel
|
|
1233
|
+
+ select + hint,按钮底部对齐到含 hint 的 label 底;
|
|
1234
|
+
② 清掉 label 自带的 margin:0 0 10px —— 容器是 align-items:flex-end,
|
|
1235
|
+
按 margin box 对齐,该 margin 会把按钮再压低 10px。
|
|
1236
|
+
两点均由 CSS 承担:label 的归零见 styles 里 `.snapshotPickerRow .field`,
|
|
1237
|
+
本行底部 10px 间距由 `.snapshotPickerHint` 或下方容器的条件 marginBottom 提供。 */}
|
|
1238
|
+
<label className={css.field} style={{ flex: '1 1 auto', minWidth: 0 }}>
|
|
1239
|
+
<span className={css.fieldLabel}>{t('syncflow.selectSnapshot')}</span>
|
|
1240
|
+
<select
|
|
1241
|
+
className={css.select}
|
|
1242
|
+
value={chState.selectedSnapshotId}
|
|
1243
|
+
disabled={state.busy !== null}
|
|
1244
|
+
onFocus={() => { void loadSnapshots() }}
|
|
1245
|
+
onMouseDown={() => { void loadSnapshots() }}
|
|
1246
|
+
onClick={() => { void loadSnapshots() }}
|
|
1247
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
1248
|
+
const id = e.target.value
|
|
1249
|
+
patchChannel({ selectedSnapshotId: id })
|
|
1250
|
+
void runSync(id === '' ? undefined : id)
|
|
1251
|
+
}}
|
|
1252
|
+
>
|
|
1253
|
+
<option value="">{t('syncflow.latestSnapshot')}</option>
|
|
1254
|
+
{chState.snapshots.map((s) => (
|
|
1255
|
+
<option key={s.id} value={s.id}>
|
|
1256
|
+
{s.id}{t('syncflow.snapshotOption', { date: s.createdAt.slice(0, 10), count: String(s.sectionCount) })}
|
|
1257
|
+
</option>
|
|
1258
|
+
))}
|
|
1259
|
+
</select>
|
|
1260
|
+
</label>
|
|
1261
|
+
<span className={css.pickerAction}>
|
|
1262
|
+
<Button
|
|
1263
|
+
disabled={state.busy !== null || chState.loadingSnapshots || !remoteReady}
|
|
1264
|
+
onClick={() => { void loadSnapshots(undefined, undefined, true) }}
|
|
1265
|
+
title={t('syncflow.refreshSnapshots')}
|
|
1266
|
+
>
|
|
1267
|
+
{chState.loadingSnapshots ? (
|
|
1268
|
+
<Spinner label={t('syncflow.refreshingSnapshots')} />
|
|
1269
|
+
) : (
|
|
1270
|
+
<><RefreshIcon size={14} /> {t('syncflow.refreshSnapshots')}</>
|
|
1271
|
+
)}
|
|
1272
|
+
</Button>
|
|
1273
|
+
</span>
|
|
1274
|
+
</div>
|
|
1275
|
+
{/* 空态提示独立成行(移出 label):label 高度 = fieldLabel + select,
|
|
1276
|
+
刷新按钮与之底部对齐即真正同一行,不再被 hint 顶高错位。
|
|
1277
|
+
底部 10px 间距由本行承担,故上方容器的 marginBottom 归零 */}
|
|
1278
|
+
{chState.snapshots.length === 0 && !chState.loadingSnapshots && (
|
|
1279
|
+
<div className={`${css.hint} ${css.snapshotPickerHint}`}>{t('syncflow.noSnapshots')}</div>
|
|
1280
|
+
)}
|
|
1281
|
+
|
|
1282
|
+
{/* R-20:原 `{state.error !== null && <ErrorBanner error={state.error} />}` 已移除 ——
|
|
1283
|
+
该字段承载 10+ 个动作的失败、渲染在全部卡片之后(用户触发点常在其上方视野外),
|
|
1284
|
+
且同文案会被 Toast 去重合并。现按动作分文案走右下角 Toast(见上方各 catch 分支)。 */}
|
|
1285
|
+
|
|
1286
|
+
{/* 自动同步设置(当前通道) */}
|
|
1287
|
+
<Card>
|
|
1288
|
+
<span className={css.groupLabel}>{t('autosync.title')}</span>
|
|
1289
|
+
<span className={css.hint}>{t('autosync.description')}</span>
|
|
1290
|
+
<label className={css.checkboxRow}>
|
|
1291
|
+
<input
|
|
1292
|
+
type="checkbox"
|
|
1293
|
+
checked={chState.autosyncEnabled}
|
|
1294
|
+
disabled={state.busy !== null}
|
|
1295
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => { void toggleAutosync(e.target.checked) }}
|
|
1296
|
+
/>
|
|
1297
|
+
<span>{t('autosync.enable')}</span>
|
|
1298
|
+
</label>
|
|
1299
|
+
<label className={css.field}>
|
|
1300
|
+
<span className={css.fieldLabel}>{t('autosync.interval')}</span>
|
|
1301
|
+
<select
|
|
1302
|
+
className={css.input}
|
|
1303
|
+
value={chState.autosyncInterval}
|
|
1304
|
+
disabled={state.busy !== null}
|
|
1305
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
|
1306
|
+
void updateAutosyncInterval(e.target.value as AutosyncInterval)
|
|
1307
|
+
}}
|
|
1308
|
+
>
|
|
1309
|
+
{AUTOSYNC_INTERVAL_OPTIONS.map((iv) => (
|
|
1310
|
+
<option key={iv} value={iv}>{intervalLabel(iv, t)}</option>
|
|
1311
|
+
))}
|
|
1312
|
+
</select>
|
|
1313
|
+
<span className={css.hint}>{t('autosync.intervalHint')}</span>
|
|
1314
|
+
</label>
|
|
1315
|
+
<div className={css.statRow}>
|
|
1316
|
+
<Badge kind={chState.autosync?.lastRunStatus === 'failed' ? 'error' : chState.autosync?.lastRunStatus === 'skipped' ? 'warn' : 'info'}>
|
|
1317
|
+
{autosyncText}
|
|
1318
|
+
</Badge>
|
|
1319
|
+
{autosyncCountdownMs !== null && chState.autosyncEnabled && (
|
|
1320
|
+
<Badge kind="info">
|
|
1321
|
+
{autosyncCountdownMs <= 0
|
|
1322
|
+
? t('autosync.due')
|
|
1323
|
+
: t('autosync.nextRun', { time: formatIntervalDuration(autosyncCountdownMs, uiT) })}
|
|
1324
|
+
</Badge>
|
|
1325
|
+
)}
|
|
1326
|
+
</div>
|
|
1327
|
+
</Card>
|
|
1328
|
+
|
|
1329
|
+
{/* P2:同步历史视图(Host /sync/history 端点;全局,含两通道记录) */}
|
|
1330
|
+
<SyncHistoryView api={api} t={t} />
|
|
1331
|
+
|
|
1332
|
+
{/* P0-②:push 前只读预览确认弹窗(「将推送什么」→ 确认后才真正上传;Radix Modal) */}
|
|
1333
|
+
<Modal
|
|
1334
|
+
open={state.pushPreview.open}
|
|
1335
|
+
onClose={() => { patch({ pushPreview: { preview: null, open: false } }) }}
|
|
1336
|
+
title={t('syncflow.pushPreviewTitle')}
|
|
1337
|
+
wide
|
|
1338
|
+
busy={state.busy === 'push'}
|
|
1339
|
+
>
|
|
1340
|
+
<Modal.Header
|
|
1341
|
+
title={t('syncflow.pushPreviewTitle')}
|
|
1342
|
+
onClose={() => { patch({ pushPreview: { preview: null, open: false } }) }}
|
|
1343
|
+
closeDisabled={state.busy === 'push'}
|
|
1344
|
+
/>
|
|
1345
|
+
<Modal.Body scroll>
|
|
1346
|
+
<PushPreviewCard preview={state.pushPreview.preview} t={t} uiT={uiT} />
|
|
1347
|
+
</Modal.Body>
|
|
1348
|
+
<Modal.Footer>
|
|
1349
|
+
<Button
|
|
1350
|
+
variant="ghost"
|
|
1351
|
+
disabled={state.busy === 'push'}
|
|
1352
|
+
onClick={() => { patch({ pushPreview: { preview: null, open: false } }) }}
|
|
1353
|
+
>
|
|
1354
|
+
{t('syncflow.cancel')}
|
|
1355
|
+
</Button>
|
|
1356
|
+
<Button
|
|
1357
|
+
variant="primary"
|
|
1358
|
+
disabled={state.busy === 'push'}
|
|
1359
|
+
onClick={() => { void runPush() }}
|
|
1360
|
+
>
|
|
1361
|
+
{state.busy === 'push' ? <Spinner label={t('syncflow.pushing')} /> : t('syncflow.pushConfirm')}
|
|
1362
|
+
</Button>
|
|
1363
|
+
</Modal.Footer>
|
|
1364
|
+
</Modal>
|
|
1365
|
+
|
|
1366
|
+
{/* 推送结果弹窗(Radix Modal) */}
|
|
1367
|
+
<Modal
|
|
1368
|
+
open={state.pushReport !== null && pushView !== null}
|
|
1369
|
+
onClose={() => { patch({ pushReport: null }) }}
|
|
1370
|
+
title={t('push.title')}
|
|
1371
|
+
cardStyle={{ width: 'min(640px, 100%)', maxHeight: '85vh' }}
|
|
1372
|
+
>
|
|
1373
|
+
<Modal.Header
|
|
1374
|
+
title={t('push.title')}
|
|
1375
|
+
onClose={() => { patch({ pushReport: null }) }}
|
|
1376
|
+
/>
|
|
1377
|
+
<Modal.Body scroll style={{ maxHeight: '70vh' }}>
|
|
1378
|
+
{pushView !== null && (<>
|
|
1379
|
+
<Banner kind={pushView.kind === 'ok' ? 'ok' : 'error'}>{pushView.headline}</Banner>
|
|
1380
|
+
{pushView.sections.length > 0 && (
|
|
1381
|
+
<div>
|
|
1382
|
+
<span className={css.fieldLabel}>{t('sections.title')}</span>
|
|
1383
|
+
<div className={css.statRow}>
|
|
1384
|
+
{pushView.sections.map((s) => <Badge key={s} kind="info">{s}</Badge>)}
|
|
1385
|
+
</div>
|
|
1386
|
+
</div>
|
|
1387
|
+
)}
|
|
1388
|
+
{pushView.warnings.length > 0 && (
|
|
1389
|
+
<div>
|
|
1390
|
+
<span className={css.fieldLabel}>{t('warnings.title')}</span>
|
|
1391
|
+
<ul className={css.warnList}>
|
|
1392
|
+
{pushView.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
|
1393
|
+
</ul>
|
|
1394
|
+
</div>
|
|
1395
|
+
)}
|
|
1396
|
+
</>)}
|
|
1397
|
+
</Modal.Body>
|
|
1398
|
+
<Modal.Footer>
|
|
1399
|
+
<Button variant="primary" onClick={() => { patch({ pushReport: null }) }}>
|
|
1400
|
+
{t('common.close')}
|
|
1401
|
+
</Button>
|
|
1402
|
+
</Modal.Footer>
|
|
1403
|
+
</Modal>
|
|
1404
|
+
|
|
1405
|
+
{/* 拉取差异预览弹窗(Radix Modal) */}
|
|
1406
|
+
<Modal
|
|
1407
|
+
open={state.pullReport !== null && pullView !== null}
|
|
1408
|
+
onClose={() => { patch({ pullReport: null }) }}
|
|
1409
|
+
title={t('pull.title')}
|
|
1410
|
+
cardStyle={{ width: 'min(720px, 100%)', maxHeight: '85vh' }}
|
|
1411
|
+
>
|
|
1412
|
+
<Modal.Header
|
|
1413
|
+
title={t('pull.title')}
|
|
1414
|
+
onClose={() => { patch({ pullReport: null }) }}
|
|
1415
|
+
/>
|
|
1416
|
+
<Modal.Body scroll style={{ maxHeight: '70vh' }}>
|
|
1417
|
+
{pullView !== null && (<>
|
|
1418
|
+
<Banner kind={pullView.kind === 'ok' ? 'info' : pullView.kind === 'empty' ? 'ok' : 'error'}>
|
|
1419
|
+
{pullView.headline}
|
|
1420
|
+
</Banner>
|
|
1421
|
+
{pullView.summary !== null && (
|
|
1422
|
+
<>
|
|
1423
|
+
<div className={css.statRow}>
|
|
1424
|
+
<Badge kind="info">{t('change.total', { total: pullView.summary.total })}</Badge>
|
|
1425
|
+
{pullView.summary.error > 0 && <Badge kind="error">{severityLabel('error', uiT)} × {pullView.summary.error}</Badge>}
|
|
1426
|
+
{pullView.summary.warning > 0 && <Badge kind="warn">{severityLabel('warning', uiT)} × {pullView.summary.warning}</Badge>}
|
|
1427
|
+
{pullView.summary.info > 0 && <Badge kind="info">{severityLabel('info', uiT)} × {pullView.summary.info}</Badge>}
|
|
1428
|
+
</div>
|
|
1429
|
+
{pullView.summary.needsReview && <Banner kind="warn">{t('pull.needsReview')}</Banner>}
|
|
1430
|
+
<div className={css.pullScroll}>
|
|
1431
|
+
<div className={css.reportList}>
|
|
1432
|
+
{pullView.summary.items.map((c) => (
|
|
1433
|
+
<div key={c.id} className={css.statRow}>
|
|
1434
|
+
<span className={css.kindTag}>{kindLabel(c.kind, uiT)}</span>
|
|
1435
|
+
<Badge kind={c.severity === 'error' ? 'error' : c.severity === 'warning' ? 'warn' : 'info'}>
|
|
1436
|
+
{severityLabel(c.severity, uiT)}
|
|
1437
|
+
</Badge>
|
|
1438
|
+
<span>{c.description}</span>
|
|
1439
|
+
</div>
|
|
1440
|
+
))}
|
|
1441
|
+
</div>
|
|
1442
|
+
</div>
|
|
1443
|
+
</>
|
|
1444
|
+
)}
|
|
1445
|
+
{pullView.previewHint !== '' && <Banner kind="info">{pullView.previewHint}</Banner>}
|
|
1446
|
+
</>)}
|
|
1447
|
+
</Modal.Body>
|
|
1448
|
+
<Modal.Footer>
|
|
1449
|
+
<Button variant="primary" onClick={() => { patch({ pullReport: null }) }}>
|
|
1450
|
+
{t('common.close')}
|
|
1451
|
+
</Button>
|
|
1452
|
+
</Modal.Footer>
|
|
1453
|
+
</Modal>
|
|
1454
|
+
|
|
1455
|
+
{/* 一键同步差异确认弹窗(Radix Modal;SyncConfirmView 内含取消/确认按钮) */}
|
|
1456
|
+
<Modal
|
|
1457
|
+
open={state.confirmSession !== null}
|
|
1458
|
+
onClose={cancelConfirm}
|
|
1459
|
+
title={t('syncflow.title')}
|
|
1460
|
+
cardStyle={{ width: 'min(820px, 100%)', maxHeight: '85vh' }}
|
|
1461
|
+
busy={state.busy === 'sync'}
|
|
1462
|
+
>
|
|
1463
|
+
<Modal.Header
|
|
1464
|
+
title={t('syncflow.title')}
|
|
1465
|
+
onClose={cancelConfirm}
|
|
1466
|
+
closeDisabled={state.busy === 'sync'}
|
|
1467
|
+
/>
|
|
1468
|
+
<Modal.Body scroll style={{ maxHeight: '72vh' }}>
|
|
1469
|
+
{state.confirmSession !== null && (
|
|
1470
|
+
<SyncConfirmView
|
|
1471
|
+
api={api}
|
|
1472
|
+
syncSessionId={state.confirmSession.syncSessionId}
|
|
1473
|
+
snapshotId={state.confirmSession.snapshotId}
|
|
1474
|
+
items={state.confirmSession.items}
|
|
1475
|
+
needsReview={state.confirmSession.needsReview}
|
|
1476
|
+
compatibility={state.confirmSession.compatibility}
|
|
1477
|
+
t={t}
|
|
1478
|
+
decisions={state.confirmDecisions}
|
|
1479
|
+
onDecisionsChange={(d) => { patch({ confirmDecisions: d }) }}
|
|
1480
|
+
onCancel={cancelConfirm}
|
|
1481
|
+
onRollbackDone={onRollbackApplied}
|
|
1482
|
+
/>
|
|
1483
|
+
)}
|
|
1484
|
+
</Modal.Body>
|
|
1485
|
+
</Modal>
|
|
1486
|
+
</div>
|
|
1487
|
+
)
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** P0-②:push 预览内容(绑 src/ui/i18n.ts 的 UiT 文案;纯展示,无敏感字段)。 */
|
|
1491
|
+
function PushPreviewCard({ preview, t, uiT }: {
|
|
1492
|
+
preview: SyncPushPreview | null
|
|
1493
|
+
t: TranslateNS<'config-manager-sync'>
|
|
1494
|
+
uiT: UiT
|
|
1495
|
+
}) {
|
|
1496
|
+
const view = pushPreviewView(preview, uiT)
|
|
1497
|
+
if (view === null) return null
|
|
1498
|
+
if (view.error !== null) return <Banner kind="error">{view.error}</Banner>
|
|
1499
|
+
return (
|
|
1500
|
+
<div>
|
|
1501
|
+
<Banner kind="info">{view.headline}</Banner>
|
|
1502
|
+
{view.previewHint !== '' && <div className={css.hint}>{view.previewHint}</div>}
|
|
1503
|
+
{view.remoteSnapshotCount === 0 && (
|
|
1504
|
+
<Banner kind="warn">{t('syncflow.pushFirstBaseline')}</Banner>
|
|
1505
|
+
)}
|
|
1506
|
+
<Card className={css.card}>
|
|
1507
|
+
<div className={css.groupLabel}>{t('syncflow.pushPreviewSections')}</div>
|
|
1508
|
+
<div className={css.planScroll}>
|
|
1509
|
+
<ul className={css.reportList}>
|
|
1510
|
+
{view.rows.map((row) => (
|
|
1511
|
+
<li key={row.section}>
|
|
1512
|
+
<span className={css.kindTag}>{row.changed ? 'changed' : 'unchanged'}</span>
|
|
1513
|
+
{' '}{row.section} · {row.count}
|
|
1514
|
+
</li>
|
|
1515
|
+
))}
|
|
1516
|
+
</ul>
|
|
1517
|
+
</div>
|
|
1518
|
+
</Card>
|
|
1519
|
+
</div>
|
|
1520
|
+
)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/** AutosyncInterval → 可读标签(复用 i18n interval 键)。 */
|
|
1524
|
+
function intervalLabel(iv: AutosyncInterval, t: TranslateNS<'config-manager-sync'>): string {
|
|
1525
|
+
switch (iv) {
|
|
1526
|
+
case '5m': return t('autosync.interval5m');
|
|
1527
|
+
case '15m': return t('autosync.interval15m');
|
|
1528
|
+
case '30m': return t('autosync.interval30m');
|
|
1529
|
+
case '60m': return t('autosync.interval60m');
|
|
1530
|
+
case '6h': return t('autosync.interval6h');
|
|
1531
|
+
case '12h': return t('autosync.interval12h');
|
|
1532
|
+
case '24h': return t('autosync.interval24h');
|
|
1533
|
+
default: return iv;
|
|
1534
|
+
}
|
|
1535
|
+
}
|