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
@@ -0,0 +1,57 @@
1
+ /**
2
+ * m-disclaimer 免责状态单测:key 隔离、读写往返、存储异常降级。
3
+ */
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+
7
+ import {
8
+ disclaimerStorageKey, readDisclaimerDismissed, writeDisclaimerDismissed,
9
+ DISCLAIMER_STORAGE_PREFIX,
10
+ } from './disclaimer.ts'
11
+
12
+ /** 内存 Storage mock(最小实现 Pick<Storage,'getItem'|'setItem'>) */
13
+ function mockStorage(): Pick<Storage, 'getItem' | 'setItem'> & { data: Map<string, string> } {
14
+ const data = new Map<string, string>()
15
+ return {
16
+ data,
17
+ getItem(key: string): string | null { return data.get(key) ?? null },
18
+ setItem(key: string, value: string): void { data.set(key, value) },
19
+ }
20
+ }
21
+
22
+ test('disclaimer: key 生成带前缀且三操作互不相同', () => {
23
+ assert.equal(disclaimerStorageKey('upload'), `${DISCLAIMER_STORAGE_PREFIX}upload`)
24
+ assert.equal(disclaimerStorageKey('download'), `${DISCLAIMER_STORAGE_PREFIX}download`)
25
+ assert.equal(disclaimerStorageKey('install'), `${DISCLAIMER_STORAGE_PREFIX}install`)
26
+ const keys = new Set((['upload', 'download', 'install'] as const).map(disclaimerStorageKey))
27
+ assert.equal(keys.size, 3, '三操作 key 必须互不相同(分开记)')
28
+ })
29
+
30
+ test('disclaimer: 未写入时默认 false(要弹免责)', () => {
31
+ const storage = mockStorage()
32
+ assert.equal(readDisclaimerDismissed('upload', storage), false)
33
+ assert.equal(readDisclaimerDismissed('download', storage), false)
34
+ })
35
+
36
+ test('disclaimer: 写入后读回 true,且只影响对应操作(分开记)', () => {
37
+ const storage = mockStorage()
38
+ writeDisclaimerDismissed('upload', storage)
39
+ assert.equal(readDisclaimerDismissed('upload', storage), true, 'upload 已勾选 → 不再提示')
40
+ assert.equal(readDisclaimerDismissed('download', storage), false, 'download 未勾选 → 仍提示')
41
+ assert.equal(readDisclaimerDismissed('install', storage), false, 'install 未勾选 → 仍提示')
42
+ })
43
+
44
+ test('disclaimer: 存储抛异常(隐私模式)时读写静默降级不抛错', () => {
45
+ const broken: Pick<Storage, 'getItem' | 'setItem'> = {
46
+ getItem() { throw new Error('SecurityError') },
47
+ setItem() { throw new Error('SecurityError') },
48
+ }
49
+ assert.equal(readDisclaimerDismissed('upload', broken), false, 'getItem 异常 → false(仍提示)')
50
+ assert.doesNotThrow(() => writeDisclaimerDismissed('upload', broken), 'setItem 异常 → 静默忽略')
51
+ })
52
+
53
+ test('disclaimer: 值非 "1"(脏数据/旧格式)视为未勾选', () => {
54
+ const storage = mockStorage()
55
+ storage.setItem(disclaimerStorageKey('download'), 'yes')
56
+ assert.equal(readDisclaimerDismissed('download', storage), false)
57
+ })
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 市场免责弹窗的「不再提示」状态(m-disclaimer,node 可测)。
3
+ *
4
+ * 背景(2026-08-21):用户要求上传 / 下载 / 装回本地时弹出免责弹窗,
5
+ * 并支持「不再提示」勾选 —— 勾选后该操作以后不再弹。
6
+ *
7
+ * 设计:
8
+ * - 三个操作**分开记**(upload / download / install 各一个 key,互不影响);
9
+ * - 持久化用浏览器 localStorage(跨会话偏好,关掉浏览器再开仍记得);
10
+ * - storage 由调用方注入(React 壳传 window.localStorage;测试注入 mock),
11
+ * 本层零浏览器依赖、纯函数可测;
12
+ * - 读写都包 try/catch:存储不可用(隐私模式 / 禁用)时静默降级为
13
+ * 「未勾选」(下次仍提示),绝不让存储异常打断操作流程。
14
+ *
15
+ * 安全:本层只存布尔标记,不触碰任何凭据;key 无敏感信息。
16
+ */
17
+ export type DisclaimerKey = 'upload' | 'download' | 'install'
18
+
19
+ /** localStorage key 前缀(按 key 隔离三个操作) */
20
+ export const DISCLAIMER_STORAGE_PREFIX = 'dsh-cm-market.disclaimer.'
21
+
22
+ /** 由操作 key 生成 localStorage key。 */
23
+ export function disclaimerStorageKey(key: DisclaimerKey): string {
24
+ return `${DISCLAIMER_STORAGE_PREFIX}${key}`
25
+ }
26
+
27
+ /** 读取「不再提示」状态(true = 已勾选,该操作以后不再弹免责;任何异常 → false)。 */
28
+ export function readDisclaimerDismissed(key: DisclaimerKey, storage: Pick<Storage, 'getItem'>): boolean {
29
+ try {
30
+ return storage.getItem(disclaimerStorageKey(key)) === '1'
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ /** 写入「不再提示」状态(勾选后调用;存储不可用时静默忽略)。 */
37
+ export function writeDisclaimerDismissed(key: DisclaimerKey, storage: Pick<Storage, 'setItem'>): void {
38
+ try {
39
+ storage.setItem(disclaimerStorageKey(key), '1')
40
+ } catch {
41
+ // 存储不可用 → 忽略:下次仍会提示(安全侧保守)
42
+ }
43
+ }
@@ -36,6 +36,8 @@ test('M-01 api.status():GET 到 /market/status,解析已添加市场列表
36
36
  ok: true,
37
37
  configured: true,
38
38
  markets: [{ url: 'https://github.com/u/m', addedAt: '2026-08-16T00:00:00.000Z', itemCount: 3 }],
39
+ // 启动后首次打开市场页自动更新的判据(Host 进程内存标记;dsh 重启后重置)
40
+ bootAutoRefreshed: false,
39
41
  };
40
42
  let called: FetchCall | null = null;
41
43
  installFetchMock((call) => {
@@ -50,6 +52,7 @@ test('M-01 api.status():GET 到 /market/status,解析已添加市场列表
50
52
  assert.equal(result.configured, true);
51
53
  assert.equal(result.markets.length, 1);
52
54
  assert.equal(result.markets[0]?.url, 'https://github.com/u/m');
55
+ assert.equal(result.bootAutoRefreshed, false, 'status 契约透传 bootAutoRefreshed(首次打开自动更新判据)');
53
56
  assert.equal('token' in result, false, 'status 契约不得携带 token');
54
57
  assert.equal(lastCall()?.url, MARKET_API.status);
55
58
  assert.equal(lastCall()?.init?.method, undefined, 'GET 不设 method');
@@ -14,16 +14,10 @@ export const zh = {
14
14
  // 入口
15
15
  'section.label': '配置市场',
16
16
  'section.description': '浏览并下载社区共享的配置(公开 Git 仓库;下载内容一律视为不可信,导入前必经校验与确认)',
17
- // 市场仓库表单
17
+ // 市场仓库操作(2026-08-21 精简:面板只保留「拉取最新 / 浏览」,不再展示 URL/名称/条目数/状态行)
18
18
  'config.title': '市场仓库',
19
- 'config.builtinHint': '内置官方市场,只读、不可编辑(由创建者维护的公开 Git 仓库)。',
20
- 'config.official': '内置官方市场',
21
- 'config.custom': '自定义市场地址',
22
- 'config.repoUrlHint': '内置公开 Git 仓库地址。市场只读拉取(绝不推送)。',
23
19
  'config.refresh': '拉取最新',
24
20
  'config.refreshing': '拉取中…',
25
- 'config.addedAt': '更新于:{time}',
26
- 'config.itemCount': '{count} 个条目',
27
21
  // 列表
28
22
  'list.empty': '内置市场尚未加载。请先「拉取最新」。',
29
23
  'list.browse': '浏览',
@@ -96,6 +90,8 @@ export const zh = {
96
90
  'myconfigs.update.hint': '版本将自动 +1,已预填原条目信息(名称可改)。',
97
91
  'myconfigs.update.run': '一键更新',
98
92
  'myconfigs.update.running': '更新中…',
93
+ 'myconfigs.update.zipHint': '更新需要新的配置包:选择新 zip 后自动校验,通过后即可一键更新。',
94
+ 'myconfigs.update.selectZip': '选择新 ZIP…',
99
95
  // 上传/更新结果卡
100
96
  'myconfigs.result.title': '上传/更新完成',
101
97
  'myconfigs.result.version': '版本 {version}',
@@ -105,6 +101,9 @@ export const zh = {
105
101
  'myconfigs.result.openRepo': '打开仓库',
106
102
  'myconfigs.result.pr': '收录 PR #{number}',
107
103
  'myconfigs.result.openPr': '查看 PR',
104
+ 'myconfigs.result.listingPending': '已上传 ✓ 正在后台提交收录(fork + 收录 PR),稍后列表状态自动更新',
105
+ 'myconfigs.result.listingFailed': '收录失败',
106
+ 'myconfigs.result.relist': '重新提交收录',
108
107
  // 已上传列表
109
108
  'myconfigs.list.title': '已上传',
110
109
  'myconfigs.list.empty': '尚未上传任何配置',
@@ -115,6 +114,20 @@ export const zh = {
115
114
  'myconfigs.item.update': '更新',
116
115
  'myconfigs.item.install': '装回本地',
117
116
  'myconfigs.item.openPr': '查看收录 PR',
117
+ // 删除条目(弹窗二次确认 + 下架处理)
118
+ 'myconfigs.delete.run': '删除',
119
+ 'myconfigs.delete.confirm': '确认删除',
120
+ 'myconfigs.delete.confirmTitle': '删除条目',
121
+ 'myconfigs.delete.confirmText': '确认删除?该操作会从配置仓库永久删除该条目的索引与文件(不可恢复)',
122
+ 'myconfigs.delete.delistStarted': '已删除本地条目,并自动提交「下架 PR」(官方市场移除需人工合并,期间市场可能仍显示该条目)',
123
+ 'myconfigs.delete.prClosed': '已删除本地条目,并关闭该条目的待审核收录 PR',
124
+ // 免责弹窗(2026-08-21:上传/下载/装回本地前置免责声明;支持「不再提示」勾选,三操作分开记)
125
+ 'disclaimer.title': '免责声明',
126
+ 'disclaimer.upload.text': '你即将把配置上传到你的公开仓库(<login>/dsh-configs)并提交官方市场收录申请。\n\n上传后内容将对所有人公开可见,且需经过人工审核才能进入官方市场。请确保:\n· 内容不包含任何真实密钥 / 凭据(环境变量引用如 $VAR 是安全的);\n· 内容不包含个人隐私或本地环境信息;\n· 你有权分享这些配置。',
127
+ 'disclaimer.download.text': '你即将从公共网络市场下载配置并导入本地。\n\n下载内容属于不可信输入:未经官方审核、可能包含风险(安装插件 / 写入文件 / 修改设置)。\n· 导入前会逐项展示并校验,高风险分区默认不导入、需逐项批准;\n· 导入会先自动创建快照,失败可回滚;\n· 请核对来源与内容后再确认导入。',
128
+ 'disclaimer.install.text': '你即将把配置从你的公开仓库装回本地。\n\n该内容属于你此前上传的配置,但同样会经过安全校验与逐分区批准:\n· 高风险分区默认不导入、需逐项批准;\n· 导入前自动创建快照,失败可回滚。',
129
+ 'disclaimer.confirm': '我已了解,继续',
130
+ 'disclaimer.dontAsk': '不再提示(该操作以后不再显示免责声明)',
118
131
  // 错误
119
132
  'myconfigs.error.loadStatus': '登录状态读取失败',
120
133
  'myconfigs.error.loadItems': '已上传列表读取失败',
@@ -130,14 +143,8 @@ export const en: Record<keyof typeof zh, string> = {
130
143
  'section.label': 'Config Marketplace',
131
144
  'section.description': 'Browse and download community-shared configs (public Git repos; downloaded content is always untrusted — validated and confirmed before import)',
132
145
  'config.title': 'Market Repository',
133
- 'config.builtinHint': 'Built-in official market — read-only and not editable (a public Git repo maintained by the creator).',
134
- 'config.official': 'Built-in official market',
135
- 'config.custom': 'Custom market URL',
136
- 'config.repoUrlHint': 'Built-in public Git repository URL. Markets are read-only (never pushed).',
137
146
  'config.refresh': 'Refresh',
138
147
  'config.refreshing': 'Refreshing…',
139
- 'config.addedAt': 'Updated: {time}',
140
- 'config.itemCount': '{count} item(s)',
141
148
  'list.empty': 'The built-in market has not loaded yet. Click "Refresh" first.',
142
149
  'list.browse': 'Browse',
143
150
  'list.loading': 'Reading market…',
@@ -204,6 +211,8 @@ export const en: Record<keyof typeof zh, string> = {
204
211
  'myconfigs.update.hint': 'The version will be bumped automatically (+1); item info is prefilled (name editable).',
205
212
  'myconfigs.update.run': 'Update',
206
213
  'myconfigs.update.running': 'Updating…',
214
+ 'myconfigs.update.zipHint': 'Updating needs a new config zip: pick one and it will be validated automatically, then you can update.',
215
+ 'myconfigs.update.selectZip': 'Choose new ZIP…',
207
216
  'myconfigs.result.title': 'Upload/update complete',
208
217
  'myconfigs.result.version': 'version {version}',
209
218
  'myconfigs.result.sha256': 'SHA-256: {hash}',
@@ -212,6 +221,9 @@ export const en: Record<keyof typeof zh, string> = {
212
221
  'myconfigs.result.openRepo': 'Open repo',
213
222
  'myconfigs.result.pr': 'Listing PR #{number}',
214
223
  'myconfigs.result.openPr': 'View PR',
224
+ 'myconfigs.result.listingPending': 'Uploaded ✓ submitting listing in the background (fork + PR) — list status will update shortly',
225
+ 'myconfigs.result.listingFailed': 'Listing failed',
226
+ 'myconfigs.result.relist': 'Resubmit listing',
215
227
  'myconfigs.list.title': 'Uploaded',
216
228
  'myconfigs.list.empty': 'No configs uploaded yet',
217
229
  'myconfigs.list.loading': 'Loading…',
@@ -221,6 +233,18 @@ export const en: Record<keyof typeof zh, string> = {
221
233
  'myconfigs.item.update': 'Update',
222
234
  'myconfigs.item.install': 'Install locally',
223
235
  'myconfigs.item.openPr': 'View listing PR',
236
+ 'myconfigs.delete.run': 'Delete',
237
+ 'myconfigs.delete.confirm': 'Confirm delete',
238
+ 'myconfigs.delete.confirmTitle': 'Delete item',
239
+ 'myconfigs.delete.confirmText': 'Delete? This permanently removes the item index and files from your config repo (not recoverable)',
240
+ 'myconfigs.delete.delistStarted': 'Local item deleted; a "delist PR" has been submitted (removal from the official market needs human merge; the item may still appear in the market meanwhile)',
241
+ 'myconfigs.delete.prClosed': 'Local item deleted; its pending listing PR has been closed',
242
+ 'disclaimer.title': 'Disclaimer',
243
+ 'disclaimer.upload.text': 'You are about to upload configs to your public repo (<login>/dsh-configs) and submit a listing request to the official market.\n\nUploaded content becomes publicly visible and requires human review before entering the official market. Please ensure:\n· No real secrets / credentials are included (env-var references like $VAR are safe);\n· No personal privacy or local environment info is included;\n· You have the right to share these configs.',
244
+ 'disclaimer.download.text': 'You are about to download configs from a public network market and import them locally.\n\nDownloaded content is untrusted input: not officially reviewed and may carry risks (installing plugins / writing files / changing settings).\n· Everything is validated and shown before import; high-risk sections are not imported by default and require individual approval;\n· A snapshot is auto-created before import and rollback is available on failure;\n· Please verify the source and content before confirming the import.',
245
+ 'disclaimer.install.text': 'You are about to reinstall configs from your public repo to this machine.\n\nThis is content you uploaded before, but it still goes through the same validation and per-section approval:\n· High-risk sections are not imported by default and require individual approval;\n· A snapshot is auto-created before import and rollback is available on failure.',
246
+ 'disclaimer.confirm': 'I understand, continue',
247
+ 'disclaimer.dontAsk': "Don't ask again (no more disclaimers for this action)",
224
248
  'myconfigs.error.loadStatus': 'Failed to read sign-in status',
225
249
  'myconfigs.error.loadItems': 'Failed to read uploaded items',
226
250
  'common.cancel': 'Cancel',
@@ -22,13 +22,13 @@
22
22
  * - 本文件不 import 任何 node 模块(纯浏览器 bundle)。
23
23
  */
24
24
  import type {
25
- MyItemEntry, MyRepoForm, UploadResult,
25
+ DeleteResult, ListingStatusResponse, MyItemEntry, MyRepoForm, UploadResult,
26
26
  } from '../../market/my-repo.ts';
27
27
  import { ConfigManagerApiError } from '../api.ts';
28
28
  import { zhUiT, type UiT } from '../../ui/i18n.ts';
29
29
 
30
30
  // 便捷重导出:Host 半 my-repo.ts 领域类型(client 消费单一来源,避免漂移)
31
- export type { MyItemEntry, MyRepoForm, UploadResult };
31
+ export type { DeleteResult, ListingStatusResponse, MyItemEntry, MyRepoForm, UploadResult };
32
32
 
33
33
  /* ---------------------------------------------------------------- 端点常量 */
34
34
 
@@ -39,6 +39,9 @@ export const MY_CONFIGS_API = {
39
39
  upload: '/api/dsh-config-manager/me/upload',
40
40
  items: '/api/dsh-config-manager/me/items',
41
41
  update: '/api/dsh-config-manager/me/update',
42
+ listing: '/api/dsh-config-manager/me/listing',
43
+ relist: '/api/dsh-config-manager/me/relist',
44
+ delete: '/api/dsh-config-manager/me/delete',
42
45
  } as const;
43
46
 
44
47
  /** 「我的配置」请求超时(ms):上传/更新含 git clone/push/fork/PR,与 market/sync 对齐量级 */
@@ -160,4 +163,19 @@ export class MyConfigsApi {
160
163
  async meUpdate(payload: { zipPath: string; form: MyConfigsFormPayload }): Promise<MyUploadResult> {
161
164
  return postJson<MyUploadResult>(MY_CONFIGS_API.update, payload, this.t);
162
165
  }
166
+
167
+ /** 查询收录/下架任务状态(结果卡轮询;任务表未命中时 Host 回退 GitHub 实况推导;无任务无实况 → null) */
168
+ async meListing(itemId: string): Promise<ListingStatusResponse | null> {
169
+ return postJson<ListingStatusResponse | null>(MY_CONFIGS_API.listing, { itemId }, this.t);
170
+ }
171
+
172
+ /** 重新提交收录(失败/重启丢失后重试;幂等复用已存在 fork/open PR) */
173
+ async meRelist(itemId: string): Promise<ListingStatusResponse> {
174
+ return postJson<ListingStatusResponse>(MY_CONFIGS_API.relist, { itemId }, this.t);
175
+ }
176
+
177
+ /** 删除条目(同步删本地索引+文件;已收录自动后台提下架 PR;待审核自动关闭收录 PR) */
178
+ async meDelete(itemId: string): Promise<DeleteResult> {
179
+ return postJson<DeleteResult>(MY_CONFIGS_API.delete, { itemId }, this.t);
180
+ }
163
181
  }
@@ -14,9 +14,9 @@ import {
14
14
  autoFieldBadges, buildMyItemsView, deriveItemStatus, deriveLoginState, itemStatusBadge,
15
15
  itemStatusFromHost, MARKET_SYNC_PR_BRANCH_PREFIX, MY_CONFIG_AUTO_FIELDS, myConfigFormValid,
16
16
  parseCategories, prBranchFor, summarizeMyItems, toMyItemView, validateMyConfigForm,
17
- EMPTY_MY_CONFIG_FORM,
17
+ EMPTY_MY_CONFIG_FORM, EMPTY_MY_WIZARD, initialWizard, restoreMyWizard, toMyWizardSlice,
18
18
  } from './my-configs-view.ts'
19
- import type { ItemStatus, MeStatusData, MyItemSource, OpenPrInfo } from './my-configs-view.ts'
19
+ import type { ItemStatus, MeStatusData, MyItemSource, MyUploadResult, MyWizardSlice, MyWizardState, OpenPrInfo } from './my-configs-view.ts'
20
20
 
21
21
  /* ---------------------------------------------------------------- 登录状态推导 */
22
22
 
@@ -286,4 +286,139 @@ test('my-configs-view: ItemStatus 判别联合可判别(编译期形状约束
286
286
  assert.equal(statusKindOf({ kind: 'listed' }), 'listed')
287
287
  assert.equal(statusKindOf({ kind: 'pending-pr', prNumber: 1, prUrl: 'u' }), 'pending-pr')
288
288
  assert.equal(statusKindOf({ kind: 'not-listed' }), 'not-listed')
289
+ })
290
+
291
+ /* ---------------------------------------------------------------- 向导状态模型 */
292
+
293
+ /** UploadResult 夹具(对齐 host 半 my-repo.ts 形状;listing 必填)。 */
294
+ function uploadResult(overrides: Partial<MyUploadResult> = {}): MyUploadResult {
295
+ return {
296
+ ok: true,
297
+ itemId: 'my-config',
298
+ version: '1.0.0',
299
+ sha256: 'sha256-abc',
300
+ sections: ['settings'],
301
+ repoUrl: 'https://github.com/xiaojun/dsh-configs',
302
+ prNumber: null,
303
+ prUrl: null,
304
+ warnings: ['供应链警示'],
305
+ listing: 'pending',
306
+ ...overrides,
307
+ }
308
+ }
309
+
310
+ function wizard(overrides: Partial<MyWizardState> = {}): MyWizardState {
311
+ return {
312
+ mode: 'upload',
313
+ step: 'select',
314
+ zipPath: null,
315
+ fileName: null,
316
+ validating: false,
317
+ validated: false,
318
+ validationError: null,
319
+ form: { ...EMPTY_MY_CONFIG_FORM },
320
+ formErrors: { name: null },
321
+ running: false,
322
+ result: null,
323
+ error: null,
324
+ ...overrides,
325
+ }
326
+ }
327
+
328
+ test('my-configs-view: toMyWizardSlice 去掉瞬态字段(validating/running/formErrors 不出现)、保留 validated', () => {
329
+ const w = wizard({
330
+ mode: 'update',
331
+ step: 'form',
332
+ zipPath: 'C:\\tmp\\upload-abc.zip',
333
+ fileName: 'cfg.zip',
334
+ validating: true,
335
+ validated: true,
336
+ validationError: null,
337
+ form: { name: 'My Config', description: 'desc', categories: 'a, b', id: 'my-config' },
338
+ formErrors: { name: null },
339
+ running: true,
340
+ result: uploadResult(),
341
+ error: null,
342
+ })
343
+ const s = toMyWizardSlice(w)
344
+ assert.equal(s.mode, 'update')
345
+ assert.equal(s.step, 'form')
346
+ assert.equal(s.zipPath, 'C:\\tmp\\upload-abc.zip')
347
+ assert.equal(s.fileName, 'cfg.zip')
348
+ assert.equal(s.validated, true, 'validated 保留(已校验通过状态值得持久化)')
349
+ assert.equal(s.validationError, null)
350
+ assert.deepEqual(s.form, { name: 'My Config', description: 'desc', categories: 'a, b', id: 'my-config' })
351
+ assert.equal(s.result, w.result, 'result 为纯 JSON 直接引用')
352
+ assert.equal(s.error, null)
353
+ // 瞬态/派生字段不进入切片
354
+ assert.ok(!('validating' in s), 'validating 不得进入切片')
355
+ assert.ok(!('running' in s), 'running 不得进入切片')
356
+ assert.ok(!('formErrors' in s), 'formErrors 不得进入切片(恢复时重算)')
357
+ })
358
+
359
+ test('my-configs-view: restoreMyWizard(null) → EMPTY 语义(step select、瞬态归零、formErrors 无错)', () => {
360
+ const w = restoreMyWizard(null)
361
+ assert.equal(w.mode, 'upload')
362
+ assert.equal(w.step, 'select')
363
+ assert.equal(w.zipPath, null)
364
+ assert.equal(w.fileName, null)
365
+ assert.equal(w.validating, false)
366
+ assert.equal(w.validated, false)
367
+ assert.equal(w.validationError, null)
368
+ assert.deepEqual(w.form, EMPTY_MY_CONFIG_FORM)
369
+ assert.deepEqual(w.formErrors, { name: null })
370
+ assert.equal(w.running, false)
371
+ assert.equal(w.result, null)
372
+ assert.equal(w.error, null)
373
+ // 返回新对象,不共享 EMPTY_MY_WIZARD / EMPTY_MY_CONFIG_FORM 引用
374
+ assert.notEqual(w, EMPTY_MY_WIZARD)
375
+ assert.notEqual(w.form, EMPTY_MY_CONFIG_FORM)
376
+ })
377
+
378
+ test('my-configs-view: restoreMyWizard(slice) → 瞬态归零、formErrors 按 form.name 重算、其余字段一致', () => {
379
+ const slice: MyWizardSlice = {
380
+ mode: 'update',
381
+ step: 'form',
382
+ zipPath: 'C:\\tmp\\upload-abc.zip',
383
+ fileName: 'cfg.zip',
384
+ validated: true,
385
+ validationError: null,
386
+ form: { name: 'My Config', description: '', categories: '', id: 'my-config' },
387
+ result: uploadResult(),
388
+ error: null,
389
+ }
390
+ const w = restoreMyWizard(slice)
391
+ assert.equal(w.mode, 'update')
392
+ assert.equal(w.step, 'form')
393
+ assert.equal(w.zipPath, slice.zipPath)
394
+ assert.equal(w.fileName, 'cfg.zip')
395
+ assert.equal(w.validating, false, 'validating 恢复后恒 false')
396
+ assert.equal(w.running, false, 'running 恢复后恒 false')
397
+ assert.equal(w.validated, true, 'validated 从切片保留')
398
+ assert.deepEqual(w.form, slice.form)
399
+ assert.deepEqual(w.formErrors, { name: null }, 'form.name 合法 → 无错误')
400
+ assert.equal(w.result, slice.result)
401
+ assert.equal(w.error, null)
402
+ assert.notEqual(w.form, slice.form, 'form 为新对象,不共享切片引用')
403
+ // form.name 为空 → formErrors.name 重算为非 null
404
+ const w2 = restoreMyWizard({ ...slice, form: { ...slice.form, name: ' ' } })
405
+ assert.notEqual(w2.formErrors.name, null, '空白名称 → 重算出错误')
406
+ assert.equal(w2.formErrors.name, validateMyConfigForm({ ...slice.form, name: ' ' }).name)
407
+ })
408
+
409
+ test('my-configs-view: initialWizard(mode) → 指定模式、step select、空表单', () => {
410
+ const w = initialWizard('update')
411
+ assert.equal(w.mode, 'update')
412
+ assert.equal(w.step, 'select')
413
+ assert.deepEqual(w.form, EMPTY_MY_CONFIG_FORM)
414
+ assert.deepEqual(w.formErrors, { name: null })
415
+ assert.equal(w.zipPath, null)
416
+ assert.equal(w.result, null)
417
+ assert.equal(w.validating, false)
418
+ assert.equal(w.running, false)
419
+ assert.notEqual(w, EMPTY_MY_WIZARD, '每次返回新对象')
420
+ assert.notEqual(w.form, EMPTY_MY_CONFIG_FORM, 'form 为新对象')
421
+ const u = initialWizard('upload')
422
+ assert.equal(u.mode, 'upload')
423
+ assert.equal(u.step, 'select')
289
424
  })
@@ -17,6 +17,10 @@
17
17
  * 渲染前仍过 redact() 兜底(与全站一致)。
18
18
  */
19
19
  import { zhUiT, type UiT } from '../../ui/i18n.ts'
20
+ import type { UploadResult } from '../../market/my-repo.ts'
21
+ import type { MarketDownloadResult } from '../../market/types.ts'
22
+ import type { ImportResult } from '../../core/types.ts'
23
+ import type { MarketApprovals } from './market-view.ts'
20
24
 
21
25
  /* ---------------------------------------------------------------- 登录状态展示模型 */
22
26
 
@@ -69,19 +73,23 @@ export function deriveLoginState(input: {
69
73
  /* ---------------------------------------------------------------- 上传表单模型 */
70
74
 
71
75
  /**
72
- * 一键上传表单(§2.4 用户输入最小化):仅名称(必填,预填 zip 文件名可改)/
76
+ * 一键上传/更新表单(§2.4 用户输入最小化):仅名称(必填,预填 zip 文件名可改)/
73
77
  * 描述(可选)/ 类别(可选,逗号分隔原始文本)。id/author/version/updatedAt
74
78
  * 均由系统自动生成,不在表单里。
79
+ * `id` 为**更新模式内部字段**(「更新」按钮预填条目 id,避免后端靠 name→slug 猜错),
80
+ * 不渲染为输入框;上传模式恒为空串。
75
81
  */
76
82
  export interface MyConfigForm {
77
83
  name: string
78
84
  description: string
79
85
  /** 类别原始文本(逗号分隔;UI 解析为数组,空串 = 无类别) */
80
86
  categories: string
87
+ /** 更新目标条目 id(update 模式预填;upload 模式空串) */
88
+ id: string
81
89
  }
82
90
 
83
91
  /** 空表单初值(React 壳 useState 初始 + 测试用)。 */
84
- export const EMPTY_MY_CONFIG_FORM: MyConfigForm = { name: '', description: '', categories: '' }
92
+ export const EMPTY_MY_CONFIG_FORM: MyConfigForm = { name: '', description: '', categories: '', id: '' }
85
93
 
86
94
  /** 表单字段错误(null = 该字段合法;文案已翻译)。 */
87
95
  export interface MyConfigFormErrors {
@@ -103,6 +111,170 @@ export function parseCategories(csv: string): string[] {
103
111
  return csv.split(',').map((c) => c.trim()).filter((c) => c !== '')
104
112
  }
105
113
 
114
+ /* ---------------------------------------------------------------- 上传/更新向导状态模型 */
115
+
116
+ /**
117
+ * 上传结果(对齐 host 半 my-repo.ts 的 UploadResult 类型,避免客户端各层重复声明)。
118
+ * 纯 JSON 无 token:error/listingError 在宿主侧已脱敏;可安全持久化。
119
+ */
120
+ export type MyUploadResult = UploadResult
121
+
122
+ /**
123
+ * 上传/更新向导的运行时全量状态(迁移自 MyConfigsView 组件内私有 WizardState)。
124
+ * validating/validated/running/formErrors 为瞬态/派生字段:不进入持久化切片
125
+ * (MyWizardSlice);validated 虽为瞬态,但「是否已校验通过」值得随切片保留,
126
+ * 恢复后由组件结合 zipPath 决定是否自动重校验(详见 restoreMyWizard 注释)。
127
+ */
128
+ export interface MyWizardState {
129
+ mode: 'upload' | 'update'
130
+ step: 'select' | 'validate' | 'form'
131
+ zipPath: string | null
132
+ fileName: string | null
133
+ /** 瞬态:校验进行中(不持久化,恢复后恒 false) */
134
+ validating: boolean
135
+ /** 瞬态:最近一次校验是否通过(切片保留,恢复后由组件决定是否重校验) */
136
+ validated: boolean
137
+ validationError: string | null
138
+ form: MyConfigForm
139
+ /** 派生:由 validateMyConfigForm(form) 重算(不持久化) */
140
+ formErrors: MyConfigFormErrors
141
+ /** 瞬态:上传/更新请求进行中(不持久化,恢复后恒 false) */
142
+ running: boolean
143
+ result: MyUploadResult | null
144
+ error: string | null
145
+ }
146
+
147
+ /** 向导初始状态常量(initialWizard('upload') 语义;调用方应经 initialWizard 获取新对象)。 */
148
+ export const EMPTY_MY_WIZARD: MyWizardState = {
149
+ mode: 'upload',
150
+ step: 'select',
151
+ zipPath: null,
152
+ fileName: null,
153
+ validating: false,
154
+ validated: false,
155
+ validationError: null,
156
+ form: { ...EMPTY_MY_CONFIG_FORM },
157
+ formErrors: { name: null },
158
+ running: false,
159
+ result: null,
160
+ error: null,
161
+ }
162
+
163
+ /**
164
+ * 向导的持久化切片 = 全量状态去掉瞬态/派生字段(validating/running/formErrors)。
165
+ * formErrors 恢复时由 restoreMyWizard 经 validateMyConfigForm 重算;validating/running 恢复后恒 false。
166
+ * 仅非敏感字段:zipPath 为宿主受控临时文件路径、form 为用户输入描述性内容(含 update 模式的
167
+ * 公开条目 id)、result 为无 token 的 UploadResult —— 与 run-store market 切片整体落盘纪律一致。
168
+ */
169
+ export interface MyWizardSlice {
170
+ mode: 'upload' | 'update'
171
+ step: 'select' | 'validate' | 'form'
172
+ zipPath: string | null
173
+ fileName: string | null
174
+ validated: boolean
175
+ validationError: string | null
176
+ form: MyConfigForm
177
+ result: MyUploadResult | null
178
+ error: string | null
179
+ }
180
+
181
+ /** 全量状态 → 持久化切片(忽略 validating/running/formErrors;form 浅拷贝,result 直接引用纯 JSON)。 */
182
+ export function toMyWizardSlice(w: MyWizardState): MyWizardSlice {
183
+ return {
184
+ mode: w.mode,
185
+ step: w.step,
186
+ zipPath: w.zipPath,
187
+ fileName: w.fileName,
188
+ validated: w.validated,
189
+ validationError: w.validationError,
190
+ form: { ...w.form },
191
+ result: w.result,
192
+ error: w.error,
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 持久化切片 → 全量状态(null → 全新向导)。瞬态字段归零(validating/running 恒 false),
198
+ * formErrors 按 form 经 validateMyConfigForm 重算;form 拷贝新对象(不共享切片引用)。
199
+ */
200
+ export function restoreMyWizard(slice: MyWizardSlice | null): MyWizardState {
201
+ if (slice === null) return { ...EMPTY_MY_WIZARD, form: { ...EMPTY_MY_CONFIG_FORM } }
202
+ return {
203
+ mode: slice.mode,
204
+ step: slice.step,
205
+ zipPath: slice.zipPath,
206
+ fileName: slice.fileName,
207
+ validating: false,
208
+ validated: slice.validated,
209
+ validationError: slice.validationError,
210
+ form: { ...slice.form },
211
+ formErrors: validateMyConfigForm(slice.form),
212
+ running: false,
213
+ result: slice.result,
214
+ error: slice.error,
215
+ }
216
+ }
217
+
218
+ /** 指定模式的向导初始状态(form 全新空表;mode/step 之外同 EMPTY_MY_WIZARD)。 */
219
+ export function initialWizard(mode: 'upload' | 'update'): MyWizardState {
220
+ return { ...EMPTY_MY_WIZARD, mode, form: { ...EMPTY_MY_CONFIG_FORM } }
221
+ }
222
+
223
+ /* ---------------------------------------------------------------- 装回本地状态模型 */
224
+
225
+ /**
226
+ * 装回本地(下载 + 逐分区批准 + 执行导入)的运行时全量状态。
227
+ * importing 为瞬态:不进入持久化切片(MyInstallSlice);approvals(逐分区批准)是
228
+ * 用户昂贵的人工决策,必须随切片保留(切 tab/刷新不丢)。
229
+ */
230
+ export interface MyInstallState {
231
+ itemId: string
232
+ detail: MarketDownloadResult | null
233
+ /** 逐分区批准表(安全不变式 (c):高风险分区默认不勾选) */
234
+ approvals: MarketApprovals
235
+ /** 瞬态:导入执行中(不持久化,恢复后恒 false) */
236
+ importing: boolean
237
+ importResult: ImportResult | null
238
+ error: string | null
239
+ }
240
+
241
+ /**
242
+ * 装回本地的持久化切片 = 全量状态去掉瞬态(importing)。
243
+ * 仅非敏感字段:detail.zipPath 为宿主受控临时文件路径、approvals 为布尔批准表、
244
+ * importResult 为纯 JSON、error 为已 redact 文本 —— 与 run-store market 切片整体落盘纪律一致。
245
+ */
246
+ export interface MyInstallSlice {
247
+ itemId: string
248
+ detail: MarketDownloadResult | null
249
+ approvals: MarketApprovals
250
+ importResult: ImportResult | null
251
+ error: string | null
252
+ }
253
+
254
+ /** 全量状态 → 持久化切片(忽略 importing;detail/approvals/importResult 直接引用纯 JSON/布尔表)。 */
255
+ export function toMyInstallSlice(s: MyInstallState): MyInstallSlice {
256
+ return {
257
+ itemId: s.itemId,
258
+ detail: s.detail,
259
+ approvals: s.approvals,
260
+ importResult: s.importResult,
261
+ error: s.error,
262
+ }
263
+ }
264
+
265
+ /** 持久化切片 → 全量状态(null → 无装回本地会话;importing 恢复后恒 false)。 */
266
+ export function restoreMyInstall(slice: MyInstallSlice | null): MyInstallState | null {
267
+ if (slice === null) return null
268
+ return {
269
+ itemId: slice.itemId,
270
+ detail: slice.detail,
271
+ approvals: slice.approvals,
272
+ importing: false,
273
+ importResult: slice.importResult,
274
+ error: slice.error,
275
+ }
276
+ }
277
+
106
278
  /* ---------------------------------------------------------------- 系统自动字段徽章 */
107
279
 
108
280
  /** 系统自动字段(表单不用填,UI 以「系统自动」徽章展示,§2.4)。 */