@c2-markdown-ui/vue 0.0.0-stage → 0.4.4

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 (61) hide show
  1. package/README.md +112 -2
  2. package/dist/MarkdownUI.vue.d.ts +15 -0
  3. package/dist/favicon.ico +0 -0
  4. package/dist/index.css +1 -0
  5. package/dist/index.d.ts +23 -0
  6. package/dist/index.js +10843 -0
  7. package/dist/index.umd.cjs +53 -0
  8. package/dist/widgets/Button.vue.d.ts +19 -0
  9. package/dist/widgets/ButtonGroup.vue.d.ts +13 -0
  10. package/dist/widgets/Cascader.vue.d.ts +48 -0
  11. package/dist/widgets/Chart.vue.d.ts +24 -0
  12. package/dist/widgets/DatePicker.vue.d.ts +42 -0
  13. package/dist/widgets/Form.vue.d.ts +59 -0
  14. package/dist/widgets/Incomplete.vue.d.ts +2 -0
  15. package/dist/widgets/MultipleChoiceQuestion.vue.d.ts +20 -0
  16. package/dist/widgets/NumberInput.vue.d.ts +37 -0
  17. package/dist/widgets/Quiz.vue.d.ts +39 -0
  18. package/dist/widgets/RadioGroup.vue.d.ts +43 -0
  19. package/dist/widgets/Select.vue.d.ts +27 -0
  20. package/dist/widgets/SelectMulti.vue.d.ts +38 -0
  21. package/dist/widgets/ShortAnswerQuestion.vue.d.ts +22 -0
  22. package/dist/widgets/Slider.vue.d.ts +21 -0
  23. package/dist/widgets/Switch.vue.d.ts +28 -0
  24. package/dist/widgets/Text.vue.d.ts +22 -0
  25. package/dist/widgets/TextInput.vue.d.ts +32 -0
  26. package/dist/widgets/TreeSelect.vue.d.ts +109 -0
  27. package/dist/widgets/Widget.vue.d.ts +8 -0
  28. package/dist/widgets/chartRemoteData.d.ts +17 -0
  29. package/dist/widgets/index.d.ts +20 -0
  30. package/dist/widgets/remoteChoices.d.ts +22 -0
  31. package/dist/widgets/treeSelectTypes.d.ts +43 -0
  32. package/package.json +46 -4
  33. package/src/App.vue +11 -0
  34. package/src/lib/MarkdownUI.vue +105 -0
  35. package/src/lib/index.ts +28 -0
  36. package/src/lib/widgets/Button.vue +47 -0
  37. package/src/lib/widgets/ButtonGroup.vue +43 -0
  38. package/src/lib/widgets/Cascader.vue +475 -0
  39. package/src/lib/widgets/Chart.vue +287 -0
  40. package/src/lib/widgets/DatePicker.vue +659 -0
  41. package/src/lib/widgets/Form.vue +479 -0
  42. package/src/lib/widgets/Incomplete.vue +56 -0
  43. package/src/lib/widgets/MultipleChoiceQuestion.vue +73 -0
  44. package/src/lib/widgets/NumberInput.vue +179 -0
  45. package/src/lib/widgets/Quiz.vue +205 -0
  46. package/src/lib/widgets/RadioGroup.vue +237 -0
  47. package/src/lib/widgets/Select.vue +279 -0
  48. package/src/lib/widgets/SelectMulti.vue +145 -0
  49. package/src/lib/widgets/ShortAnswerQuestion.vue +79 -0
  50. package/src/lib/widgets/Slider.vue +61 -0
  51. package/src/lib/widgets/Switch.vue +68 -0
  52. package/src/lib/widgets/Text.vue +59 -0
  53. package/src/lib/widgets/TextInput.vue +83 -0
  54. package/src/lib/widgets/TreeSelect.vue +872 -0
  55. package/src/lib/widgets/Widget.vue +185 -0
  56. package/src/lib/widgets/chartRemoteData.ts +84 -0
  57. package/src/lib/widgets/index.ts +20 -0
  58. package/src/lib/widgets/remoteChoices.ts +64 -0
  59. package/src/lib/widgets/treeSelectTypes.ts +49 -0
  60. package/src/lib/widgets.css +1754 -0
  61. package/src/main.ts +4 -0
