dsh-config-manager 0.1.39 → 0.1.41

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.
Files changed (50) hide show
  1. package/lib/client.d.ts +90 -8
  2. package/lib/client.js +1904 -1086
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/cache-cleaner.d.ts +39 -0
  5. package/lib/core/cache-cleaner.js +164 -0
  6. package/lib/core/cache-cleaner.js.map +1 -0
  7. package/lib/index.js +172 -3
  8. package/lib/index.js.map +1 -1
  9. package/lib/market/github-repos.d.ts +19 -8
  10. package/lib/market/github-repos.js +62 -33
  11. package/lib/market/github-repos.js.map +1 -1
  12. package/lib/market/my-repo.d.ts +111 -3
  13. package/lib/market/my-repo.js +309 -26
  14. package/lib/market/my-repo.js.map +1 -1
  15. package/lib/market/prepare.js +3 -2
  16. package/lib/market/prepare.js.map +1 -1
  17. package/lib/market/types.d.ts +2 -0
  18. package/lib/security/secret-scanner.d.ts +18 -3
  19. package/lib/security/secret-scanner.js +144 -20
  20. package/lib/security/secret-scanner.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/client/common/ConfirmDialog.tsx +135 -0
  23. package/src/client/config-manager.module.css +101 -0
  24. package/src/client/market/MarketPanel.tsx +138 -45
  25. package/src/client/market/MyConfigsView.tsx +515 -132
  26. package/src/client/market/disclaimer.test.ts +57 -0
  27. package/src/client/market/disclaimer.ts +43 -0
  28. package/src/client/market/market-api.test.ts +3 -0
  29. package/src/client/market/market-locales.ts +37 -13
  30. package/src/client/market/my-configs-api.ts +20 -2
  31. package/src/client/market/my-configs-view.test.ts +137 -2
  32. package/src/client/market/my-configs-view.ts +174 -2
  33. package/src/client/run-store.test.ts +40 -2
  34. package/src/client/run-store.ts +70 -10
  35. package/src/client/sync/SyncConfirmView.tsx +28 -22
  36. package/src/client/sync/SyncSettingsView.tsx +175 -65
  37. package/src/client/sync/sync-api.ts +19 -0
  38. package/src/client/sync/sync-locales.ts +14 -2
  39. package/src/core/cache-cleaner.test.ts +333 -0
  40. package/src/core/cache-cleaner.ts +196 -0
  41. package/src/index.ts +165 -5
  42. package/src/market/github-repos.test.ts +99 -8
  43. package/src/market/github-repos.ts +67 -35
  44. package/src/market/my-repo.test.ts +218 -27
  45. package/src/market/my-repo.ts +403 -30
  46. package/src/market/prepare.test.ts +71 -1
  47. package/src/market/prepare.ts +3 -2
  48. package/src/market/types.ts +2 -0
  49. package/src/security/secret-scanner.ts +139 -22
  50. package/src/security/security.test.ts +111 -2
@@ -3,10 +3,16 @@
3
3
  *
4
4
  * 独立设置页壳(sectionHeader/close/自身 tab)已移除 —— tab 容器由
5
5
  * ConfigManagerSection 统一渲染,本组件只输出内容体:
6
- * - **通道子 tab 面板**:GitHub(git)/ WebDAV 两个子 tab(modeTabs 样式)切换,
7
- * 两个通道的配置表单、自动同步、同步模式、是否加密、远端快照**各自独立**;
6
+ * - **同步通道入口卡**:展示当前通道 + 配置状态 + 凭据徽章;点「配置同步通道」
7
+ * → 弹出**通道配置弹窗**(弹窗体系与市场操作弹窗一致,DESIGN.md §8.12:
8
+ * dialogMask + dialogCard dialogWide + dialogHeaderRow + dialogClose +
9
+ * dialogBodyScroll,零新增样式);
10
+ * - **通道配置弹窗**:通道子 tab(GitHub(git)/ WebDAV)切换,两个通道的
11
+ * 配置表单、自动同步、同步模式、是否加密、远端快照**各自独立**;关闭弹窗
12
+ * = 放弃本次操作(GitHub 登录流程进行中则一并取消,§8.12 约定);
8
13
  * - GitHub 子 tab:repoUrl(必填)+ 认证 token(可选,写入 DSH credentials 的提示)
