dsh-config-manager 0.1.76 → 0.1.78
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/lib/adapters/plugin-data-declared.d.ts +12 -0
- package/lib/adapters/plugin-data-declared.js +57 -0
- package/lib/adapters/plugin-data.d.ts +4 -6
- package/lib/adapters/plugin-data.js +8 -36
- package/lib/adapters/plugins.d.ts +16 -0
- package/lib/adapters/plugins.js +223 -16
- package/lib/client.d.ts +104 -9
- package/lib/client.js +1800 -718
- package/lib/core/analyzer.js +3 -1
- package/lib/core/backup.d.ts +9 -0
- package/lib/core/backup.js +16 -1
- package/lib/core/messages.d.ts +11 -0
- package/lib/core/messages.js +22 -0
- package/lib/core/plugin-cli.d.ts +26 -0
- package/lib/core/plugin-cli.js +62 -2
- package/lib/core/rollback-attribution.d.ts +81 -0
- package/lib/core/rollback-attribution.js +236 -0
- package/lib/core/rollback-merge.d.ts +22 -0
- package/lib/core/rollback-merge.js +203 -0
- package/lib/core/rollback-point.d.ts +114 -0
- package/lib/core/rollback-point.js +534 -17
- package/lib/core/types.d.ts +28 -0
- package/lib/index.d.ts +26 -3
- package/lib/index.js +129 -16
- package/lib/routes/recovery.js +11 -0
- package/lib/schema/section-registry.js +5 -5
- package/lib/schema/types.d.ts +14 -0
- package/lib/ui/badge-tone.d.ts +16 -0
- package/lib/ui/badge-tone.js +20 -0
- package/lib/ui/i18n.d.ts +4 -4
- package/lib/ui/i18n.js +8 -8
- package/lib/ui/rollback-view.d.ts +95 -0
- package/lib/ui/rollback-view.js +55 -0
- package/lib/ui/selection-model.d.ts +140 -1
- package/lib/ui/selection-model.js +241 -9
- package/package.json +1 -1
- package/src/adapters/plugin-data-declared.ts +60 -0
- package/src/adapters/plugin-data.ts +7 -33
- package/src/adapters/plugins.test.ts +162 -0
- package/src/adapters/plugins.ts +236 -17
- package/src/adapters/units.test.ts +26 -0
- package/src/client/chat/ChatEntryView.tsx +147 -46
- package/src/client/chat/chat-entry-view.test.ts +277 -1
- package/src/client/chat/chat-entry-view.ts +98 -4
- package/src/client/chat/chat-node-registration.ts +71 -0
- package/src/client/chat/chat-slots.ts +30 -12
- package/src/client/common/ContentPicker.tsx +581 -175
- package/src/client/common/host-primitives.ts +64 -1
- package/src/client/common/ui.tsx +17 -3
- package/src/client/config-manager.module.css +630 -397
- package/src/client/css-scale-guard.test.ts +41 -3
- package/src/client/css-token-guard.test.ts +10 -4
- package/src/client/export/ExportView.tsx +2 -0
- package/src/client/index.ts +15 -3
- package/src/client/locales.ts +54 -10
- package/src/client/recovery/RollbackCard.tsx +56 -2
- package/src/client/recovery/RollbackDiffPane.tsx +5 -0
- package/src/client/recovery/RollbackFileChanges.tsx +46 -2
- package/src/client/recovery/recovery-locales.ts +22 -0
- package/src/client/recovery/rollback-file-changes.test.ts +1 -1
- package/src/core/analyzer.ts +3 -1
- package/src/core/backup.ts +16 -1
- package/src/core/messages.ts +22 -0
- package/src/core/plugin-cli.fs.test.ts +65 -3
- package/src/core/plugin-cli.ts +55 -2
- package/src/core/rollback-attribution.test.ts +120 -0
- package/src/core/rollback-attribution.ts +267 -0
- package/src/core/rollback-merge.test.ts +133 -0
- package/src/core/rollback-merge.ts +213 -0
- package/src/core/rollback-point.test.ts +412 -0
- package/src/core/rollback-point.ts +560 -20
- package/src/core/types.ts +29 -0
- package/src/index.facade.test.ts +60 -0
- package/src/index.ts +133 -19
- package/src/routes/recovery.ts +11 -0
- package/src/schema/section-registry.ts +5 -5
- package/src/schema/types.ts +14 -0
- package/src/ui/badge-tone.test.ts +23 -0
- package/src/ui/badge-tone.ts +27 -0
- package/src/ui/i18n.ts +8 -8
- package/src/ui/rollback-view.test.ts +27 -0
- package/src/ui/rollback-view.ts +139 -0
- package/src/ui/selection-model.test.ts +240 -2
- package/src/ui/selection-model.ts +349 -1
|
@@ -15,18 +15,25 @@
|
|
|
15
15
|
* 职责边界:本组件**只渲染与转发交互**,全部选择语义(稀疏排除集、原子组联动、
|
|
16
16
|
* 三态判定、请求参数换算)都在 `src/ui/selection-model.ts` 里,node 可测。
|
|
17
17
|
*/
|
|
18
|
-
import { useMemo, useState } from 'react'
|
|
18
|
+
import { Fragment, useEffect, useMemo, useState } from 'react'
|
|
19
19
|
import type { ChangeEvent, ReactNode } from 'react'
|
|
20
20
|
import type { SectionId } from '../../schema/types.ts'
|
|
21
21
|
import type { TranslateNS } from '../client-types.ts'
|
|
22
22
|
import {
|
|
23
23
|
applySessionParentCoupling,
|
|
24
24
|
applySessionWorkspaceCoupling,
|
|
25
|
-
filterSections, groupPickState, groupSelectedCount, groupUnits,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
filterPluginTree, filterSections, groupPickState, groupSelectedCount, groupUnits, isPluginTreeSection,
|
|
26
|
+
pickerSummary,
|
|
27
|
+
pluginEntryPickState, pluginEntrySelectedCount, pluginEntryTotalCount, pluginEntryUnreadSections, pluginTree,
|
|
28
|
+
pluginTreePickState, pluginTreeSelectedCount, pluginTreeTotalCount, pluginTreeUnreadSections,
|
|
29
|
+
applyPluginPendingChecks, sameSelection, sectionCountState, sectionExpandable, sectionPickState,
|
|
30
|
+
selectAll, selectedUnitCount, tailWeightedEllipsis, togglePluginEntry, togglePluginTree, toggleSection,
|
|
31
|
+
toggleUnit, toggleUnitGroup,
|
|
32
|
+
unitChecked, visibleUnits,
|
|
33
|
+
type PluginLeafKind, type Selection, type SelectionSection, type SelectionUnit, type SessionParentChange,
|
|
34
|
+
type SessionWorkspaceCouplingFocus,
|
|
29
35
|
} from '../../ui/selection-model.ts'
|
|
36
|
+
import type { ExportGroup } from '../../ui/types.ts'
|
|
30
37
|
import { formatBytes } from '../../ui/report.ts'
|
|
31
38
|
import { redact } from '../../security/redaction.ts'
|
|
32
39
|
import { Badge, Button, Checkbox, Spinner } from './ui.tsx'
|
|
@@ -39,6 +46,30 @@ import css from '../config-manager.module.css'
|
|
|
39
46
|
/** 单元名显示长度上限(配合 .pickerUnitName 的 CSS 省略做兜底;UI-16) */
|
|
40
47
|
const UNIT_NAME_MAX = 44
|
|
41
48
|
|
|
49
|
+
/**
|
|
50
|
+
* 「插件」一级区块在展开态表里的键(与插件包名、`bucket:<分区>` 共用 `expandedPlugins` 这张表)。
|
|
51
|
+
* 默认折叠 —— 用户要求「所有插件收在一个叫插件的区块里,展开后才出现」。
|
|
52
|
+
*/
|
|
53
|
+
const PLUGIN_ROOT_KEY = 'plugin-root'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 分组标题的字典键(与 `ui/types.ts` 的 EXPORT_GROUPS 一一对应)。
|
|
57
|
+
*
|
|
58
|
+
* **顺序不在这里**:顺序由调用方给的节点顺序决定(宿主目录已按 EXPORT_GROUPS 排好),
|
|
59
|
+
* 本表只负责「id → 本地化文案」这一件事(否则中英文界面会漂移)。
|
|
60
|
+
*/
|
|
61
|
+
const GROUP_TITLE_KEY = {
|
|
62
|
+
general: 'group.general',
|
|
63
|
+
ai: 'group.ai',
|
|
64
|
+
extensions: 'group.extensions',
|
|
65
|
+
mcp: 'group.mcp',
|
|
66
|
+
customization: 'group.customization',
|
|
67
|
+
automation: 'group.automation',
|
|
68
|
+
workspace: 'group.workspace',
|
|
69
|
+
ui: 'group.ui',
|
|
70
|
+
optional: 'group.optional',
|
|
71
|
+
} as const satisfies Record<ExportGroup, string>
|
|
72
|
+
|
|
42
73
|
/** 单元列表容器 id(展开按钮的 aria-controls 指向它;UI-22) */
|
|
43
74
|
function unitsId(section: SectionId): string {
|
|
44
75
|
return `picker-units-${section}`
|
|
@@ -121,7 +152,41 @@ export function ContentPicker({
|
|
|
121
152
|
* 分组行的勾选框也照常可用(不必先展开就能整组选/取消)。
|
|
122
153
|
*/
|
|
123
154
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
|
|
155
|
+
/**
|
|
156
|
+
* 「按插件看」的展开态(键 = 插件包名,或 \`bucket:<分区 id>\`)。
|
|
157
|
+
* 默认全部折叠 —— 展开插件节点时才去读它涉及的 pluginData / pluginAssets 清单(惰性,见渲染处)。
|
|
158
|
+
*/
|
|
159
|
+
const [expandedPlugins, setExpandedPlugins] = useState<Record<string, boolean>>({})
|
|
160
|
+
/**
|
|
161
|
+
* 待补勾队列(键 = 「插件」一级区块或某个插件名)。
|
|
162
|
+
*
|
|
163
|
+
* 为什么需要它:分区清单是**惰性**读取的 —— 勾一级区块/插件时,它名下还有区域没读到(unread),
|
|
164
|
+
* 那些单元此刻勾不上。若就此结束,用户会以为「全带上了」,实际包里少了数据目录/大文件。
|
|
165
|
+
* 所以:先勾已知的 + 触发读取 + 入队;清单到货后(nodes 变化)自动补齐并出队。
|
|
166
|
+
*/
|
|
167
|
+
const [pendingChecks, setPendingChecks] = useState<{ key: string; kind: 'root' | 'entry' }[]>([])
|
|
124
168
|
const visible = useMemo(() => filterSections(nodes, query), [nodes, query])
|
|
169
|
+
/** 「按插件看」的树:同一份 nodes 的第二个投影(纯函数在 src/ui/selection-model.ts) */
|
|
170
|
+
/** 「按插件看」的完整树(无搜索过滤;纯函数在 src/ui/selection-model.ts) */
|
|
171
|
+
const fullTree = useMemo(() => pluginTree(nodes), [nodes])
|
|
172
|
+
const filteredTree = useMemo(() => filterPluginTree(fullTree, query), [fullTree, query])
|
|
173
|
+
/**
|
|
174
|
+
* 搜索把树过滤空时**回落到完整树**:分区名命中(如搜「插件数据目录」)应当仍能看到这一组,
|
|
175
|
+
* 否则用户会在组标题下面看到一句「没有匹配」,而组里的分区明明就在那里。
|
|
176
|
+
*/
|
|
177
|
+
const tree = filteredTree.entries.length === 0 && filteredTree.buckets.every((b) => b.units.length === 0)
|
|
178
|
+
? fullTree
|
|
179
|
+
: filteredTree
|
|
180
|
+
/**
|
|
181
|
+
* 「插件与扩展」组里的四个插件分区(plugins / pluginFiles / pluginData / pluginAssets)合并成
|
|
182
|
+
* **一棵按插件分组的树**,只在其中**第一个出现**的分区处渲染一次 —— 其余成员就地跳过。
|
|
183
|
+
*
|
|
184
|
+
* 为什么用「第一个成员」而不是新造一个假节点:顺序由调用方给的节点顺序决定(宿主目录已按
|
|
185
|
+
* EXPORT_GROUPS 排好),树落在它本来该在的位置,不引入第二个排序来源。
|
|
186
|
+
* `self`(配置管理器设置)**不在**这四项里,仍按普通分区行渲染。
|
|
187
|
+
*/
|
|
188
|
+
const treeSectionIds = visible.filter((n) => isPluginTreeSection(n.section)).map((n) => n.section)
|
|
189
|
+
const firstTreeSection = treeSectionIds[0]
|
|
125
190
|
const summary = pickerSummary(value, nodes)
|
|
126
191
|
/**
|
|
127
192
|
* 未读到清单的分区数(在途 + 失败,去重;G-02):> 0 时 footer 合计必须标注
|
|
@@ -152,6 +217,354 @@ export function ContentPicker({
|
|
|
152
217
|
section === 'sessions' ? 'sessions' : section === 'workspaces' ? 'workspaces' : 'both'
|
|
153
218
|
)
|
|
154
219
|
|
|
220
|
+
/**
|
|
221
|
+
* 待补勾:清单到货后把「勾一级区块/插件时还没读到」的单元补齐(见 pendingChecks 的注释)。
|
|
222
|
+
*
|
|
223
|
+
* 收敛判据是「该节点名下已无 unread 叶子」;只要还在读就一直排队,到齐后补一次并出队。
|
|
224
|
+
* 幂等:补齐结果与当前选择等价时不写库(sameSelection),避免自激渲染。
|
|
225
|
+
*/
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (pendingChecks.length === 0) return
|
|
228
|
+
const { selection, remaining } = applyPluginPendingChecks(value, nodes, tree, pendingChecks)
|
|
229
|
+
setPendingChecks(remaining)
|
|
230
|
+
if (!sameSelection(selection, value)) commit(selection)
|
|
231
|
+
// tree/nodes 变化即代表清单到货;value 变化来自本次补齐本身(此时队列已空,直接返回)
|
|
232
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
233
|
+
}, [pendingChecks, tree, nodes, value])
|
|
234
|
+
|
|
235
|
+
/** 叶子的种类文案(模型不出文案;i18n 铁律)。 */
|
|
236
|
+
const leafKindText = (kind: PluginLeafKind): string => (
|
|
237
|
+
kind === 'plugin' ? t('picker.pluginLeafPlugin')
|
|
238
|
+
: kind === 'config' ? t('picker.pluginLeafConfig') : t('picker.pluginLeafAssets')
|
|
239
|
+
)
|
|
240
|
+
/**
|
|
241
|
+
* 桶的标题文案(无法归属到具体插件的整块分区 —— 不猜归属的结果必须显式说出来)。
|
|
242
|
+
*
|
|
243
|
+
* 只有两个桶:插件分区里的补丁行 / pnpm-workspace.yaml,以及 pluginFiles。两者都**不是插件**,
|
|
244
|
+
* 因此不放进「插件」一级区块里。缺省回落分区显示名(新增桶时不会渲染出裸 key)。
|
|
245
|
+
*/
|
|
246
|
+
const bucketLabel = (section: SectionId): string => (
|
|
247
|
+
section === 'plugins' ? t('picker.bucketPlugins')
|
|
248
|
+
: section === 'pluginFiles' ? t('picker.bucketLooseFiles') : label(section)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 单个单元行(**唯一实现**:「按分区看」的单元与「按插件看」的桶共用同一个渲染)。
|
|
253
|
+
*
|
|
254
|
+
* G-01:单元名与 detail 都是**宿主下发的文本**(导入侧来自备份包内的单元名/会话名),
|
|
255
|
+
* 必须同等待遇 —— 显示文本与 title 都先过 redact,且**先脱敏、后省略**(反过来的话,
|
|
256
|
+
* 被截断的密钥不再匹配值形状模式而漏网)。
|
|
257
|
+
*/
|
|
258
|
+
const renderUnitRow = (node: SelectionSection, u: SelectionUnit, nested: boolean): ReactNode => {
|
|
259
|
+
const safeLabel = redact(u.label)
|
|
260
|
+
return (
|
|
261
|
+
<div key={u.id} className={nested ? css.pickerUnit + ' ' + css.pickerUnitNested : css.pickerUnit}>
|
|
262
|
+
{/* 占位对齐:让单元行的勾选框与分组行(chevron 之后)的勾选框同一左缘 */}
|
|
263
|
+
{nested && <span className={css.pickerChevronSpacer} aria-hidden="true" />}
|
|
264
|
+
<Checkbox
|
|
265
|
+
checked={unitChecked(value, node.section, u.id)}
|
|
266
|
+
onChange={(checked) => { commit(toggleUnit(value, nodes, u.id, checked), focusOf(node.section), { unitId: u.id, checked }) }}
|
|
267
|
+
label={
|
|
268
|
+
<span className={css.categoryItem}>
|
|
269
|
+
{/* 空白会话(从未提问过):DSH 自己显示「新会话」—— 这里同样用本地化占位名,
|
|
270
|
+
目录名仍进 title(悬停可核对)。其余单元照旧中段省略 + title 全文(UI-16,DESIGN.md §9.5)。 */}
|
|
271
|
+
{u.blank === true ? (
|
|
272
|
+
<span className={css.pickerUnitName} title={safeLabel}>{t('picker.blankSession')}</span>
|
|
273
|
+
) : (
|
|
274
|
+
<span className={css.pickerUnitName} title={safeLabel}>{tailWeightedEllipsis(safeLabel, UNIT_NAME_MAX)}</span>
|
|
275
|
+
)}
|
|
276
|
+
{u.lockedWith !== undefined && (
|
|
277
|
+
<span title={t('picker.lockedHint')}><Badge kind="warn">{t('picker.locked')}</Badge></span>
|
|
278
|
+
)}
|
|
279
|
+
{u.fileCount !== undefined && u.fileCount > 1 && (
|
|
280
|
+
<span className={css.pickerCount}>{t('picker.fileCount', { count: String(u.fileCount) })}</span>
|
|
281
|
+
)}
|
|
282
|
+
{/* detail 是宿主下发的原始字符串(版本 / 绝对路径等)→ 渲染前过 redact(安全不变量) */}
|
|
283
|
+
{/* 调用方决定的单元级徽章(市场:将改动 / 已一致 / 不导入) */}
|
|
284
|
+
{unitBadge?.(node.section, u.id)}
|
|
285
|
+
{u.detail !== undefined && <span className={css.pickerUnitDetail}>{redact(u.detail)}</span>}
|
|
286
|
+
{u.sizeBytes > 0 && (
|
|
287
|
+
<span className={css.sectionSize + ' ' + css.mono}>{formatBytes(u.sizeBytes)}</span>
|
|
288
|
+
)}
|
|
289
|
+
</span>
|
|
290
|
+
}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 「插件与扩展」组里的四个插件分区合并渲染成的**一棵按插件分组的树**(用户要求:
|
|
298
|
+
* 不再用「按分区 / 按插件」视图切换 —— 树就是这一组的渲染方式)。
|
|
299
|
+
*
|
|
300
|
+
* 只显示**能证明**的归属(宿主下发的 relatedAreas = 内置来源表 + 插件自述 dsh.dataDirs);
|
|
301
|
+
* pluginFiles 没有任何包名信息,因此整块进「未归属」桶 —— 不猜,也不假装它属于某个插件。
|
|
302
|
+
* 「注册表说它应该在哪儿」与「磁盘上确实有」分开渲染:unread 不可勾(展开时才去读),
|
|
303
|
+
* absent 明确标「无内容」—— 否则会出现「勾了却没导出」的幽灵勾选(引擎只带走清单里真实存在的单元)。
|
|
304
|
+
*
|
|
305
|
+
* 纯展示:叶子仍是原来的单元 id,勾选仍走 toggleUnit / toggleSection(语义一字不改)。
|
|
306
|
+
*/
|
|
307
|
+
const renderPluginTree = (): ReactNode => {
|
|
308
|
+
/**
|
|
309
|
+
* 「插件」一级区块(用户要求:所有插件先收在一个可折叠的区块里,展开才列出插件)。
|
|
310
|
+
*
|
|
311
|
+
* 默认**折叠**;**搜索时自动展开** —— 否则搜索结果会藏在折叠节点里,看起来像「没有匹配」。
|
|
312
|
+
* 它的勾选框 = 所有插件名下**存在的**单元(逐个走 toggleUnit),与「未归属 / 遗留数据」桶无关:
|
|
313
|
+
* 那两个桶是「无法归属的东西」,不该被「全选所有插件」顺手带上。
|
|
314
|
+
*/
|
|
315
|
+
const rootKey = PLUGIN_ROOT_KEY
|
|
316
|
+
const rootOpen = expandedPlugins[rootKey] === true || query.trim() !== ''
|
|
317
|
+
const rootState = pluginTreePickState(value, tree)
|
|
318
|
+
const rootPicked = pluginTreeSelectedCount(value, tree)
|
|
319
|
+
const rootTotal = pluginTreeTotalCount(tree)
|
|
320
|
+
const rootSize = tree.entries.reduce((n, e) => n + e.leaves.reduce((m, l) => m + l.sizeBytes, 0), 0)
|
|
321
|
+
const rootUnread = pluginTreeUnreadSections(tree)
|
|
322
|
+
const body = (
|
|
323
|
+
<>
|
|
324
|
+
{!tree.pluginsLoaded && <span className={css.hint}>{t('picker.pluginViewEmpty')}</span>}
|
|
325
|
+
{tree.pluginsLoaded
|
|
326
|
+
&& tree.entries.length === 0
|
|
327
|
+
&& tree.buckets.every((b) => b.units.length === 0)
|
|
328
|
+
&& <span className={css.hint}>{t('picker.empty')}</span>}
|
|
329
|
+
{tree.entries.map((entry) => {
|
|
330
|
+
const open = expandedPlugins[entry.name] === true
|
|
331
|
+
const state = pluginEntryPickState(value, entry)
|
|
332
|
+
const picked = pluginEntrySelectedCount(value, entry)
|
|
333
|
+
// 分母用「已知」叶子(含未读取的区域):否则折叠/展开会给出两个数字(2026-10-17 用户实测)
|
|
334
|
+
const total = pluginEntryTotalCount(entry)
|
|
335
|
+
const unread = pluginEntryUnreadSections(entry)
|
|
336
|
+
const entrySize = entry.leaves.reduce((n, l) => n + l.sizeBytes, 0)
|
|
337
|
+
const bodyId = 'picker-plugin-' + entry.name
|
|
338
|
+
return (
|
|
339
|
+
<div key={entry.name} className={css.pickerGroup}>
|
|
340
|
+
<div className={css.pickerTreeRow}>
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
className={css.iconBtn}
|
|
344
|
+
data-size="sm"
|
|
345
|
+
aria-expanded={open}
|
|
346
|
+
aria-controls={bodyId}
|
|
347
|
+
aria-label={open ? t('picker.collapse') : t('picker.expand')}
|
|
348
|
+
title={open ? t('picker.collapse') : t('picker.expand')}
|
|
349
|
+
onClick={() => {
|
|
350
|
+
// 展开才去读:清单是惰性的,插件树同样不预读 pluginData / pluginAssets
|
|
351
|
+
if (!open) for (const s of unread) onRequestUnits?.(s)
|
|
352
|
+
setExpandedPlugins({ ...expandedPlugins, [entry.name]: !open })
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
<ExpandChevron open={open} />
|
|
356
|
+
</button>
|
|
357
|
+
<Checkbox
|
|
358
|
+
checked={state === 'all'}
|
|
359
|
+
onChange={(checked) => {
|
|
360
|
+
if (!checked) {
|
|
361
|
+
// 取消:撤掉队列里本插件的补勾请求(否则到货后又把它勾回来)
|
|
362
|
+
setPendingChecks((prev) => prev.filter((p) => p.key !== entry.name))
|
|
363
|
+
commit(togglePluginEntry(value, nodes, entry, false))
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
for (const s of unread) onRequestUnits?.(s)
|
|
367
|
+
if (unread.length > 0) {
|
|
368
|
+
setPendingChecks((prev) => (prev.some((p) => p.key === entry.name) ? prev : [...prev, { key: entry.name, kind: 'entry' }]))
|
|
369
|
+
}
|
|
370
|
+
commit(togglePluginEntry(value, nodes, entry, true))
|
|
371
|
+
}}
|
|
372
|
+
label={
|
|
373
|
+
<span className={css.categoryItem}>
|
|
374
|
+
{/* 包名是宿主直出文本 → 显示前过 redact(与单元行同一纪律) */}
|
|
375
|
+
<span className={css.pickerUnitName} title={redact(entry.name)}>
|
|
376
|
+
{tailWeightedEllipsis(redact(entry.name), UNIT_NAME_MAX)}
|
|
377
|
+
</span>
|
|
378
|
+
{state === 'partial' && <Badge kind="info">{t('picker.partial')}</Badge>}
|
|
379
|
+
<span className={css.pickerCount}>
|
|
380
|
+
{t('picker.selectedOf', { selected: String(picked), total: String(total) })}
|
|
381
|
+
</span>
|
|
382
|
+
{entrySize > 0 && <span className={css.sectionSize + ' ' + css.mono}>{formatBytes(entrySize)}</span>}
|
|
383
|
+
{unread.length > 0 && <span className={css.pickerCount}>{t('picker.pluginAreaUnread')}</span>}
|
|
384
|
+
</span>
|
|
385
|
+
}
|
|
386
|
+
/>
|
|
387
|
+
</div>
|
|
388
|
+
<Collapse open={open} id={bodyId}>
|
|
389
|
+
<div className={css.pickerChildren}>
|
|
390
|
+
{entry.leaves.map((leaf) => {
|
|
391
|
+
const kindText = leafKindText(leaf.kind)
|
|
392
|
+
const leafName = leaf.kind === 'plugin' ? kindText : kindText + ' · ' + redact(leaf.label)
|
|
393
|
+
if (leaf.state !== 'present') {
|
|
394
|
+
// 不可勾的两态:还没读 / 读了但没有内容(如实说明,绝不产生幽灵勾选)。
|
|
395
|
+
// **不给按钮**:展开插件(或一级区块)就是读取入口 —— 与其它分区行的惰性语义一致。
|
|
396
|
+
return (
|
|
397
|
+
<div key={leaf.unitId} className={css.pickerUnit + ' ' + css.pickerUnitNested}>
|
|
398
|
+
<span className={css.pickerChevronSpacer} aria-hidden="true" />
|
|
399
|
+
<span className={css.categoryItem}>
|
|
400
|
+
<span className={css.pickerUnitName} title={redact(leaf.label)}>{leafName}</span>
|
|
401
|
+
<span className={css.pickerCount}>
|
|
402
|
+
{leaf.state === 'unread' ? t('picker.pluginAreaUnread') : t('picker.pluginAreaAbsent')}
|
|
403
|
+
</span>
|
|
404
|
+
|
|
405
|
+
</span>
|
|
406
|
+
</div>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
return (
|
|
410
|
+
<div key={leaf.unitId} className={css.pickerUnit + ' ' + css.pickerUnitNested}>
|
|
411
|
+
<span className={css.pickerChevronSpacer} aria-hidden="true" />
|
|
412
|
+
<Checkbox
|
|
413
|
+
checked={unitChecked(value, leaf.section, leaf.unitId)}
|
|
414
|
+
onChange={(checked) => { commit(toggleUnit(value, nodes, leaf.unitId, checked)) }}
|
|
415
|
+
label={
|
|
416
|
+
<span className={css.categoryItem}>
|
|
417
|
+
<span className={css.pickerUnitName} title={redact(leaf.label)}>{leafName}</span>
|
|
418
|
+
{leaf.detail !== undefined && <span className={css.pickerUnitDetail}>{redact(leaf.detail)}</span>}
|
|
419
|
+
{leaf.sizeBytes > 0 && <span className={css.sectionSize + ' ' + css.mono}>{formatBytes(leaf.sizeBytes)}</span>}
|
|
420
|
+
</span>
|
|
421
|
+
}
|
|
422
|
+
/>
|
|
423
|
+
</div>
|
|
424
|
+
)
|
|
425
|
+
})}
|
|
426
|
+
</div>
|
|
427
|
+
</Collapse>
|
|
428
|
+
</div>
|
|
429
|
+
)
|
|
430
|
+
})}
|
|
431
|
+
</>
|
|
432
|
+
)
|
|
433
|
+
const bucketsBody = (
|
|
434
|
+
<>
|
|
435
|
+
{tree.buckets.map((bucket) => {
|
|
436
|
+
const node = nodes.find((n) => n.section === bucket.section)
|
|
437
|
+
if (node === undefined) return null
|
|
438
|
+
const key = 'bucket:' + bucket.section
|
|
439
|
+
const open = expandedPlugins[key] === true
|
|
440
|
+
const units = visibleUnits(bucket.units, showAllUnits[key] === true)
|
|
441
|
+
const bodyId = 'picker-bucket-' + bucket.section
|
|
442
|
+
return (
|
|
443
|
+
<div key={key} className={css.pickerGroup}>
|
|
444
|
+
<div className={css.pickerTreeRow}>
|
|
445
|
+
{bucket.units.length > 0 || !bucket.loaded ? (
|
|
446
|
+
<button
|
|
447
|
+
type="button"
|
|
448
|
+
className={css.iconBtn}
|
|
449
|
+
data-size="sm"
|
|
450
|
+
aria-expanded={open}
|
|
451
|
+
aria-controls={bodyId}
|
|
452
|
+
aria-label={open ? t('picker.collapse') : t('picker.expand')}
|
|
453
|
+
title={open ? t('picker.collapse') : t('picker.expand')}
|
|
454
|
+
onClick={() => {
|
|
455
|
+
// 展开即读(与其他分区行同一套惰性语义)—— 不再给「读取该分区」按钮
|
|
456
|
+
if (!open && !bucket.loaded) onRequestUnits?.(bucket.section)
|
|
457
|
+
setExpandedPlugins({ ...expandedPlugins, [key]: !open })
|
|
458
|
+
}}
|
|
459
|
+
>
|
|
460
|
+
<ExpandChevron open={open} />
|
|
461
|
+
</button>
|
|
462
|
+
) : (
|
|
463
|
+
<span className={css.pickerChevronSpacer} aria-hidden="true" />
|
|
464
|
+
)}
|
|
465
|
+
{/* 桶也是**整个分区**:勾选框与普通分区行同一套三态语义(toggleSection),
|
|
466
|
+
这样「整块带走/整块不带」不需要先展开逐条勾。 */}
|
|
467
|
+
<Checkbox
|
|
468
|
+
checked={sectionPickState(value, node) === 'all'}
|
|
469
|
+
onChange={(checked) => { commit(toggleSection(value, node, checked), focusOf(node.section)) }}
|
|
470
|
+
label={
|
|
471
|
+
<span className={css.categoryItem}>
|
|
472
|
+
<span className={css.pickerUnitName}>{bucketLabel(bucket.section)}</span>
|
|
473
|
+
{sectionPickState(value, node) === 'partial' && <Badge kind="info">{t('picker.partial')}</Badge>}
|
|
474
|
+
{node.portability === 'deviceSpecific' && <Badge kind="warn">{t('picker.deviceSpecific')}</Badge>}
|
|
475
|
+
{bucket.loaded && (
|
|
476
|
+
<span className={css.pickerCount}>
|
|
477
|
+
{t('picker.selectedOf', {
|
|
478
|
+
selected: String(selectedUnitCount(value, node)),
|
|
479
|
+
total: String(node.units.length === 0 ? node.count : node.units.length),
|
|
480
|
+
})}
|
|
481
|
+
</span>
|
|
482
|
+
)}
|
|
483
|
+
{/* 未读时不给「读取该分区」按钮:展开这一行就是读取入口(与其他分区行同一套惰性语义) */}
|
|
484
|
+
{bucket.loaded && <InfoHint text={t('picker.bucketHint')} t={t} />}
|
|
485
|
+
</span>
|
|
486
|
+
}
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
<Collapse open={open} id={bodyId}>
|
|
490
|
+
<div className={css.pickerChildren}>
|
|
491
|
+
{units.shown.map((u) => renderUnitRow(node, u, true))}
|
|
492
|
+
{units.hidden > 0 && (
|
|
493
|
+
<div className={css.pickerMore}>
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
className={css.ghostButton}
|
|
497
|
+
data-size="sm"
|
|
498
|
+
onClick={() => { setShowAllUnits({ ...showAllUnits, [key]: true }) }}
|
|
499
|
+
>
|
|
500
|
+
{t('picker.showAll', { count: String(bucket.units.length) })}
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
</Collapse>
|
|
506
|
+
</div>
|
|
507
|
+
)
|
|
508
|
+
})}
|
|
509
|
+
</>
|
|
510
|
+
)
|
|
511
|
+
return (
|
|
512
|
+
<>
|
|
513
|
+
<div className={css.pickerGroup}>
|
|
514
|
+
<div className={css.pickerTreeRow}>
|
|
515
|
+
<button
|
|
516
|
+
type="button"
|
|
517
|
+
className={css.iconBtn}
|
|
518
|
+
data-size="sm"
|
|
519
|
+
aria-expanded={rootOpen}
|
|
520
|
+
aria-controls="picker-plugin-root"
|
|
521
|
+
aria-label={rootOpen ? t('picker.collapse') : t('picker.expand')}
|
|
522
|
+
title={rootOpen ? t('picker.collapse') : t('picker.expand')}
|
|
523
|
+
onClick={() => {
|
|
524
|
+
// 展开才去读:清单是惰性的,插件树同样不预读 pluginData / pluginAssets
|
|
525
|
+
if (!rootOpen) for (const s of rootUnread) onRequestUnits?.(s)
|
|
526
|
+
setExpandedPlugins({ ...expandedPlugins, [rootKey]: !rootOpen })
|
|
527
|
+
}}
|
|
528
|
+
>
|
|
529
|
+
<ExpandChevron open={rootOpen} />
|
|
530
|
+
</button>
|
|
531
|
+
<Checkbox
|
|
532
|
+
checked={rootState === 'all'}
|
|
533
|
+
onChange={(checked) => {
|
|
534
|
+
if (!checked) {
|
|
535
|
+
// 取消:把队列里属于本节点的请求一并撤掉,免得到货后又把它勾回来
|
|
536
|
+
setPendingChecks((prev) => prev.filter((p) => p.kind !== 'root'))
|
|
537
|
+
commit(togglePluginTree(value, nodes, tree, false))
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
for (const s of rootUnread) onRequestUnits?.(s)
|
|
541
|
+
if (!tree.pluginsLoaded || tree.entries.some((e) => pluginEntryUnreadSections(e).length > 0)) {
|
|
542
|
+
setPendingChecks((prev) => (prev.some((p) => p.kind === 'root') ? prev : [...prev, { key: rootKey, kind: 'root' }]))
|
|
543
|
+
}
|
|
544
|
+
commit(togglePluginTree(value, nodes, tree, true))
|
|
545
|
+
}}
|
|
546
|
+
label={
|
|
547
|
+
<span className={css.categoryItem}>
|
|
548
|
+
<span className={css.categoryName}>{t('picker.pluginRoot')}</span>
|
|
549
|
+
{rootState === 'partial' && <Badge kind="info">{t('picker.partial')}</Badge>}
|
|
550
|
+
<span className={css.pickerCount}>
|
|
551
|
+
{t('picker.selectedOf', { selected: String(rootPicked), total: String(rootTotal) })}
|
|
552
|
+
</span>
|
|
553
|
+
{rootSize > 0 && <span className={css.sectionSize + ' ' + css.mono}>{formatBytes(rootSize)}</span>}
|
|
554
|
+
{rootUnread.length > 0 && <span className={css.pickerCount}>{t('picker.pluginAreaUnread')}</span>}
|
|
555
|
+
</span>
|
|
556
|
+
}
|
|
557
|
+
/>
|
|
558
|
+
</div>
|
|
559
|
+
<Collapse open={rootOpen} id="picker-plugin-root">
|
|
560
|
+
<div className={css.pickerChildren}>{body}</div>
|
|
561
|
+
</Collapse>
|
|
562
|
+
</div>
|
|
563
|
+
{bucketsBody}
|
|
564
|
+
</>
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
|
|
155
568
|
return (
|
|
156
569
|
<div className={css.pickerRoot}>
|
|
157
570
|
<div className={css.pickerToolbar}>
|
|
@@ -176,7 +589,24 @@ export function ContentPicker({
|
|
|
176
589
|
|
|
177
590
|
{visible.length > 0 && (
|
|
178
591
|
<div className={css.pickerList}>
|
|
179
|
-
{visible.map((node) => {
|
|
592
|
+
{visible.map((node, nodeIndex) => {
|
|
593
|
+
/**
|
|
594
|
+
* 分组标题:只在**分组变化处**插一行(顺序 = 调用方给的顺序,UI 不重排)。
|
|
595
|
+
* 没有 group 的调用方(导入向导 / 市场)整段不渲染 —— 行为逐像素不变。
|
|
596
|
+
* 标题不是选项(DESIGN.md:更小更淡、不可选中、非首组起分隔线),
|
|
597
|
+
* 因此这里用 span 而不是 Checkbox —— 免得被读成「灰掉的选项」。
|
|
598
|
+
*/
|
|
599
|
+
const groupTitle = node.group !== undefined
|
|
600
|
+
&& (nodeIndex === 0 || visible[nodeIndex - 1]?.group !== node.group)
|
|
601
|
+
? node.group
|
|
602
|
+
: undefined
|
|
603
|
+
/**
|
|
604
|
+
* 「插件与扩展」组里的四个插件分区(plugins / pluginFiles / pluginData / pluginAssets)
|
|
605
|
+
* 合并成一棵按插件分组的树:只在其中**第一个出现**的分区处渲染一次(见 renderPluginTree),
|
|
606
|
+
* 其余成员就地跳过 —— 顺序仍由调用方给的节点顺序决定,不引入第二个排序来源。
|
|
607
|
+
* `self`(配置管理器设置)不在那四项里,照旧按普通分区行渲染。
|
|
608
|
+
*/
|
|
609
|
+
if (isPluginTreeSection(node.section) && node.section !== firstTreeSection) return null
|
|
180
610
|
const state = sectionPickState(value, node)
|
|
181
611
|
const open = expanded[node.section] === true
|
|
182
612
|
const picked = selectedUnitCount(value, node)
|
|
@@ -196,180 +626,156 @@ export function ContentPicker({
|
|
|
196
626
|
/** 本帧要渲染的单元(默认前 N 条;UI-15) */
|
|
197
627
|
const units = visibleUnits(node.units, showAllUnits[node.section] === true)
|
|
198
628
|
return (
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
{
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
629
|
+
<Fragment key={node.section}>
|
|
630
|
+
{groupTitle !== undefined && (
|
|
631
|
+
<div className={css.pickerGroupTitle}>
|
|
632
|
+
<span className={css.pickerGroupTitleName}>{t(GROUP_TITLE_KEY[groupTitle])}</span>
|
|
633
|
+
{groupTitle === 'extensions' && <InfoHint text={t('picker.groupHint.extensions')} t={t} />}
|
|
634
|
+
</div>
|
|
635
|
+
)}
|
|
636
|
+
{node.section === firstTreeSection ? renderPluginTree() : (
|
|
637
|
+
<div className={css.pickerGroup}>
|
|
638
|
+
<div className={css.pickerTreeRow}>
|
|
639
|
+
{/* 级联树:展开控件在选择框**左边**(点它只展开本节点的子树,不展开别人的)。
|
|
640
|
+
用原生 button(共享 Button 原语不透传 aria-* 属性,UI-22);图标走**形变层** ——
|
|
641
|
+
展开态切换本身携带信息,让「变了」可见(common/Icon.tsx 的 ExpandChevron)。 */}
|
|
642
|
+
{expandable ? (
|
|
643
|
+
<button
|
|
644
|
+
type="button"
|
|
645
|
+
className={css.iconBtn}
|
|
646
|
+
data-size="sm"
|
|
647
|
+
aria-expanded={open}
|
|
648
|
+
aria-controls={unitsId(node.section)}
|
|
649
|
+
aria-label={open ? t('picker.collapse') : t('picker.expand')}
|
|
650
|
+
title={open ? t('picker.collapse') : t('picker.expand')}
|
|
651
|
+
onClick={onToggleExpand}
|
|
652
|
+
>
|
|
653
|
+
<ExpandChevron open={open} />
|
|
654
|
+
</button>
|
|
655
|
+
) : (
|
|
656
|
+
<span className={css.pickerChevronSpacer} aria-hidden="true" />
|
|
657
|
+
)}
|
|
658
|
+
<Checkbox
|
|
659
|
+
checked={state === 'all'}
|
|
660
|
+
onChange={(checked) => { commit(toggleSection(value, node, checked), focusOf(node.section)) }}
|
|
661
|
+
label={
|
|
662
|
+
<span className={css.categoryItem}>
|
|
663
|
+
<span className={css.categoryName}>{label(node.section)}</span>
|
|
664
|
+
{state === 'partial' && <Badge kind="info">{t('picker.partial')}</Badge>}
|
|
665
|
+
{highRisk?.(node.section) === true && (
|
|
666
|
+
<span title={t('picker.highRiskHint')}>
|
|
667
|
+
<Badge kind="warn">{t('picker.highRisk')}</Badge>
|
|
668
|
+
</span>
|
|
669
|
+
)}
|
|
670
|
+
{/* 设备相关 / 敏感分区(UI-09):数据来自调用方的选择模型,
|
|
671
|
+
「全选」不再静默把 sessions / credentialsStatus 一起勾上 */}
|
|
672
|
+
{node.portability === 'deviceSpecific' && (
|
|
673
|
+
<Badge kind="warn">{t('picker.deviceSpecific')}</Badge>
|
|
674
|
+
)}
|
|
675
|
+
{node.sensitive === true && <Badge kind="warn">{t('picker.sensitive')}</Badge>}
|
|
676
|
+
{/* 三态:读取中 / 读取失败 / 已选 n/m。
|
|
677
|
+
没读到清单(在途或失败)时**不要**显示 0/0 —— 那会被读成
|
|
678
|
+
「这一项没有内容」;失败态必须显式说明「将整体导出」(UI-07)。 */}
|
|
679
|
+
{countState === 'pending' ? (
|
|
680
|
+
<span className={css.pickerCount}>{t('picker.loadingSection')}</span>
|
|
681
|
+
) : countState === 'failed' ? (
|
|
682
|
+
/* 失败态用 warn 语义色(DESIGN.md「状态即语义」):中性的 .pickerCount
|
|
683
|
+
会让「读取失败」读起来像一条普通说明,而不是需要用户注意的状态 */
|
|
684
|
+
<span className={`${css.pickerCount} ${css.warnText}`}>{t('picker.sectionLoadFailed')}</span>
|
|
685
|
+
) : countState === 'count' ? (
|
|
686
|
+
<span className={css.pickerCount}>
|
|
687
|
+
{t('picker.selectedOf', { selected: String(picked), total: String(total) })}
|
|
688
|
+
</span>
|
|
689
|
+
) : (
|
|
690
|
+
/* 未读(惰性):**不渲染 0/0** —— 「已选 0/0」会被读成「这条分区没有内容」,
|
|
691
|
+
而事实是「还没去读」。数字等展开(= 触发读取)之后再给。 */
|
|
692
|
+
null
|
|
693
|
+
)}
|
|
694
|
+
{node.sizeBytes > 0 && (
|
|
695
|
+
<span className={`${css.sectionSize} ${css.mono}`}>{formatBytes(node.sizeBytes)}</span>
|
|
696
|
+
)}
|
|
697
|
+
</span>
|
|
698
|
+
}
|
|
699
|
+
/>
|
|
700
|
+
</div>
|
|
701
|
+
{/* 展开/收起两端都有高度动画(common/Motion.tsx 的 Collapse);
|
|
702
|
+
容器**始终存在**,所以 aria-controls 恒有值。 */}
|
|
703
|
+
<Collapse open={open} id={unitsId(node.section)}>
|
|
704
|
+
<div className={css.pickerChildren}>
|
|
705
|
+
{/* 二级分组(sessions 按工作区聚合):没有 group 的单元仍走平铺,
|
|
706
|
+
与改造前的渲染完全一致(skills / pluginFiles 等分区零变化)。
|
|
707
|
+
级联树语义:展开分区只列出分组,点某个分组的 chevron 才展开那一棵子树
|
|
708
|
+
(默认全部折叠,不会一次铺开全部会话)。 */}
|
|
709
|
+
{groupUnits(units.shown).map((group, gi) => {
|
|
710
|
+
// 分组三态与计数都必须带上所属分区 —— 分区整体没勾时,它下面的分组/单元一律显示未勾选
|
|
711
|
+
const groupState = groupPickState(value, group, node.section)
|
|
712
|
+
const groupPicked = groupSelectedCount(value, group, node.section)
|
|
713
|
+
const groupKey = node.section + ':' + (group.label ?? '__flat')
|
|
714
|
+
const groupOpen = group.label === null || expandedGroups[groupKey] === true
|
|
715
|
+
const groupBodyId = 'picker-units-' + node.section + '-g' + String(gi)
|
|
284
716
|
return (
|
|
285
|
-
<div key={
|
|
286
|
-
{
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
) :
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
717
|
+
<div key={groupKey}>
|
|
718
|
+
{group.label !== null && (
|
|
719
|
+
<div className={css.pickerTreeRow + ' ' + css.pickerSubgroup}>
|
|
720
|
+
{/* 级联树:只展开这一棵子树(与分区行同一套 chevron 原语与 aria 约定) */}
|
|
721
|
+
<button
|
|
722
|
+
type="button"
|
|
723
|
+
className={css.iconBtn}
|
|
724
|
+
data-size="sm"
|
|
725
|
+
aria-expanded={groupOpen}
|
|
726
|
+
aria-controls={groupBodyId}
|
|
727
|
+
aria-label={groupOpen ? t('picker.collapse') : t('picker.expand')}
|
|
728
|
+
title={groupOpen ? t('picker.collapse') : t('picker.expand')}
|
|
729
|
+
onClick={() => { setExpandedGroups({ ...expandedGroups, [groupKey]: !groupOpen }) }}
|
|
730
|
+
>
|
|
731
|
+
<ExpandChevron open={groupOpen} />
|
|
732
|
+
</button>
|
|
733
|
+
<Checkbox
|
|
734
|
+
checked={groupState === 'all'}
|
|
735
|
+
onChange={(checked) => { commit(toggleUnitGroup(value, node, group, checked), focusOf(node.section)) }}
|
|
736
|
+
label={
|
|
737
|
+
<span className={css.categoryItem}>
|
|
738
|
+
{/* 分组名是宿主直出字符串(工作区标题)→ 渲染前过 redact */}
|
|
739
|
+
<span className={css.pickerSubgroupName} title={redact(group.label)}>{redact(group.label)}</span>
|
|
740
|
+
{groupState === 'partial' && <Badge kind="info">{t('picker.partial')}</Badge>}
|
|
741
|
+
<span className={css.pickerCount}>
|
|
742
|
+
{t('picker.selectedOf', { selected: String(groupPicked), total: String(group.units.length) })}
|
|
743
|
+
</span>
|
|
744
|
+
</span>
|
|
745
|
+
}
|
|
746
|
+
/>
|
|
747
|
+
</div>
|
|
748
|
+
)}
|
|
749
|
+
{group.label === null
|
|
750
|
+
? group.units.map((u) => renderUnitRow(node, u, true))
|
|
751
|
+
: (
|
|
752
|
+
<Collapse open={groupOpen} id={groupBodyId}>
|
|
753
|
+
<div className={css.pickerChildren}>
|
|
754
|
+
{group.units.map((u) => renderUnitRow(node, u, true))}
|
|
755
|
+
</div>
|
|
756
|
+
</Collapse>
|
|
757
|
+
)}
|
|
316
758
|
</div>
|
|
317
759
|
)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
aria-label={groupOpen ? t('picker.collapse') : t('picker.expand')}
|
|
331
|
-
title={groupOpen ? t('picker.collapse') : t('picker.expand')}
|
|
332
|
-
onClick={() => { setExpandedGroups({ ...expandedGroups, [groupKey]: !groupOpen }) }}
|
|
333
|
-
>
|
|
334
|
-
<ExpandChevron open={groupOpen} />
|
|
335
|
-
</button>
|
|
336
|
-
<Checkbox
|
|
337
|
-
checked={groupState === 'all'}
|
|
338
|
-
onChange={(checked) => { commit(toggleUnitGroup(value, node, group, checked), focusOf(node.section)) }}
|
|
339
|
-
label={
|
|
340
|
-
<span className={css.categoryItem}>
|
|
341
|
-
{/* 分组名是宿主直出字符串(工作区标题)→ 渲染前过 redact */}
|
|
342
|
-
<span className={css.pickerSubgroupName} title={redact(group.label)}>{redact(group.label)}</span>
|
|
343
|
-
{groupState === 'partial' && <Badge kind="info">{t('picker.partial')}</Badge>}
|
|
344
|
-
<span className={css.pickerCount}>
|
|
345
|
-
{t('picker.selectedOf', { selected: String(groupPicked), total: String(group.units.length) })}
|
|
346
|
-
</span>
|
|
347
|
-
</span>
|
|
348
|
-
}
|
|
349
|
-
/>
|
|
350
|
-
</div>
|
|
351
|
-
)}
|
|
352
|
-
{group.label === null
|
|
353
|
-
? group.units.map(renderUnit)
|
|
354
|
-
: <Collapse open={groupOpen} id={groupBodyId}>{group.units.map(renderUnit)}</Collapse>}
|
|
760
|
+
})}
|
|
761
|
+
{/* 渐进披露(UI-15):默认前 N 条 + 「显示全部(共 M 条)」,纯前端方案 */}
|
|
762
|
+
{units.hidden > 0 && (
|
|
763
|
+
<div className={css.pickerMore}>
|
|
764
|
+
<button
|
|
765
|
+
type="button"
|
|
766
|
+
className={css.ghostButton}
|
|
767
|
+
data-size="sm"
|
|
768
|
+
onClick={() => { setShowAllUnits({ ...showAllUnits, [node.section]: true }) }}
|
|
769
|
+
>
|
|
770
|
+
{t('picker.showAll', { count: String(node.units.length) })}
|
|
771
|
+
</button>
|
|
355
772
|
</div>
|
|
356
|
-
)
|
|
357
|
-
})}
|
|
358
|
-
{/* 渐进披露(UI-15):默认前 N 条 + 「显示全部(共 M 条)」,纯前端方案 */}
|
|
359
|
-
{units.hidden > 0 && (
|
|
360
|
-
<div className={css.pickerMore}>
|
|
361
|
-
<button
|
|
362
|
-
type="button"
|
|
363
|
-
className={css.ghostButton}
|
|
364
|
-
data-size="sm"
|
|
365
|
-
onClick={() => { setShowAllUnits({ ...showAllUnits, [node.section]: true }) }}
|
|
366
|
-
>
|
|
367
|
-
{t('picker.showAll', { count: String(node.units.length) })}
|
|
368
|
-
</button>
|
|
773
|
+
)}
|
|
369
774
|
</div>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
775
|
+
</Collapse>
|
|
776
|
+
</div>
|
|
777
|
+
)}
|
|
778
|
+
</Fragment>
|
|
373
779
|
)
|
|
374
780
|
})}
|
|
375
781
|
</div>
|