@@ -0,0 +1,872 @@
1
+ <template>
2
+ <div class="selector widget-tree-select" ref="rootEl">
3
+ <div v-if="label || hint || required" class="widget-label-row">
4
+ <span v-if="required" class="widget-required-mark" aria-hidden="true">*</span>
5
+ <label v-if="label">{{ label }}</label>
6
+ <span v-if="hint" class="widget-hint" :data-hint="hint">ⓘ</span>
7
+ </div>
8
+ <div class="widget-field-content">
9
+ <!-- 触发按钮 -->
10
+ <button
11
+ type="button"
12
+ class="ts-input"
13
+ :class="{ 'widget-input-error': !!error }"
14
+ :disabled="disabled"
15
+ :aria-expanded="open"
16
+ :data-full="allTagsText"
17
+ @click="toggleOpen"
18
+ >
19
+ <!-- 多选 tag 显示(按回显策略过滤 + maxTagCount 截断) -->
20
+ <template v-if="multiple && selected.length > 0">
21
+ <span class="ts-tag" v-for="v in displayTags" :key="v.value">
22
+ {{ v.label }}
23
+ </span>
24
+ <span v-if="strategyValues.length > tagLimit" class="ts-tag ts-tag-more">(+{{ strategyValues.length - tagLimit }})</span>
25
+ </template>
26
+ <span
27
+ v-else
28
+ class="ts-input-text"
29
+ :class="{ 'ts-placeholder': selected.length === 0 }"
30
+ >
31
+ {{ selectedLabel || placeholder || '请选择' }}
32
+ </span>
33
+ <span
34
+ v-if="!disabled && selected.length > 0"
35
+ class="ts-clear"
36
+ role="button"
37
+ tabindex="-1"
38
+ aria-label="清空"
39
+ @click.stop="clearValue"
40
+ >×</span>
41
+ <span v-else-if="!disabled" class="ts-arrow" :class="{ 'ts-arrow-open': open }" aria-hidden="true">▾</span>
42
+ </button>
43
+ <div v-if="error" class="widget-error-text" role="alert">{{ error }}</div>
44
+
45
+ <!-- 下拉面板 -->
46
+ <div v-if="open" class="ts-panel">
47
+ <!-- 搜索框 -->
48
+ <div v-if="searchable" class="ts-search">
49
+ <input
50
+ ref="searchInputEl"
51
+ v-model="query"
52
+ type="text"
53
+ class="ts-search-input"
54
+ placeholder="搜索…"
55
+ @keydown.esc="open = false"
56
+ />
57
+ </div>
58
+ <!-- 工具栏 -->
59
+ <div class="ts-toolbar">
60
+ <button type="button" class="ts-tool-btn" @click="expandAll">展开全部</button>
61
+ <button type="button" class="ts-tool-btn" @click="collapseAll">折叠全部</button>
62
+ </div>
63
+ <!-- 树形节点列表 -->
64
+ <div class="ts-tree" role="tree">
65
+ <template v-if="visibleNodes.length > 0">
66
+ <TreeNode
67
+ v-for="node in visibleNodes"
68
+ :key="node.value"
69
+ :node="node"
70
+ :depth="node.depth"
71
+ :multiple="multiple"
72
+ :checked="isNodeChecked(node)"
73
+ :indeterminate="isNodeIndeterminate(node)"
74
+ :treeLine="treeLine"
75
+ :lazy="lazy"
76
+ :url="url"
77
+ :remoteData="remoteData"
78
+ @toggle="handleToggle"
79
+ @check="handleCheck"
80
+ @load="handleLoad"
81
+ />
82
+ </template>
83
+ <div v-else class="ts-empty">{{ loading ? '加载中…' : '暂无数据' }}</div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </template>
89
+
90
+ <script setup lang="ts">
91
+ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
92
+ import type { TreeNodeRaw, TreeSelectNode as TsNode, TreeSelectProps } from './treeSelectTypes'
93
+
94
+ type Node = TsNode
95
+
96
+ // ---------- 类型 ----------
97
+
98
+ const props = withDefaults(defineProps<TreeSelectProps>(), {
99
+ choices: () => [],
100
+ data: undefined,
101
+ url: '',
102
+ lazy: false,
103
+ multiple: false,
104
+ checkStrictly: false,
105
+ showCheckedStrategy: 'all',
106
+ maxTagCount: undefined,
107
+ treeLine: false,
108
+ label: '',
109
+ default: '',
110
+ placeholder: '',
111
+ hint: '',
112
+ required: false,
113
+ message: '',
114
+ error: '',
115
+ disabled: false,
116
+ searchable: false
117
+ })
118
+
119
+ const emit = defineEmits<{
120
+ change: [value: string | string[]]
121
+ }>()
122
+
123
+ // ---------- 数据转换 ----------
124
+ function parseSeg(seg: string) {
125
+ const idx = seg.indexOf(':')
126
+ if (idx > 0) {
127
+ const v = seg.slice(0, idx).trim()
128
+ const l = seg.slice(idx + 1).trim()
129
+ if (v && l) return { value: v, label: l }
130
+ }
131
+ return { value: seg.trim(), label: seg.trim() }
132
+ }
133
+
134
+ function rawToTree(nodes: TreeNodeRaw[] | undefined, depth = 0): Node[] {
135
+ if (!Array.isArray(nodes)) return []
136
+ return nodes.map(n => {
137
+ const v = n.value ?? n.key
138
+ const children = n.children
139
+ const hasChildren = Array.isArray(children) && children.length > 0
140
+ const wasLoaded = n.loaded === true
141
+ return {
142
+ value: String(v ?? ''),
143
+ label: String(n.label ?? v ?? ''),
144
+ children: rawToTree(children, depth + 1),
145
+ loaded: hasChildren || wasLoaded,
146
+ depth
147
+ }
148
+ })
149
+ }
150
+
151
+ function buildTreeFromChoices(choices: string[]): Node[] {
152
+ const root: Node[] = []
153
+ for (const path of choices) {
154
+ if (!path) continue
155
+ const segs = path.split('/').map(s => s.trim()).filter(Boolean)
156
+ if (segs.length === 0) continue
157
+ let level = root
158
+ for (let i = 0; i < segs.length; i++) {
159
+ const { value, label } = parseSeg(segs[i])
160
+ let node = level.find(n => n.value === value)
161
+ if (!node) {
162
+ node = { value, label, children: [], loaded: false, depth: i }
163
+ level.push(node)
164
+ }
165
+ level = node.children
166
+ }
167
+ }
168
+ return root
169
+ }
170
+
171
+ function normalizeRemoteTree(json: any): TreeNodeRaw[] {
172
+ let list = json
173
+ if (!Array.isArray(json)) {
174
+ list = Array.isArray(json?.data) ? json.data
175
+ : Array.isArray(json?.options) ? json.options
176
+ : Array.isArray(json?.choices) ? json.choices
177
+ : Array.isArray(json?.items) ? json.items
178
+ : Array.isArray(json?.tree) ? json.tree
179
+ : Array.isArray(json?.treeData) ? json.treeData
180
+ : []
181
+ }
182
+ const map = (arr: any[]): TreeNodeRaw[] =>
183
+ (arr || [])
184
+ .map((it: any): TreeNodeRaw | null => {
185
+ if (!it || typeof it !== 'object') return null
186
+ const value = it.value ?? it.key
187
+ if (value === undefined || value === null || value === '') return null
188
+ const node: TreeNodeRaw = { value: String(value) }
189
+ const label = it.label ?? it.name ?? it.text
190
+ if (label !== undefined && label !== null) node.label = String(label)
191
+ if (Array.isArray(it.children) && it.children.length > 0) {
192
+ node.children = map(it.children)
193
+ }
194
+ return node
195
+ })
196
+ .filter((n: TreeNodeRaw | null): n is TreeNodeRaw => n !== null)
197
+ return map(list)
198
+ }
199
+
200
+ // ---------- 状态 ----------
201
+ const remoteData = ref<TreeNodeRaw[] | null>(null)
202
+ const loading = ref(false)
203
+ const expanded = ref<Set<string>>(new Set())
204
+ const selected = ref<string[]>([])
205
+ const query = ref('')
206
+ const open = ref(false)
207
+ const searchInputEl = ref<HTMLInputElement | null>(null)
208
+ const rootEl = ref<HTMLElement | null>(null)
209
+
210
+ // ---------- 树数据源 ----------
211
+ const baseTree = computed<Node[]>(() => {
212
+ if (remoteData.value) return rawToTree(remoteData.value)
213
+ if (Array.isArray(props.data) && props.data.length > 0) return rawToTree(props.data)
214
+ return buildTreeFromChoices(props.choices)
215
+ })
216
+
217
+ const searchTree = computed<Node[]>(() => {
218
+ if (!query.value.trim()) return baseTree.value
219
+ const q = query.value.toLowerCase()
220
+ return pruneTree(baseTree.value, q)
221
+ })
222
+
223
+ function pruneTree(nodes: Node[], q: string): Node[] {
224
+ const result: Node[] = []
225
+ for (const node of nodes) {
226
+ const matchLabel = node.label.toLowerCase().includes(q)
227
+ const childMatch = pruneTree(node.children, q)
228
+ if (matchLabel || childMatch.length > 0) {
229
+ result.push({
230
+ ...node,
231
+ children: childMatch.length > 0 ? childMatch : node.children,
232
+ loaded: childMatch.length > 0 ? true : node.loaded
233
+ })
234
+ }
235
+ }
236
+ return result
237
+ }
238
+
239
+ // ---------- 可见节点扁平列表 ----------
240
+ const visibleNodes = computed<Node[]>(() => {
241
+ const nodes: Node[] = []
242
+ function walk(list: Node[]) {
243
+ for (const node of list) {
244
+ nodes.push(node)
245
+ if (expanded.value.has(node.value) && node.children.length > 0) {
246
+ walk(node.children)
247
+ }
248
+ }
249
+ }
250
+ walk(searchTree.value)
251
+ return nodes
252
+ })
253
+
254
+ // ---------- 展示文案 ----------
255
+ const selectedLabel = computed(() => {
256
+ const v = selected.value[0]
257
+ if (!v) return ''
258
+ function find(n: Node[]): string {
259
+ for (const node of n) {
260
+ if (node.value === v) return node.label
261
+ const f = find(node.children)
262
+ if (f) return f
263
+ }
264
+ return ''
265
+ }
266
+ return find(baseTree.value)
267
+ })
268
+
269
+ const displayTags = computed<{ value: string; label: string }[]>(() => {
270
+ return strategyValues.value.slice(0, tagLimit.value).map(v => ({
271
+ value: v,
272
+ label: findLabel(v)
273
+ }))
274
+ })
275
+
276
+ function findLabel(v: string): string {
277
+ function find(n: Node[]): string {
278
+ for (const node of n) {
279
+ if (node.value === v) return node.label
280
+ const f = find(node.children)
281
+ if (f) return f
282
+ }
283
+ return v
284
+ }
285
+ return find(baseTree.value)
286
+ }
287
+
288
+ function findNode(value: string, nodes: Node[] = baseTree.value): Node | undefined {
289
+ for (const n of nodes) {
290
+ if (n.value === value) return n
291
+ const found = findNode(value, n.children)
292
+ if (found) return found
293
+ }
294
+ }
295
+
296
+ /** 触发器最多显示的标签数(antd maxTagCount),不传则不限制 */
297
+ const tagLimit = computed<number>(() => {
298
+ return typeof props.maxTagCount === 'number' && props.maxTagCount >= 0 ? props.maxTagCount : Infinity
299
+ })
300
+
301
+ /** 全部已选项文本(策略过滤后),悬浮提示展示用 */
302
+ const allTagsText = computed<string>(() => {
303
+ if (!props.multiple || selected.value.length === 0) return ''
304
+ return strategyValues.value.map(v => findLabel(v)).join(', ')
305
+ })
306
+
307
+ /** 按回显策略(antd showCheckedStrategy)过滤后的选中值:all 全部 / parent 仅顶层父级 / child 仅叶子 */
308
+ const strategyValues = computed<string[]>(() => {
309
+ const values = selected.value
310
+ if (!props.multiple || props.checkStrictly || props.showCheckedStrategy === 'all') return values
311
+ const set = new Set(values)
312
+ if (props.showCheckedStrategy === 'child') {
313
+ return values.filter(v => {
314
+ const n = findNode(v)
315
+ return !n || n.children.length === 0
316
+ })
317
+ }
318
+ // parent:级联模式下勾满的父级回显为父级本身,其全部后代不单独显示;
319
+ // 未勾满的父级不显示,只显示其下被选中的叶子/子父级。
320
+ // 注意"父级全选"不能只看 set.has(父级value) —— 级联勾子节点时 selected 里
321
+ // 只有叶子值,父级 value 不在其中,需按"所有后代均被选中"判定
322
+ const result: string[] = []
323
+ const fullyChecked = (n: Node): boolean =>
324
+ n.children.length === 0 ? set.has(n.value) : n.children.every(fullyChecked)
325
+ const walk = (nodes: Node[]) => {
326
+ for (const n of nodes) {
327
+ if (n.children.length > 0 && fullyChecked(n)) {
328
+ // 全选父级:回显自身,跳过后代
329
+ result.push(n.value)
330
+ } else {
331
+ walk(n.children)
332
+ if (n.children.length === 0 && set.has(n.value)) result.push(n.value)
333
+ }
334
+ }
335
+ }
336
+ walk(baseTree.value)
337
+ // selected 中可能存在树外的值(如懒加载未加载的分支),原样保留
338
+ const known = new Set<string>()
339
+ const collect = (nodes: Node[]) => {
340
+ for (const n of nodes) { known.add(n.value); collect(n.children) }
341
+ }
342
+ collect(baseTree.value)
343
+ for (const v of values) if (!known.has(v)) result.push(v)
344
+ return result
345
+ })
346
+
347
+ // ---------- 搜索时自动展开匹配祖先 ----------
348
+ watch(query, (q) => {
349
+ if (!q.trim()) return
350
+ const qLower = q.toLowerCase()
351
+ const toExpand = new Set<string>()
352
+ function collectMatches(nodes: Node[], ancestors: string[]) {
353
+ for (const node of nodes) {
354
+ if (node.label.toLowerCase().includes(qLower)) {
355
+ ancestors.forEach(a => toExpand.add(a))
356
+ }
357
+ if (node.children.length > 0) {
358
+ collectMatches(node.children, [...ancestors, node.value])
359
+ }
360
+ }
361
+ }
362
+ collectMatches(baseTree.value, [])
363
+ toExpand.forEach(v => expanded.value.add(v))
364
+ })
365
+
366
+ // ---------- 交互 ----------
367
+ function toggleOpen() {
368
+ if (props.disabled) return
369
+ open.value = !open.value
370
+ if (open.value && props.searchable) {
371
+ query.value = ''
372
+ nextTick(() => searchInputEl.value?.focus())
373
+ }
374
+ }
375
+
376
+ function handleToggle(value: string, hasChildren: boolean) {
377
+ if (hasChildren) {
378
+ const s = new Set(expanded.value)
379
+ if (s.has(value)) s.delete(value)
380
+ else s.add(value)
381
+ expanded.value = s
382
+ }
383
+ }
384
+
385
+ /** 节点是否被全选:checkStrictly 下仅看自身;关联模式下所有后代都被选中 */
386
+ function isNodeChecked(node: Node): boolean {
387
+ if (props.checkStrictly || !props.multiple) return selected.value.includes(node.value)
388
+ if (node.children.length === 0) return selected.value.includes(node.value)
389
+ return node.children.every(c => isNodeChecked(c))
390
+ }
391
+ /** 节点是否被半选(部分后代被选中,仅关联模式非叶子有意义) */
392
+ function isNodeIndeterminate(node: Node): boolean {
393
+ if (props.checkStrictly || node.children.length === 0) return false
394
+ const childStates = node.children.map(c => isNodeChecked(c) || isNodeIndeterminate(c))
395
+ return childStates.some(Boolean) && !childStates.every(Boolean)
396
+ }
397
+
398
+ function handleCheck(node: Node) {
399
+ touched = true
400
+ if (props.multiple) {
401
+ if (props.checkStrictly) {
402
+ // 父子不关联:只切换单节点
403
+ selected.value = selected.value.includes(node.value)
404
+ ? selected.value.filter(v => v !== node.value)
405
+ : [...selected.value, node.value]
406
+ } else {
407
+ const set = new Set(selected.value)
408
+ if (set.has(node.value)) set.delete(node.value)
409
+ else set.add(node.value)
410
+ // 全选/取消子孙
411
+ function walkDescendants(n: Node, check: boolean) {
412
+ if (check) set.add(n.value)
413
+ else set.delete(n.value)
414
+ n.children.forEach(c => walkDescendants(c, check))
415
+ }
416
+ const checked = set.has(node.value)
417
+ walkDescendants(node, checked)
418
+ selected.value = Array.from(set)
419
+ }
420
+ } else {
421
+ selected.value = [node.value]
422
+ open.value = false
423
+ }
424
+ emit('change', props.multiple ? strategyValues.value : selected.value[0] ?? '')
425
+ }
426
+
427
+ /** 查找从根到目标节点的 value 路径(懒加载 fill 按路径逐级定位挂载点) */
428
+ function nodePathOf(target: string, nodes: Node[] = baseTree.value): string[] | null {
429
+ for (const n of nodes) {
430
+ if (n.value === target) return [n.value]
431
+ if (n.children.length > 0) {
432
+ const sub = nodePathOf(target, n.children)
433
+ if (sub) return [n.value, ...sub]
434
+ }
435
+ }
436
+ return null
437
+ }
438
+
439
+ async function handleLoad(node: Node) {
440
+ if (!props.url || node.loaded) return
441
+ loading.value = true
442
+ try {
443
+ const sep = props.url.includes('?') ? '&' : '?'
444
+ const res = await fetch(`${props.url}${sep}parent=${encodeURIComponent(node.value)}`)
445
+ if (!res.ok) throw new Error()
446
+ const json = await res.json()
447
+ const list = normalizeRemoteTree(json)
448
+ // 注入子级到 remoteData(按从根到节点的完整路径定位挂载点)
449
+ function fill(nodes: TreeNodeRaw[], path: string[], children: TreeNodeRaw[]): TreeNodeRaw[] {
450
+ return nodes.map(n => {
451
+ const v = String(n.value ?? n.key ?? '')
452
+ if (v !== path[0]) return n
453
+ if (path.length === 1) return { ...n, children, loaded: true }
454
+ return { ...n, children: fill(n.children || [], path.slice(1), children) }
455
+ })
456
+ }
457
+ if (remoteData.value) {
458
+ const path = nodePathOf(node.value) ?? [node.value]
459
+ remoteData.value = fill(remoteData.value, path, list)
460
+ }
461
+ } catch {
462
+ // 静默失败
463
+ } finally {
464
+ loading.value = false
465
+ }
466
+ }
467
+
468
+ function expandAll() {
469
+ const all = new Set<string>()
470
+ function collect(nodes: Node[]) {
471
+ for (const n of nodes) {
472
+ if (n.children.length > 0) {
473
+ all.add(n.value)
474
+ collect(n.children)
475
+ }
476
+ }
477
+ }
478
+ collect(baseTree.value)
479
+ expanded.value = all
480
+ }
481
+
482
+ function collapseAll() {
483
+ expanded.value = new Set()
484
+ }
485
+
486
+ function clearValue() {
487
+ touched = true
488
+ selected.value = []
489
+ emit('change', props.multiple ? [] : '')
490
+ }
491
+
492
+ // ---------- 初始化选中值 ----------
493
+ /** 默认值语义(对齐 antd TreeSelect defaultValue):
494
+ * 逗号分隔多值;每个值支持 / 分隔路径(zj/hz)或节点 value(hz),
495
+ * 路径写法按段逐级查找(段先按 value 匹配、再按 label 匹配),定位到路径末端节点;
496
+ * 单段 value 在根级找不到时,从整棵树深度查找(antd 语义:value 全树唯一) */
497
+ function resolveDefault(treeNodes: Node[]): { values: string[]; ancestors: string[] } {
498
+ const def = props.default
499
+ if (!def) return { values: [], ancestors: [] }
500
+ const paths = Array.isArray(def)
501
+ ? def.map(String)
502
+ : String(def).split(',').map(s => s.trim()).filter(Boolean)
503
+ const values: string[] = []
504
+ const ancestors: string[] = []
505
+ /** 从整棵树深度查找节点,返回从根到命中节点的 value 链 */
506
+ function deepFind(nodes: Node[], seg: string, chain: string[]): string[] | null {
507
+ for (const n of nodes) {
508
+ if (n.value === seg || n.label === seg) return [...chain, n.value]
509
+ const sub = deepFind(n.children, seg, [...chain, n.value])
510
+ if (sub) return sub
511
+ }
512
+ return null
513
+ }
514
+ for (const path of paths) {
515
+ const segs = path.split('/').map(s => s.trim()).filter(Boolean)
516
+ let level = treeNodes
517
+ let node: Node | undefined
518
+ const chain: string[] = []
519
+ for (const seg of segs) {
520
+ const found = level.find(n => n.value === seg) ?? level.find(n => n.label === seg)
521
+ if (!found) { node = undefined; break }
522
+ chain.push(found.value)
523
+ node = found
524
+ level = found.children
525
+ }
526
+ if (node) {
527
+ values.push(node.value)
528
+ ancestors.push(...chain.slice(0, -1))
529
+ } else if (segs.length === 1) {
530
+ // 单段 value 逐级未命中(根级找不到):从整棵树深度查找(antd defaultValue 语义)
531
+ const deep = deepFind(treeNodes, segs[0], [])
532
+ if (deep) {
533
+ values.push(deep[deep.length - 1])
534
+ ancestors.push(...deep.slice(0, -1))
535
+ }
536
+ }
537
+ }
538
+ return {
539
+ values: [...new Set(values)],
540
+ ancestors: [...new Set(ancestors)]
541
+ }
542
+ }
543
+
544
+ /** 用户是否交互过(勾选/清空):交互后不再跟随 default 变化,避免覆盖用户选择 */
545
+ let touched = false
546
+
547
+ // 挂载/数据源就绪/default 变化时回填默认值(含祖先展开)
548
+ watch(
549
+ () => [props.default, baseTree.value] as const,
550
+ () => {
551
+ if (touched) return
552
+ const { values, ancestors } = resolveDefault(baseTree.value)
553
+ selected.value = values
554
+ const s = new Set(expanded.value)
555
+ ancestors.forEach(a => s.add(a))
556
+ expanded.value = s
557
+ },
558
+ { immediate: true }
559
+ )
560
+
561
+ // ---------- 远端加载 ----------
562
+ if (props.url) {
563
+ let cancelled = false
564
+ loading.value = true
565
+ ;(async () => {
566
+ try {
567
+ const res = await fetch(props.url)
568
+ if (!res.ok) throw new Error()
569
+ const json = await res.json()
570
+ if (cancelled) return
571
+ remoteData.value = normalizeRemoteTree(json)
572
+ } catch {
573
+ // 静默失败
574
+ } finally {
575
+ if (!cancelled) loading.value = false
576
+ }
577
+ })()
578
+ }
579
+
580
+ // ---------- 点击外部关闭 ----------
581
+ function onDocClick(e: MouseEvent) {
582
+ if (rootEl.value && !rootEl.value.contains(e.target as globalThis.Node)) {
583
+ open.value = false
584
+ }
585
+ }
586
+ onMounted(() => document.addEventListener('mousedown', onDocClick))
587
+ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
588
+ </script>
589
+
590
+ <script lang="ts">
591
+ // 子组件:单个树节点(避免模板递归,改用递归渲染)
592
+ import { defineComponent, h, type VNode } from 'vue'
593
+ import type { TreeSelectNode } from './treeSelectTypes'
594
+
595
+ const TreeNode = defineComponent({
596
+ name: 'TreeNode',
597
+ props: {
598
+ node: { type: Object as () => TreeSelectNode, required: true },
599
+ depth: { type: Number, required: true },
600
+ multiple: { type: Boolean, default: false },
601
+ checked: { type: Boolean, default: false },
602
+ indeterminate: { type: Boolean, default: false },
603
+ treeLine: { type: Boolean, default: false },
604
+ lazy: { type: Boolean, default: false },
605
+ url: { type: String, default: '' }
606
+ },
607
+ emits: ['toggle', 'check', 'load'],
608
+ setup(props, { emit }) {
609
+ return () => {
610
+ const { node, depth, multiple, checked, indeterminate, treeLine } = props
611
+ // 懒加载模式下,未加载子级的节点(children 为空且 loaded=false)视为"可展开",
612
+ // 显示展开箭头;加载完成返回空数组时 loaded=true,确认为真叶子
613
+ const isLazyUnloaded = props.lazy && !!props.url && !node.loaded && node.children.length === 0
614
+ const hasChildren = node.children.length > 0 || isLazyUnloaded
615
+ const paddingLeft = 16 + depth * 20
616
+
617
+ const labelEl = h('span', { class: 'ts-node-label', style: { paddingLeft: '8px' } }, node.label)
618
+
619
+ let leftEl: VNode
620
+ if (multiple) {
621
+ // 子组件内部元素不受 scoped 样式影响,选中/半选状态色用内联样式保证
622
+ leftEl = h('span', {
623
+ class: ['ts-checkbox', checked ? 'ts-checked' : '', indeterminate ? 'ts-indeterminate' : ''].filter(Boolean),
624
+ role: 'checkbox',
625
+ 'aria-checked': indeterminate ? 'mixed' : checked ? 'true' : 'false',
626
+ style: (checked || indeterminate) ? { color: '#1677ff' } : undefined
627
+ }, checked ? '☑' : indeterminate ? '▣' : '☐')
628
+ } else {
629
+ leftEl = h('span', {
630
+ class: ['ts-radio', checked ? 'ts-radio-checked' : ''].filter(Boolean),
631
+ role: 'radio',
632
+ 'aria-checked': checked ? 'true' : 'false',
633
+ style: checked ? { color: '#1677ff' } : undefined
634
+ }, checked ? '●' : '○')
635
+ }
636
+
637
+ const arrowEl = hasChildren
638
+ ? h('span', {
639
+ class: 'ts-node-arrow',
640
+ 'aria-hidden': 'true',
641
+ onClick: (e: MouseEvent) => { e.stopPropagation(); emit('toggle', node.value, true) }
642
+ }, '▸')
643
+ : h('span', { class: 'ts-node-leaf', 'aria-hidden': 'true' }, '·')
644
+
645
+ return h('div', {
646
+ class: ['ts-node', treeLine ? 'ts-tree-line' : ''].filter(Boolean),
647
+ role: 'treeitem',
648
+ style: { paddingLeft: `${paddingLeft}px` },
649
+ onClick: () => {
650
+ if (hasChildren) {
651
+ emit('toggle', node.value, true)
652
+ // 懒加载未加载子级:单击展开时同步请求子级(对齐 React 版 handleNodeClick 行为)
653
+ if (isLazyUnloaded) emit('load', node)
654
+ }
655
+ // 单选模式下点击非叶子节点只展开不选中(避免关闭下拉);多选模式保持展开+勾选
656
+ if (!hasChildren || multiple) emit('check', node)
657
+ },
658
+ onDblclick: () => {
659
+ if (props.lazy && props.url && hasChildren && !node.loaded) {
660
+ emit('load', node)
661
+ }
662
+ }
663
+ }, [leftEl, arrowEl, labelEl])
664
+ }
665
+ }
666
+ })
667
+
668
+ export { TreeNode }
669
+ export default {}
670
+ </script>
671
+
672
+ <style scoped>
673
+ .widget-tree-select { position: relative; }
674
+
675
+ .ts-input {
676
+ position: relative;
677
+ display: flex;
678
+ align-items: center;
679
+ flex-wrap: wrap;
680
+ gap: 0.375rem;
681
+ width: 100%;
682
+ min-height: 42px;
683
+ padding: 0.5rem 0.75rem;
684
+ border: 1px solid #d9d9d9;
685
+ border-radius: 6px;
686
+ font-size: 0.875rem;
687
+ background-color: #ffffff;
688
+ cursor: pointer;
689
+ transition: border-color 0.2s, box-shadow 0.2s;
690
+ text-align: left;
691
+ }
692
+ .ts-input:hover { border-color: #4096ff; }
693
+ .ts-input:focus {
694
+ outline: none;
695
+ border-color: #1677ff;
696
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
697
+ }
698
+ .ts-input:disabled {
699
+ background-color: #f5f5f5;
700
+ color: rgba(0, 0, 0, 0.25);
701
+ cursor: not-allowed;
702
+ }
703
+
704
+ .ts-input-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
705
+ .ts-placeholder { color: rgba(0, 0, 0, 0.45); }
706
+ .ts-arrow { color: rgba(0, 0, 0, 0.45); font-size: 0.75rem; transition: transform 0.2s; margin-left: auto; }
707
+ .ts-arrow-open { transform: rotate(180deg); }
708
+ .ts-clear {
709
+ padding: 0 0.25rem;
710
+ color: rgba(0, 0, 0, 0.45);
711
+ font-size: 1rem;
712
+ line-height: 1;
713
+ margin-left: auto;
714
+ }
715
+ .ts-clear:hover { color: rgba(0, 0, 0, 0.88); }
716
+
717
+ /* 触发器悬浮提示:展示全部已选项(maxTagCount 截断时补全) */
718
+ .ts-input[data-full]:not([data-full=''])::after {
719
+ content: attr(data-full);
720
+ position: absolute;
721
+ top: calc(100% + 8px);
722
+ left: 0;
723
+ padding: 0.5rem 0.75rem;
724
+ max-width: 18rem;
725
+ width: max-content;
726
+ white-space: normal;
727
+ word-break: break-all;
728
+ font-size: 0.75rem;
729
+ font-weight: 400;
730
+ text-align: left;
731
+ color: #fafafa;
732
+ background-color: rgba(0, 0, 0, 0.88);
733
+ border-radius: 6px;
734
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
735
+ opacity: 0;
736
+ visibility: hidden;
737
+ pointer-events: none;
738
+ transition: opacity 0.15s ease;
739
+ z-index: 100;
740
+ }
741
+ .ts-input[data-full]:not([data-full='']):not([aria-expanded='true']):hover::after {
742
+ opacity: 1;
743
+ visibility: visible;
744
+ }
745
+
746
+ .ts-tag {
747
+ display: inline-flex;
748
+ align-items: center;
749
+ padding: 1px 8px;
750
+ background: #f0f0f0;
751
+ border-radius: 4px;
752
+ font-size: 0.8125rem;
753
+ color: rgba(0, 0, 0, 0.88);
754
+ }
755
+ .ts-tag-more { color: rgba(0, 0, 0, 0.45); }
756
+
757
+ .ts-panel {
758
+ position: absolute;
759
+ z-index: 50;
760
+ top: calc(100% + 4px);
761
+ left: 0;
762
+ min-width: 240px;
763
+ max-height: 320px;
764
+ overflow: hidden;
765
+ display: flex;
766
+ flex-direction: column;
767
+ background: #ffffff;
768
+ border: 1px solid #d9d9d9;
769
+ border-radius: 8px;
770
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
771
+ }
772
+
773
+ .ts-search { padding: 0.5rem; border-bottom: 1px solid #f0f0f0; }
774
+ .ts-search-input {
775
+ width: 100%;
776
+ padding: 0.375rem 0.5rem;
777
+ border: 1px solid #d9d9d9;
778
+ border-radius: 4px;
779
+ font-size: 0.875rem;
780
+ outline: none;
781
+ }
782
+ .ts-search-input:focus { border-color: #1677ff; }
783
+
784
+ .ts-toolbar {
785
+ display: flex;
786
+ gap: 0.5rem;
787
+ padding: 0.375rem 0.5rem;
788
+ border-bottom: 1px solid #f0f0f0;
789
+ }
790
+ .ts-tool-btn {
791
+ padding: 0.125rem 0.5rem;
792
+ border: 1px solid #d9d9d9;
793
+ border-radius: 4px;
794
+ background: transparent;
795
+ font-size: 0.75rem;
796
+ color: rgba(0, 0, 0, 0.65);
797
+ cursor: pointer;
798
+ transition: color 0.15s, border-color 0.15s;
799
+ }
800
+ .ts-tool-btn:hover { color: #1677ff; border-color: #1677ff; }
801
+
802
+ .ts-tree {
803
+ flex: 1;
804
+ overflow-y: auto;
805
+ padding: 0.375rem 0;
806
+ }
807
+
808
+ .ts-node {
809
+ display: flex;
810
+ align-items: center;
811
+ gap: 0.375rem;
812
+ padding: 0.375rem 0.5rem;
813
+ cursor: pointer;
814
+ font-size: 0.875rem;
815
+ color: rgba(0, 0, 0, 0.88);
816
+ border-radius: 4px;
817
+ margin: 0 0.25rem;
818
+ transition: background 0.15s;
819
+ }
820
+ .ts-node:hover { background: #f5f5f5; }
821
+
822
+ /* 树连接线(antd treeLine):横向连线 + 纵向缩进引导线 */
823
+ .ts-node.ts-tree-line { position: relative; }
824
+ .ts-node.ts-tree-line::before {
825
+ content: '';
826
+ position: absolute;
827
+ top: 0;
828
+ bottom: 0;
829
+ left: 8px;
830
+ border-left: 1px solid #d9d9d9;
831
+ }
832
+ .ts-node.ts-tree-line:first-child::before { top: 50%; }
833
+ .ts-node.ts-tree-line:last-child::before { bottom: 50%; }
834
+ .ts-node.ts-tree-line::after {
835
+ content: '';
836
+ position: absolute;
837
+ top: 50%;
838
+ left: 8px;
839
+ width: 12px;
840
+ border-top: 1px solid #d9d9d9;
841
+ }
842
+
843
+ .ts-checkbox, .ts-radio {
844
+ display: inline-flex;
845
+ align-items: center;
846
+ font-size: 0.9375rem;
847
+ color: #1677ff;
848
+ flex-shrink: 0;
849
+ width: 16px;
850
+ justify-content: center;
851
+ }
852
+
853
+ .ts-node-arrow {
854
+ display: inline-flex;
855
+ align-items: center;
856
+ width: 16px;
857
+ font-size: 0.625rem;
858
+ color: rgba(0, 0, 0, 0.45);
859
+ transition: transform 0.2s;
860
+ flex-shrink: 0;
861
+ cursor: default;
862
+ }
863
+ .ts-node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
864
+ .ts-node-leaf { display: inline-block; width: 16px; text-align: center; color: rgba(0, 0, 0, 0.25); font-size: 1rem; flex-shrink: 0; }
865
+
866
+ .ts-empty {
867
+ padding: 1rem;
868
+ text-align: center;
869
+ font-size: 0.875rem;
870
+ color: rgba(0, 0, 0, 0.45);
871
+ }
872
+ </style>