9
- * + GitHub OAuth device flow 登录(git 可执行文件固定使用系统 PATH 中的 git);
14
+ * + **GitHub OAuth device flow 登录**(登录块跟随 git 通道配置放在弹窗内:
15
+ * 未登录/失效时显示;git 可执行文件固定使用系统 PATH 中的 git);
10
16
  * - WebDAV 子 tab:url + username + password(密码写入 DSH credentials)+ 常见服务器预设;
11
17
  * - 私有仓库强制提示横幅(仅 git 子 tab 常驻);
12
18
  * - 推送按钮 → SyncPushReport;拉取按钮 → SyncPullReport.changes 差异摘要;
@@ -29,7 +35,7 @@ import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts'
29
35
  import type { SectionId } from '../../schema/types.ts'
30
36
  import { Badge, Banner, Button, Card, Checkbox, SectionTitle, Spinner } from '../common/ui.tsx'
31
37
  import { ErrorBanner } from '../common/ErrorBanner.tsx'
32
- import { runStore, toSyncStoreSlice, type SyncStoreSlice } from '../run-store.ts'
38
+ import { runStore, toSyncStoreSlice, type SyncConfirmDecisions, type SyncStoreSlice } from '../run-store.ts'
33
39
  import { SYNC_CREDENTIAL_REF, SYNC_WEBDAV_CREDENTIAL_REF } from './sync-api.ts'
34
40
  import type {
35
41
  AutosyncInterval, AutosyncStatusResponse, SyncApi, SyncPushPayload, SyncSectionInfo, SyncSnapshotLite,
@@ -37,7 +43,7 @@ import type {
37
43
  } from './sync-api.ts'
38
44
  import {
39
45
  autosyncIntervalMs, autosyncStatusText, channelTabModels, computeAutosyncCountdown,
40
- computeGithubLoginView, computeRemoteReady, computeSyncButtons, computeSyncStatus,
46
+ computeGithubLoginView, computeRemoteReady, computeSyncButtons,
41
47
  defaultChannelSyncState, formatIntervalDuration, githubPollMessage, kindLabel, presetById,
42
48
  presetIdForUrl, privateRepoHint, pullReportView, pushReportView, readStoredChannel,
43
49
  recommendedSyncSections, severityLabel, syncSectionGroups, syncSectionOptions,
@@ -84,11 +90,20 @@ interface SyncUiState {
84
90
  pullReport: SyncPullReport | null
85
91
  /** 一键同步差异确认会话(POST /sync/sync 结果;非空时渲染 SyncConfirmView) */
86
92
  confirmSession: SyncStartResponse | null
93
+ /** 一键同步差异确认的逐项决策(adopted/resolution;与 confirmSession 生命周期绑定,切 tab/刷新不丢) */
94
+ confirmDecisions: SyncConfirmDecisions | null
87
95
  /** 最近一次一键同步执行结果(回滚入口) */
88
96
  lastRestoreId: string | null
89
97
  error: string | null
90
98
  /** GitHub OAuth device flow 状态(flowId/userCode 仅内存,token 只存宿主) */
91
99
  github: GithubUiState
100
+ /**
101
+ * GitHub token 是否有效(「已登录」判定:token 存在且 GitHub API 接受)。
102
+ * null = 尚未校验(不显示登录块,避免已登录用户看到闪烁);true = 已登录
103
+ * (隐藏 GitHub 登录块);false = 未配置或已失效(显示登录块)。
104
+ * 仅内存瞬态(不进 store 切片):切 tab/刷新后重新校验,保证新鲜。
105
+ */
106
+ githubSignedIn: boolean | null
92
107
  }
93
108
 
94
109
  interface GithubUiState {
@@ -128,9 +143,11 @@ const initial: SyncUiState = {
128
143
  pushReport: null,
129
144
  pullReport: null,
130
145
  confirmSession: null,
146
+ confirmDecisions: null,
131
147
  lastRestoreId: null,
132
148
  error: null,
133
149
  github: initialGithub,
150
+ githubSignedIn: null,
134
151
  }
135
152
 
136
153
  /**
@@ -162,6 +179,7 @@ function initFromStore(): SyncUiState {
162
179
  pushReport: s.pushReport,
163
180
  pullReport: s.pullReport,
164
181
  confirmSession: s.confirmSession,
182
+ confirmDecisions: s.confirmDecisions,
165
183
  lastRestoreId: s.lastRestoreId,
166
184
  error: s.error,
167
185
  loadError: s.loadError,
@@ -175,6 +193,8 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
175
193
  const stateRef = useRef<SyncUiState>(state)
176
194
  /** 挂载守卫:卸载后不再 setState(store 镜像仍执行,异步结果照常落库) */
177
195
  const mountedRef = useRef(true)
196
+ /** 通道配置弹窗开关(瞬态 UI:切 tab/刷新不持久化,弹窗不自动重开;DESIGN.md §8.12 约定) */
197
+ const [channelOpen, setChannelOpen] = useState(false)
178
198
 
179
199
  /**
180
200
  * 统一提交入口:更新 stateRef → 挂载时 setState → **总是**镜像进 runStore。
@@ -275,6 +295,8 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
275
295
  if (info.configured && preset.trim() !== '') {
276
296
  void loadSnapshots(preset, activeCh)
277
297
  }
298
+ // 校验 GitHub token 有效性:已登录(有效)→ 隐藏 GitHub 登录块;未配置/失效 → 显示
299
+ void validateGithub()
278
300
  } catch (err) {
279
301
  patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
280
302
  }
@@ -436,6 +458,10 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
436
458
  next.statusInfo = info
437
459
  }
438
460
  commit(next)
461
+ // 手动填入的 git token 保存成功 → 校验有效性(有效则隐藏 GitHub 登录块)
462
+ if (payloadToSave.transport !== 'webdav' && saved.credentialConfigured) {
463
+ void validateGithub()
464
+ }
439
465
  } catch (err) {
440
466
  patch({ savingConfig: false, error: err instanceof Error ? err.message : String(err) })
441
467
  } finally {
@@ -515,8 +541,8 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
515
541
  }
516
542
  const message = githubPollMessage(poll, uiT)
517
543
  if (poll.status === 'success') {
518
- patchGithub({ phase: 'success', error: null })
519
- // token 已由宿主写入 DSH credentials:刷新状态行与凭据徽章
544
+ // token 已由宿主写入 DSH credentials:标记已登录(隐藏登录块)+ 刷新状态/凭据徽章
545
+ patch({ githubSignedIn: true, github: { ...stateRef.current.github, phase: 'success', error: null } })
520
546
  void loadStatus()
521
547
  } else {
522
548
  patchGithub({ phase: 'error', error: message })
@@ -539,6 +565,43 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
539
565
  }
540
566
  }
541
567
 
568
+ /**
569
+ * 校验 GitHub token 是否有效(「已登录」判定 → 决定登录块显隐)。
570
+ * 挂载 / 登录成功 / 保存凭据后调用;已确认登录(githubSignedIn===true)时跳过
571
+ * (避免反复网络调用)。401 → 未登录:显示登录块并提示重新登录;网络等其余
572
+ * 错误 → 保持现状(不误判登出,已登录用户不被打扰;下次进入页面会再校验)。
573
+ */
574
+ const validateGithub = async (): Promise<void> => {
575
+ if (stateRef.current.githubSignedIn === true) return
576
+ try {
577
+ const res = await api.githubValidate()
578
+ patch({ githubSignedIn: res.configured && res.valid })
579
+ } catch {
580
+ // 校验失败(网络/限流等):不可知 → 维持现状(null 隐藏 / 既有值不变)
581
+ }
582
+ }
583
+
584
+ /* ------------------------------------------------ 通道配置弹窗(弹窗驱动,DESIGN.md §8.12 同体系) */
585
+
586
+ /** 打开通道配置弹窗:登录态尚未校验时补一次校验(决定 Git 子 tab 登录块显隐)。 */
587
+ const openChannelDialog = (): void => {
588
+ setChannelOpen(true)
589
+ if (stateRef.current.githubSignedIn === null) void validateGithub()
590
+ }
591
+
592
+ /**
593
+ * 关闭通道配置弹窗 = 放弃本次操作(§8.12 约定):GitHub 登录流程进行中则取消
594
+ * (停轮询 + 通知宿主丢弃设备码登记),保存中(savingConfig)时禁止关闭。
595
+ */
596
+ const closeChannelDialog = (): void => {
597
+ if (stateRef.current.savingConfig) return
598
+ const phase = stateRef.current.github.phase
599
+ if (phase === 'starting' || phase === 'waiting' || phase === 'polling') {
600
+ void runGithubCancel()
601
+ }
602
+ setChannelOpen(false)
603
+ }
604
+
542
605
  const runPush = async (): Promise<void> => {
543
606
  patch({ busy: 'push', error: null, pushReport: null, pullReport: null })
544
607
  try {
@@ -647,7 +710,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
647
710
  if (state.confirmSession !== null) {
648
711
  try { await api.cancel(state.confirmSession.syncSessionId) } catch { /* 尽力清理 */ }
649
712
  }
650
- patch({ busy: 'sync', error: null, confirmSession: null, lastRestoreId: null })
713
+ patch({ busy: 'sync', error: null, confirmSession: null, confirmDecisions: null, lastRestoreId: null })
651
714
  try {
652
715
  // 解密密码(可选):一键同步拉取加密快照时提供;仅内存传输
653
716
  const decrypt =
@@ -661,7 +724,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
661
724
  patch({ busy: null, error: session.message ?? t('syncflow.syncFailed') })
662
725
  return
663
726
  }
664
- patch({ busy: null, confirmSession: session, token: '', webdavPassword: '' })
727
+ patch({ busy: null, confirmSession: session, confirmDecisions: null, token: '', webdavPassword: '' })
665
728
  patchChannel({ decryptPassword: '' })
666
729
  void loadSnapshots()
667
730
  } catch (err) {
@@ -671,7 +734,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
671
734
 
672
735
  /** 用户取消差异确认:清除会话,复位到空闲。 */
673
736
  const cancelConfirm = (): void => {
674
- patch({ confirmSession: null })
737
+ patch({ confirmSession: null, confirmDecisions: null })
675
738
  }
676
739
 
677
740
  /** 从 SyncConfirmView 透传的一键回滚完成信号。 */
@@ -715,7 +778,6 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
715
778
  }
716
779
  }
717
780
 
718
- const status = computeSyncStatus(state.statusInfo, state.loading, state.loadError, uiT)
719
781
  /** 活动通道的远端地址是否就绪(git=repoUrl 非空;webdav=webdavUrl 非空) */
720
782
  const remoteReady = computeRemoteReady(state.channel, state.repoUrl, state.webdavUrl)
721
783
  const buttons = computeSyncButtons(state.busy, remoteReady, uiT)
@@ -746,9 +808,59 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
746
808
  <div className={css.viewBody}>
747
809
  <SectionTitle title={t('section.label')} subtitle={t('section.description')} />
748
810
 
749
- {/* 通道子 tab 面板:GitHub / WebDAV(modeTabs 样式;两通道设置各自独立) */}
811
+ {/* 同步通道入口卡:通道配置改为弹窗驱动(点按钮 → 弹窗内配置 Git/WebDAV 通道;
812
+ 弹窗样式复用市场操作弹窗体系,DESIGN.md §8.12) */}
813
+ <Card>
814
+ <span className={css.groupLabel}>{t('channel.title')}</span>
815
+ <span className={css.hint}>{t('channel.openHint')}</span>
816
+ <div className={css.statRow}>
817
+ <Badge kind="info">{state.channel === 'webdav' ? t('channel.webdav') : t('channel.git')}</Badge>
818
+ <Badge kind={remoteReady ? 'ok' : 'warn'}>
819
+ {remoteReady ? t('channel.configured') : t('channel.notConfigured')}
820
+ </Badge>
821
+ {state.channel === 'git' && state.statusInfo?.credentialConfigured === true && (
822
+ <Badge kind="ok">{t('config.tokenSaved')}</Badge>
823
+ )}
824
+ {state.channel === 'webdav' && state.statusInfo?.webdav?.passwordConfigured === true && (
825
+ <Badge kind="ok">{t('webdav.passwordSaved')}</Badge>
826
+ )}
827
+ </div>
828
+ {remoteReady && (
829
+ <span className={css.hint}>
830
+ {t('channel.currentUrl')}:{state.channel === 'webdav' ? state.webdavUrl : state.repoUrl}
831
+ </span>
832
+ )}
833
+ <div className={css.actionRow}>
834
+ <Button variant="primary" onClick={openChannelDialog}>
835
+ {t('channel.open')}
836
+ </Button>
837
+ </div>
838
+ </Card>
839
+
840
+ {/* 通道配置弹窗(操作弹窗体系:dialogMask + dialogCard dialogWide + 标题行 + 关闭 × + 正文限高内滚) */}
841
+ {channelOpen && (
842
+ <div
843
+ className={css.dialogMask}
844
+ onMouseDown={(e) => { if (e.target === e.currentTarget && !state.savingConfig) closeChannelDialog() }}
845
+ >
846
+ <div className={`${css.dialogCard} ${css.dialogWide}`} role="dialog" aria-modal="true" aria-label={t('channel.title')}>
847
+ <div className={css.dialogHeaderRow}>
848
+ <span className={css.dialogHeader}>{t('channel.title')}</span>
849
+ <button
850
+ type="button"
851
+ className={css.dialogClose}
852
+ aria-label={t('common.close')}
853
+ disabled={state.savingConfig}
854
+ onClick={closeChannelDialog}
855
+ >
856
+ ×
857
+ </button>
858
+ </div>
859
+ <div className={css.dialogBodyScroll}>
860
+
861
+ {/* 通道子 tab:GitHub / WebDAV(modeTabs 样式;两通道设置各自独立) */}
750
862
  <div className={css.modeTabs} role="tablist">
751
- {channelTabModels(state.channel, state.busy !== null).map((tab) => (
863
+ {channelTabModels(state.channel, state.busy !== null || state.savingConfig).map((tab) => (
752
864
  <button
753
865
  key={tab.channel}
754
866
  type="button"
@@ -768,11 +880,6 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
768
880
  {/* 私有仓库强制提示:仅 git 通道适用 */}
769
881
  {state.channel === 'git' && <Banner kind="warn">{privateRepoHint(uiT)}</Banner>}
770
882
 
771
- {/* 通道配置(当前子 tab 的表单) */}
772
- <Card>
773
- <span className={css.groupLabel}>{t('channel.title')}</span>
774
- <span className={css.hint}>{t('channel.hint')}</span>
775
-
776
883
  {/* git 通道分支 */}
777
884
  {state.channel === 'git' && (
778
885
  <>
@@ -813,44 +920,51 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
813
920
  <span className={css.hint}>{t('config.tokenHint', { ref: SYNC_CREDENTIAL_REF })}</span>
814
921
  </label>
815
922
 
816
- {/* GitHub OAuth 登录(device flow):仅 git 通道显示 */}
817
- <span className={css.groupLabel}>{t('github.title')}</span>
818
- <span className={css.hint}>{t('github.description')}</span>
819
- {githubView.showCode && (
820
- <div className={css.statRow}>
821
- <Badge kind="info">{t('github.userCode')}:<strong>{githubView.userCode}</strong></Badge>
822
- <a
823
- className={css.ghostButton}
824
- href={githubView.verificationUri}
825
- target="_blank"
826
- rel="noreferrer"
827
- style={{ textDecoration: 'none' }}
828
- >
829
- {t('github.openAuth')}
830
- </a>
831
- </div>
832
- )}
833
- <div className={css.actionRow}>
834
- <Button
835
- variant="primary"
836
- disabled={!githubView.canStart || state.busy !== null}
837
- onClick={() => { void runGithubStart() }}
838
- >
839
- {githubView.startLabel}
840
- </Button>
841
- {githubView.canCancel && (
842
- <Button disabled={state.busy !== null} onClick={() => { void runGithubCancel() }}>
843
- {t('github.cancel')}
844
- </Button>
845
- )}
846
- </div>
847
- <div className={css.statRow}>
848
- <Badge kind={githubView.phase === 'success' ? 'ok' : githubView.phase === 'error' ? 'error' : 'warn'}>
849
- {githubView.statusText}
850
- </Badge>
851
- </div>
852
- {githubView.phase === 'error' && (
853
- <span className={css.hint}>{t('config.tokenHint', { ref: SYNC_CREDENTIAL_REF })}</span>
923
+ {/* GitHub OAuth 登录(device flow):弹窗内仅 git 通道显示;已登录(token 有效)时整块隐藏 */}
924
+ {state.githubSignedIn === false && (
925
+ <>
926
+ {state.statusInfo?.credentialConfigured === true && (
927
+ <Banner kind="warn">{t('github.tokenInvalid')}</Banner>
928
+ )}
929
+ <span className={css.groupLabel}>{t('github.title')}</span>
930
+ <span className={css.hint}>{t('github.description')}</span>
931
+ {githubView.showCode && (
932
+ <div className={css.statRow}>
933
+ <Badge kind="info">{t('github.userCode')}:<strong>{githubView.userCode}</strong></Badge>
934
+ <a
935
+ className={css.ghostButton}
936
+ href={githubView.verificationUri}
937
+ target="_blank"
938
+ rel="noreferrer"
939
+ style={{ textDecoration: 'none' }}
940
+ >
941
+ {t('github.openAuth')}
942
+ </a>
943
+ </div>
944
+ )}
945
+ <div className={css.actionRow}>
946
+ <Button
947
+ variant="primary"
948
+ disabled={!githubView.canStart || state.busy !== null}
949
+ onClick={() => { void runGithubStart() }}
950
+ >
951
+ {githubView.startLabel}
952
+ </Button>
953
+ {githubView.canCancel && (
954
+ <Button disabled={state.busy !== null} onClick={() => { void runGithubCancel() }}>
955
+ {t('github.cancel')}
956
+ </Button>
957
+ )}
958
+ </div>
959
+ <div className={css.statRow}>
960
+ <Badge kind={githubView.phase === 'success' ? 'ok' : githubView.phase === 'error' ? 'error' : 'warn'}>
961
+ {githubView.statusText}
962
+ </Badge>
963
+ </div>
964
+ {githubView.phase === 'error' && (
965
+ <span className={css.hint}>{t('config.tokenHint', { ref: SYNC_CREDENTIAL_REF })}</span>
966
+ )}
967
+ </>
854
968
  )}
855
969
  </>
856
970
  )}
@@ -944,16 +1058,10 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
944
1058
  </Button>
945
1059
  </div>
946
1060
  <span className={css.hint}>{t('config.saveHint')}</span>
947
- </Card>
948
-
949
- {/* 同步状态(当前子 tab 通道) */}
950
- <Card>
951
- <span className={css.groupLabel}>{t('status.title')}</span>
952
- <div className={css.statRow}>
953
- <Badge kind={status.kind === 'ready' ? 'ok' : status.kind === 'error' ? 'error' : 'warn'}>{status.text}</Badge>
954
- <Badge kind="info">{state.channel === 'webdav' ? t('channel.webdav') : t('channel.git')}</Badge>
1061
+ </div>
1062
+ </div>
955
1063
  </div>
956
- </Card>
1064
+ )}
957
1065
 
958
1066
  {/* 同步模式(当前通道):默认(快速导出)/ 高级(自定义导出) */}
959
1067
  <Card>
@@ -1154,6 +1262,8 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
1154
1262
  needsReview={state.confirmSession.needsReview}
1155
1263
  compatibility={state.confirmSession.compatibility}
1156
1264
  t={t}
1265
+ decisions={state.confirmDecisions}
1266
+ onDecisionsChange={(d) => { patch({ confirmDecisions: d }) }}
1157
1267
  onCancel={cancelConfirm}
1158
1268
  onRollbackDone={onRollbackApplied}
1159
1269
  />
@@ -37,6 +37,7 @@ export const SYNC_API = {
37
37
  githubStart: '/api/dsh-config-manager/sync/github/start',
38
38
  githubPoll: '/api/dsh-config-manager/sync/github/poll',
39
39
  githubCancel: '/api/dsh-config-manager/sync/github/cancel',
40
+ githubValidate: '/api/dsh-config-manager/sync/github/validate',
40
41
  history: '/api/dsh-config-manager/sync/history',
41
42
  snapshotsList: '/api/dsh-config-manager/sync/snapshots-list',
42
43
  sync: '/api/dsh-config-manager/sync/sync',
@@ -377,6 +378,18 @@ export interface GithubPollResponse {
377
378
  credentialConfigured?: boolean;
378
379
  }
379
380
 
381
+ /** POST /sync/github/validate 响应:token 是否已配置 + 是否有效(GET /user;
382
+ * 401 → 无效)。只回布尔 + 登录名(非敏感),token 值永不回传浏览器。 */
383
+ export interface GithubValidateResponse {
384
+ ok: boolean;
385
+ /** token 是否存在于 DSH credentials */
386
+ configured: boolean;
387
+ /** token 是否有效(GitHub GET /user 成功;configured=false 时恒 false) */
388
+ valid: boolean;
389
+ /** GitHub 登录名(valid=true 时;非敏感展示位) */
390
+ login?: string;
391
+ }
392
+
380
393
  /** 同步请求超时(ms):与 Host 半 ROUTE_TIMEOUT_MS 对齐(git 网络操作可能较慢) */
381
394
  const SYNC_TIMEOUT_MS = 5 * 60 * 1000;
382
395
 
@@ -464,6 +477,12 @@ export class SyncApi {
464
477
  return postJson<{ ok: boolean }>(SYNC_API.githubCancel, { flowId }, this.t);
465
478
  }
466
479
 
480
+ /** GitHub token 有效性校验(判定「是否已登录」:token 存在且 GitHub API 接受;
481
+ * 401 → valid:false 引导重新登录;非 401 错误向上抛,UI 兜底不误判登出) */
482
+ async githubValidate(): Promise<GithubValidateResponse> {
483
+ return postJson<GithubValidateResponse>(SYNC_API.githubValidate, {}, this.t);
484
+ }
485
+
467
486
  /** 同步历史:列出本地祖先快照 + 自动同步执行记录(按 createdAt 倒序合并)。 */
468
487
  async history(): Promise<SyncHistoryResponse> {
469
488
  const response = await fetch(SYNC_API.history);
@@ -24,10 +24,15 @@ export const zh = {
24
24
  'config.saveHint': '表单改动会自动保存(密码/token 安全写入 DSH credentials,不会写入同步文件或日志);也可点击按钮立即保存。',
25
25
  // 通道选择器
26
26
  'channel.title': '同步通道',
27
- 'channel.hint': '选择远程同步通道:Git 私有仓库或 WebDAV 服务器。',
28
27
  'channel.git': 'Git 私有仓库',
29
28
  'channel.webdav': 'WebDAV 服务器',
30
29
  'channel.perChannelHint': 'GitHub 与 WebDAV 通道的自动同步、同步模式、加密与远端快照各自独立配置。',
30
+ // 通道入口卡(弹窗驱动:点按钮 → 弹窗内配置通道)
31
+ 'channel.open': '配置同步通道',
32
+ 'channel.openHint': '远程同步通过「同步通道」进行:Git 私有仓库或 WebDAV 服务器。点击按钮在弹窗中配置或修改。',
33
+ 'channel.configured': '已配置',
34
+ 'channel.notConfigured': '未配置',
35
+ 'channel.currentUrl': '当前地址',
31
36
  // WebDAV 配置表单
32
37
  'webdav.title': 'WebDAV 配置',
33
38
  'webdav.url': '服务器地址',
@@ -45,6 +50,7 @@ export const zh = {
45
50
  'github.login': '使用 GitHub 登录',
46
51
  'github.retry': '重新登录',
47
52
  'github.cancel': '取消',
53
+ 'github.tokenInvalid': 'GitHub 登录已失效,请重新登录。',
48
54
  'github.userCode': '一次性授权代码',
49
55
  'github.openAuth': '打开 GitHub 授权页面',
50
56
  'github.clientIdHint': '需要插件配置 githubClientId(GitHub OAuth App 的 client_id)才能使用 GitHub 登录。',
@@ -201,10 +207,15 @@ export const en: Record<keyof typeof zh, string> = {
201
207
  'config.saving': 'Saving…',
202
208
  'config.saveHint': 'Form changes are saved automatically (password/token written securely into DSH credentials, never into sync files or logs); you can also save explicitly with the button.',
203
209
  'channel.title': 'Sync Channel',
204
- 'channel.hint': 'Choose the remote sync channel: Git private repository or WebDAV server.',
205
210
  'channel.git': 'Git private repo',
206
211
  'channel.webdav': 'WebDAV server',
207
212
  'channel.perChannelHint': 'Auto sync, sync mode, encryption and remote snapshots are configured independently for GitHub and WebDAV channels.',
213
+ // Channel entry card (dialog-driven: click the button → configure the channel in the dialog)
214
+ 'channel.open': 'Configure sync channel',
215
+ 'channel.openHint': 'Remote sync runs through a sync channel: a private Git repository or a WebDAV server. Click the button to configure or change it in the dialog.',
216
+ 'channel.configured': 'Configured',
217
+ 'channel.notConfigured': 'Not configured',
218
+ 'channel.currentUrl': 'Current URL',
208
219
  'webdav.title': 'WebDAV Configuration',
209
220
  'webdav.url': 'Server URL',
210
221
  'webdav.urlHint': 'WebDAV server root URL (https://…). Sync snapshots and the index are stored under a snapshots/ subdirectory of that URL. Do not include a username/password in the URL.',
@@ -220,6 +231,7 @@ export const en: Record<keyof typeof zh, string> = {
220
231
  'github.login': 'Sign in with GitHub',
221
232
  'github.retry': 'Sign in again',
222
233
  'github.cancel': 'Cancel',
234
+ 'github.tokenInvalid': 'GitHub sign-in is no longer valid — please sign in again.',
223
235
  'github.userCode': 'One-time code',
224
236
  'github.openAuth': 'Open GitHub authorization page',
225
237
  'github.clientIdHint': 'Requires the githubClientId plugin config (the client_id of your GitHub OAuth App).',