@c2-markdown-ui/svelte 0.0.0-stage → 0.4.2

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.
@@ -0,0 +1,924 @@
1
+ <script lang="ts">
2
+ /** 树形选择器(对齐 antd TreeSelect)
3
+ * 静态数据源两种格式(兼容共存,data 优先):
4
+ * 1. choices:扁平路径数组,每项用 / 分隔层级,每段支持 key:label
5
+ * 2. data:嵌套对象树(TreeNode[]),如 [{value:'zj',label:'浙江',children:[...]}]
6
+ * url:远端数据源,fetch 拉取嵌套树,data/choices 作为初始/后备
7
+ * multiple:多选模式(antd 默认单选,multiple 开启多选)
8
+ * checkStrictly:父子节点选中不关联(antd checkStrictly)
9
+ * showCheckedStrategy:回显策略 all 全部 / parent 仅顶层父级 / child 仅叶子(antd SHOW_ALL/SHOW_PARENT/SHOW_CHILD)
10
+ * maxTagCount:触发器最多显示标签数,超出 +N(antd maxTagCount)
11
+ * treeLine:显示树连接线(antd treeLine)
12
+ * searchable:可搜索(antd showSearch,默认关闭) */
13
+ interface TreeNode {
14
+ value?: string | number;
15
+ key?: string | number;
16
+ label?: string | number;
17
+ children?: TreeNode[];
18
+ loaded?: boolean;
19
+ [extra: string]: any;
20
+ }
21
+ interface Props {
22
+ choices: string[];
23
+ /** 嵌套对象树数据源(与扁平路径 choices 兼容共存,优先于 choices) */
24
+ data?: TreeNode[];
25
+ /** 远端数据源 URL */
26
+ url?: string;
27
+ /** 懒加载模式(url 存在时生效):首次只拉根级,点击父级后逐级请求子级 */
28
+ lazy?: boolean;
29
+ /** 多选模式:开启后显示复选框,返回 value[];关闭(默认)单选,返回 value */
30
+ multiple?: boolean;
31
+ /** 父子节点选中不关联(antd checkStrictly) */
32
+ checkStrictly?: boolean;
33
+ /** 选中值回显策略:all / parent / child(antd showCheckedStrategy) */
34
+ showCheckedStrategy?: string;
35
+ /** 触发器最多显示的标签数(antd maxTagCount) */
36
+ maxTagCount?: number;
37
+ /** 树连接线(antd treeLine) */
38
+ treeLine?: boolean;
39
+ label?: string;
40
+ default?: string | string[];
41
+ placeholder?: string;
42
+ hint?: string;
43
+ required?: boolean;
44
+ message?: string;
45
+ error?: string;
46
+ /** 是否禁用 */
47
+ disabled?: boolean;
48
+ /** 是否可搜索:开启后下拉面板内置搜索框,按 label 过滤节点(默认关闭) */
49
+ searchable?: boolean;
50
+ onchange: (value: string | string[]) => void;
51
+ }
52
+ let {
53
+ choices,
54
+ data,
55
+ url = '',
56
+ lazy = false,
57
+ multiple = false,
58
+ checkStrictly = false,
59
+ showCheckedStrategy = 'all',
60
+ maxTagCount,
61
+ treeLine = false,
62
+ label = '',
63
+ default: initial,
64
+ placeholder = '',
65
+ hint = '',
66
+ required = false,
67
+ message = '',
68
+ error = '',
69
+ disabled = false,
70
+ searchable = false,
71
+ onchange
72
+ }: Props = $props();
73
+
74
+ // ---------- 数据解析 ----------
75
+ interface TreeNodeItem {
76
+ value: string;
77
+ label: string;
78
+ children: TreeNodeItem[];
79
+ isLeaf: boolean;
80
+ loaded?: boolean;
81
+ }
82
+
83
+ function parseSeg(seg: string): { value: string; label: string } {
84
+ const idx = seg.indexOf(':');
85
+ if (idx > 0) {
86
+ const v = seg.slice(0, idx).trim();
87
+ const l = seg.slice(idx + 1).trim();
88
+ if (v && l) return { value: v, label: l };
89
+ }
90
+ return { value: seg.trim(), label: seg.trim() };
91
+ }
92
+
93
+ function nodesToTree(nodes: TreeNode[] | undefined, isLazy = false): TreeNodeItem[] {
94
+ if (!Array.isArray(nodes)) return [];
95
+ return nodes.map(n => {
96
+ const v = n.value ?? n.key;
97
+ const children = n.children;
98
+ const hasChildren = Array.isArray(children) && children.length > 0;
99
+ const wasLoaded = n.loaded === true;
100
+ return {
101
+ value: String(v ?? ''),
102
+ label: String(n.label ?? v ?? ''),
103
+ children: hasChildren ? nodesToTree(children, isLazy) : [],
104
+ isLeaf: isLazy ? !hasChildren && wasLoaded : !hasChildren,
105
+ loaded: hasChildren || wasLoaded,
106
+ };
107
+ });
108
+ }
109
+
110
+ function normalizeRemoteTree(json: any): TreeNode[] {
111
+ let list = json;
112
+ if (!Array.isArray(json)) {
113
+ list = Array.isArray(json?.data) ? json.data
114
+ : Array.isArray(json?.options) ? json.options
115
+ : Array.isArray(json?.choices) ? json.choices
116
+ : Array.isArray(json?.items) ? json.items
117
+ : Array.isArray(json?.tree) ? json.tree
118
+ : Array.isArray(json?.treeData) ? json.treeData
119
+ : [];
120
+ }
121
+ const map = (arr: any[]): TreeNode[] =>
122
+ (arr || [])
123
+ .map((it: any): TreeNode | null => {
124
+ if (!it || typeof it !== 'object') return null;
125
+ const value = it.value ?? it.key;
126
+ if (value === undefined || value === null || value === '') return null;
127
+ const node: TreeNode = { value: String(value) };
128
+ const label = it.label ?? it.name ?? it.text;
129
+ if (label !== undefined && label !== null) node.label = String(label);
130
+ if (Array.isArray(it.children) && it.children.length > 0) {
131
+ node.children = map(it.children);
132
+ }
133
+ return node;
134
+ })
135
+ .filter((n: TreeNode | null): n is TreeNode => n !== null);
136
+ return map(list);
137
+ }
138
+
139
+ // ---------- 树数据源 ----------
140
+ let remoteData = $state<TreeNode[] | null>(null);
141
+ let loading = $state(false);
142
+ let loadingPath = $state('');
143
+
144
+ const tree = $derived.by(() => {
145
+ if (remoteData) return nodesToTree(remoteData, lazy);
146
+ if (Array.isArray(data) && data.length > 0) return nodesToTree(data, lazy);
147
+ const root: TreeNodeItem[] = [];
148
+ for (const path of choices) {
149
+ if (!path) continue;
150
+ const segs = path.split('/').map(s => s.trim()).filter(Boolean);
151
+ if (segs.length === 0) continue;
152
+ let level = root;
153
+ for (let i = 0; i < segs.length; i++) {
154
+ const { value, label } = parseSeg(segs[i]);
155
+ let node = level.find(n => n.value === value);
156
+ if (!node) {
157
+ node = { value, label, children: [], isLeaf: i === segs.length - 1 };
158
+ level.push(node);
159
+ }
160
+ level = node.children;
161
+ }
162
+ }
163
+ return root;
164
+ });
165
+
166
+ // ---------- 初始选中值 ----------
167
+ /** 默认值语义(对齐 antd TreeSelect defaultValue):
168
+ * 逗号分隔多值;每个值支持 / 分隔路径(zj/hz)或节点 value(hz),
169
+ * 路径写法按段逐级查找(段先按 value 匹配、再按 label 匹配),定位到路径末端节点。
170
+ * 返回选中节点 value 数组 + 路径祖先 value 数组(用于初始展开) */
171
+ function resolveInitial(): { values: string[]; ancestors: string[] } {
172
+ if (!initial) return { values: [], ancestors: [] };
173
+ const paths = Array.isArray(initial)
174
+ ? initial.map(String)
175
+ : initial.split(',').map(s => s.trim()).filter(Boolean);
176
+ const values: string[] = [];
177
+ const ancestors: string[] = [];
178
+ for (const path of paths) {
179
+ const segs = path.split('/').map(s => s.trim()).filter(Boolean);
180
+ let level = tree;
181
+ let node: TreeNodeItem | undefined;
182
+ const chain: string[] = [];
183
+ for (const seg of segs) {
184
+ const found = level.find(n => n.value === seg) ?? level.find(n => n.label === seg);
185
+ if (!found) { node = undefined; break; }
186
+ chain.push(found.value);
187
+ node = found;
188
+ level = found.children;
189
+ }
190
+ if (node) {
191
+ values.push(node.value);
192
+ ancestors.push(...chain.slice(0, -1));
193
+ }
194
+ }
195
+ return {
196
+ values: [...new Set(values)],
197
+ ancestors: [...new Set(ancestors)]
198
+ };
199
+ }
200
+
201
+ /** 用户是否交互过(勾选/清空):交互后不再跟随 default 变化,避免覆盖用户选择 */
202
+ let touched = false;
203
+ let selected = $state<string[]>([]);
204
+
205
+ // ---------- 展开状态 ----------
206
+ /** 展开的节点 value 集合 */
207
+ let expanded = $state<Set<string>>(new Set());
208
+
209
+ // 挂载/数据源就绪/default 变化(编辑器面板写回 DSL 后 morphdom 复用实例)时回填默认值
210
+ $effect(() => {
211
+ if (touched) return;
212
+ const { values, ancestors } = resolveInitial();
213
+ selected = values;
214
+ expanded = new Set(ancestors);
215
+ });
216
+
217
+ // ---------- 搜索 ----------
218
+ let query = $state('');
219
+ let searchEl: HTMLInputElement | undefined = $state();
220
+
221
+ /** 过滤后的节点:保留匹配项及其所有祖先路径 */
222
+ const filteredTree = $derived.by(() => {
223
+ const q = query.trim().toLowerCase();
224
+ if (!q) return null; // null 表示不过滤
225
+ function matchLabel(node: TreeNodeItem): boolean {
226
+ return node.label.toLowerCase().includes(q) ||
227
+ node.children.some(c => matchLabel(c));
228
+ }
229
+ function prune(node: TreeNodeItem): TreeNodeItem | null {
230
+ if (node.label.toLowerCase().includes(q)) return node;
231
+ const filteredChildren = node.children
232
+ .map(c => prune(c))
233
+ .filter((c): c is TreeNodeItem => c !== null);
234
+ if (filteredChildren.length > 0) {
235
+ return { ...node, children: filteredChildren };
236
+ }
237
+ return null;
238
+ }
239
+ const pruned = tree
240
+ .map(n => prune(n))
241
+ .filter((n): n is TreeNodeItem => n !== null);
242
+ return pruned.length > 0 ? pruned : null;
243
+ });
244
+
245
+ /** 搜索模式:展开所有匹配路径的祖先节点 */
246
+ $effect(() => {
247
+ if (query.trim() && filteredTree !== null) {
248
+ function collectAncestors(nodes: TreeNodeItem[], ancestors: string[] = []) {
249
+ for (const node of nodes) {
250
+ expanded.add(node.value);
251
+ if (node.children.length > 0) {
252
+ collectAncestors(node.children, [...ancestors, node.value]);
253
+ }
254
+ }
255
+ }
256
+ collectAncestors(filteredTree);
257
+ }
258
+ });
259
+
260
+ // ---------- 展示文案 ----------
261
+ function getLabel(value: string): string {
262
+ function find(nodes: TreeNodeItem[], v: string): string | undefined {
263
+ for (const n of nodes) {
264
+ if (n.value === v) return n.label;
265
+ const found = find(n.children, v);
266
+ if (found) return found;
267
+ }
268
+ }
269
+ return find(tree, value) ?? value;
270
+ }
271
+
272
+ const display = $derived.by(() => {
273
+ if (selected.length === 0) return '';
274
+ if (!multiple) return getLabel(selected[0]);
275
+ const values = applyStrategy(selected);
276
+ const limit = typeof maxTagCount === 'number' && maxTagCount >= 0 ? maxTagCount : Infinity;
277
+ const parts = values.slice(0, limit).map(v => getLabel(v));
278
+ if (values.length > limit) parts.push(`+${values.length - limit}`);
279
+ return parts.join(', ');
280
+ });
281
+
282
+ // ---------- 展开/折叠 ----------
283
+ function toggleExpand(value: string) {
284
+ if (expanded.has(value)) {
285
+ expanded.delete(value);
286
+ } else {
287
+ expanded.add(value);
288
+ }
289
+ expanded = new Set(expanded);
290
+ }
291
+
292
+ function expandAll() {
293
+ function collect(nodes: TreeNodeItem[]) {
294
+ for (const n of nodes) {
295
+ // 懒加载模式:未加载子级的节点不预置展开(首次点击时展开并逐级拉取);
296
+ // 已加载的非叶子节点正常展开
297
+ if (n.isLeaf) continue;
298
+ if (lazy && url && !n.loaded) continue;
299
+ expanded.add(n.value);
300
+ collect(n.children);
301
+ }
302
+ }
303
+ collect(tree);
304
+ expanded = new Set(expanded);
305
+ }
306
+
307
+ function collapseAll() {
308
+ expanded = new Set();
309
+ }
310
+
311
+ // ---------- 选择逻辑(多选) ----------
312
+ function getAllValues(node: TreeNodeItem): string[] {
313
+ const vals = [node.value];
314
+ for (const c of node.children) vals.push(...getAllValues(c));
315
+ return vals;
316
+ }
317
+
318
+ function findNode(value: string, nodes: TreeNodeItem[] = tree): TreeNodeItem | undefined {
319
+ for (const n of nodes) {
320
+ if (n.value === value) return n;
321
+ const found = findNode(value, n.children);
322
+ if (found) return found;
323
+ }
324
+ }
325
+
326
+ /** 节点是否被全选:checkStrictly 下仅看自身;关联模式下所有后代都被选中 */
327
+ function isNodeChecked(node: TreeNodeItem): boolean {
328
+ if (checkStrictly || !multiple) return selected.includes(node.value);
329
+ if (node.children.length === 0) return selected.includes(node.value);
330
+ return node.children.every(c => isNodeChecked(c));
331
+ }
332
+ /** 节点是否被半选(部分后代被选中,仅关联模式非叶子有意义) */
333
+ function isNodeIndeterminate(node: TreeNodeItem): boolean {
334
+ if (checkStrictly || node.children.length === 0) return false;
335
+ const childStates = node.children.map(c => isNodeChecked(c) || isNodeIndeterminate(c));
336
+ return childStates.some(Boolean) && !childStates.every(Boolean);
337
+ }
338
+
339
+ /** 按回显策略(antd showCheckedStrategy)过滤选中值:
340
+ * all 全部 / parent 仅返回最顶层的全选父级 / child 仅返回叶子 */
341
+ function applyStrategy(values: string[]): string[] {
342
+ if (!multiple || checkStrictly || showCheckedStrategy === 'all') return values;
343
+ const set = new Set(values);
344
+ /** 从根到目标节点的祖先链(含自身) */
345
+ function chainOf(v: string): TreeNodeItem[] {
346
+ const chain: TreeNodeItem[] = [];
347
+ function walk(nodes: TreeNodeItem[]): boolean {
348
+ for (const p of nodes) {
349
+ chain.push(p);
350
+ if (p.value === v) return true;
351
+ if (walk(p.children)) return true;
352
+ chain.pop();
353
+ }
354
+ return false;
355
+ }
356
+ walk(tree);
357
+ return chain;
358
+ }
359
+ if (showCheckedStrategy === 'child') {
360
+ return values.filter(v => {
361
+ const n = findNode(v);
362
+ return !n || n.children.length === 0;
363
+ });
364
+ }
365
+ // parent:叶子或全选父级保留,但被全选祖先覆盖的后代不显示
366
+ return values.filter(v => {
367
+ const chain = chainOf(v);
368
+ if (chain.length === 0) return true;
369
+ const fullyChecked = (n: TreeNodeItem) =>
370
+ n.children.length === 0 || n.children.every(c => set.has(c.value));
371
+ for (let i = 0; i < chain.length - 1; i++) {
372
+ if (fullyChecked(chain[i])) return false; // 存在全选祖先
373
+ }
374
+ return fullyChecked(chain[chain.length - 1]);
375
+ });
376
+ }
377
+
378
+ function toggleNode(node: TreeNodeItem) {
379
+ if (multiple) {
380
+ touched = true;
381
+ if (checkStrictly) {
382
+ // 父子不关联:只切换单节点
383
+ if (selected.includes(node.value)) {
384
+ selected = selected.filter(v => v !== node.value);
385
+ } else {
386
+ selected = [...selected, node.value];
387
+ }
388
+ onchange(selected);
389
+ return;
390
+ }
391
+ const allVals = getAllValues(node);
392
+ const allSelected = allVals.every(v => selected.includes(v));
393
+ if (allSelected) {
394
+ selected = selected.filter(v => !allVals.includes(v));
395
+ } else {
396
+ const newSelected = new Set(selected);
397
+ allVals.forEach(v => newSelected.add(v));
398
+ selected = Array.from(newSelected);
399
+ }
400
+ onchange(applyStrategy(selected));
401
+ } else {
402
+ touched = true;
403
+ selected = [node.value];
404
+ onchange(node.value);
405
+ open = false;
406
+ }
407
+ }
408
+
409
+ // ---------- 懒加载 ----------
410
+ function fillChildren(nodes: TreeNode[], path: string[], children: TreeNode[]): TreeNode[] {
411
+ return nodes.map(n => {
412
+ const v = String(n.value ?? n.key ?? '');
413
+ if (v !== path[0]) return n;
414
+ if (path.length === 1) return { ...n, children, loaded: true };
415
+ return { ...n, children: fillChildren(n.children || [], path.slice(1), children) };
416
+ });
417
+ }
418
+
419
+ async function loadChildren(node: TreeNodeItem, path: string[]) {
420
+ if (!url || node.loaded) return;
421
+ loadingPath = path.join('/');
422
+ try {
423
+ const sep = url.includes('?') ? '&' : '?';
424
+ const res = await fetch(`${url}${sep}parent=${encodeURIComponent(node.value)}`);
425
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
426
+ const json: any = await res.json();
427
+ const list = normalizeRemoteTree(json);
428
+ if (remoteData) remoteData = fillChildren(remoteData, path, list);
429
+ } catch {
430
+ // 静默处理
431
+ } finally {
432
+ loadingPath = '';
433
+ }
434
+ }
435
+
436
+ // ---------- 面板开关 ----------
437
+ let open = $state(false);
438
+ let rootEl: HTMLElement | undefined = $state();
439
+
440
+ $effect(() => {
441
+ function onDocClick(e: MouseEvent) {
442
+ if (rootEl && !rootEl.contains(e.target as globalThis.Node)) open = false;
443
+ }
444
+ document.addEventListener('mousedown', onDocClick);
445
+ return () => document.removeEventListener('mousedown', onDocClick);
446
+ });
447
+
448
+ $effect(() => {
449
+ if (!url) { remoteData = null; return; }
450
+ loading = true;
451
+ let cancelled = false;
452
+ (async () => {
453
+ try {
454
+ const res = await fetch(url);
455
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
456
+ const json: any = await res.json();
457
+ if (cancelled) return;
458
+ const list = normalizeRemoteTree(json);
459
+ if (list.length === 0) throw new Error('响应中没有可用选项');
460
+ remoteData = list;
461
+ } catch {
462
+ // 静默回落
463
+ } finally {
464
+ if (!cancelled) loading = false;
465
+ }
466
+ })();
467
+ return () => { cancelled = true; };
468
+ });
469
+
470
+ $effect(() => {
471
+ if (open && searchable) {
472
+ query = '';
473
+ if (searchEl) searchEl.focus();
474
+ }
475
+ });
476
+
477
+ function toggleOpen() {
478
+ if (disabled) return;
479
+ open = !open;
480
+ if (open) expandAll();
481
+ }
482
+
483
+ function clearValue(e: MouseEvent) {
484
+ e.stopPropagation();
485
+ touched = true;
486
+ selected = [];
487
+ onchange(multiple ? [] : '');
488
+ }
489
+
490
+ const shownError = $derived(error || '');
491
+
492
+ // ---------- 渲染树节点 ----------
493
+ function renderNode(node: TreeNodeItem, depth: number = 0): any {
494
+ const isExpand = expanded.has(node.value);
495
+ const isLoad = loadingPath && loadingPath.startsWith(node.value + '/');
496
+ const checked = multiple ? isNodeChecked(node) : selected.includes(node.value);
497
+ const indeterminate = multiple && isNodeIndeterminate(node);
498
+ const isLoadingNode = loadingPath === node.value;
499
+
500
+ return {
501
+ node, depth, isExpand, isLoad, checked, indeterminate, isLoadingNode
502
+ };
503
+ }
504
+
505
+ /** 递归渲染节点(避免在 template 中使用复杂的递归逻辑) */
506
+ function collectVisibleNodes(nodes: TreeNodeItem[], depth: number = 0): ReturnType<typeof renderNode>[] {
507
+ const result: ReturnType<typeof renderNode>[] = [];
508
+ const activeTree = filteredTree !== null ? flattenFiltered(filteredTree) : nodes;
509
+ const treeToUse = filteredTree !== null ? filteredTree : nodes;
510
+
511
+ function walk(ns: TreeNodeItem[], d: number) {
512
+ for (const n of ns) {
513
+ result.push(renderNode(n, d));
514
+ if (!n.isLeaf && expanded.has(n.value)) {
515
+ walk(n.children, d + 1);
516
+ }
517
+ }
518
+ }
519
+ walk(treeToUse, 0);
520
+ return result;
521
+ }
522
+
523
+ function flattenFiltered(nodes: TreeNodeItem[]): TreeNodeItem[] {
524
+ const result: TreeNodeItem[] = [];
525
+ function collect(ns: TreeNodeItem[]) {
526
+ for (const n of ns) {
527
+ result.push(n);
528
+ collect(n.children);
529
+ }
530
+ }
531
+ collect(nodes);
532
+ return result;
533
+ }
534
+
535
+ const visibleNodes = $derived(collectVisibleNodes(tree));
536
+
537
+ /** 查找从根到目标节点的 value 路径(懒加载 fillChildren 按路径逐级定位挂载点) */
538
+ function nodePathOf(target: string, nodes: TreeNodeItem[] = tree): string[] | null {
539
+ for (const n of nodes) {
540
+ if (n.value === target) return [n.value];
541
+ if (n.children.length > 0) {
542
+ const sub = nodePathOf(target, n.children);
543
+ if (sub) return [n.value, ...sub];
544
+ }
545
+ }
546
+ return null;
547
+ }
548
+
549
+ function handleNodeClick(node: TreeNodeItem) {
550
+ if (disabled) return;
551
+ if (!node.isLeaf) {
552
+ toggleExpand(node.value);
553
+ if (lazy && url && !node.loaded) {
554
+ const path = nodePathOf(node.value) ?? [node.value];
555
+ void loadChildren(node, path);
556
+ }
557
+ } else {
558
+ toggleNode(node);
559
+ }
560
+ }
561
+
562
+ function handleCheck(node: TreeNodeItem, e: MouseEvent) {
563
+ e.stopPropagation();
564
+ toggleNode(node);
565
+ }
566
+
567
+ function handleNodeSelect(node: TreeNodeItem, e: MouseEvent) {
568
+ e.stopPropagation();
569
+ toggleNode(node);
570
+ }
571
+ </script>
572
+
573
+ <div class="selector widget-tree-select" bind:this={rootEl}>
574
+ {#if label || hint || required}
575
+ <div class="widget-label-row">
576
+ {#if required}<span class="widget-required-mark" aria-hidden="true">*</span>{/if}
577
+ {#if label}<label>{label}</label>{/if}
578
+ {#if hint}<span class="widget-hint" data-hint={hint}>ⓘ</span>{/if}
579
+ </div>
580
+ {/if}
581
+ <div class="widget-field-content">
582
+ <button type="button" class="ts-trigger" class:widget-input-error={!!shownError} onclick={toggleOpen} aria-expanded={open} {disabled}>
583
+ {#if loading}
584
+ <span class="ts-placeholder">加载中…</span>
585
+ {:else if selected.length > 0}
586
+ <span class="ts-value">{display}</span>
587
+ {:else}
588
+ <span class="ts-placeholder ts-placeholder-empty">{placeholder || '请选择'}</span>
589
+ {/if}
590
+ {#if !disabled && selected.length > 0}
591
+ <span class="ts-clear" role="button" tabindex="-1" aria-label="清空" onclick={(e) => clearValue(e)}>×</span>
592
+ {:else if !disabled}
593
+ <span class="ts-arrow" class:ts-arrow-open={open} aria-hidden="true">▾</span>
594
+ {/if}
595
+ </button>
596
+ {#if shownError}<div class="widget-error-text" role="alert">{shownError}</div>{/if}
597
+
598
+ {#if open}
599
+ <div class="ts-panel">
600
+ {#if searchable}
601
+ <div class="ts-search">
602
+ <input
603
+ bind:this={searchEl}
604
+ type="text"
605
+ class="ts-search-input"
606
+ bind:value={query}
607
+ placeholder="搜索节点…"
608
+ />
609
+ </div>
610
+ {/if}
611
+ <div class="ts-toolbar">
612
+ <button type="button" class="ts-tool-btn" onclick={expandAll}>展开全部</button>
613
+ <button type="button" class="ts-tool-btn" onclick={collapseAll}>折叠全部</button>
614
+ </div>
615
+ <div class="ts-tree" role="tree">
616
+ {#if visibleNodes.length === 0}
617
+ <div class="ts-empty">{loading ? '加载中…' : query.trim() ? '无匹配结果' : '暂无数据'}</div>
618
+ {:else}
619
+ {#each visibleNodes as { node, depth, isExpand, isLoadingNode } (node.value)}
620
+ <div
621
+ class="ts-node"
622
+ class:ts-node-selected={!multiple && selected.includes(node.value)}
623
+ class:ts-tree-line={treeLine}
624
+ style="padding-left: {depth * 20 + 8}px"
625
+ role="treeitem"
626
+ aria-expanded={!node.isLeaf ? isExpand : undefined}
627
+ >
628
+ {#if !node.isLeaf}
629
+ <button
630
+ type="button"
631
+ class="ts-expand-btn"
632
+ onclick={() => handleNodeClick(node)}
633
+ aria-label={isExpand ? '折叠' : '展开'}
634
+ >
635
+ {#if isLoadingNode}
636
+ <span class="ts-loading-icon"></span>
637
+ {:else}
638
+ <span class="ts-expand-arrow" class:ts-expanded={isExpand}>▶</span>
639
+ {/if}
640
+ </button>
641
+ {:else}
642
+ <span class="ts-leaf-spacer"></span>
643
+ {/if}
644
+
645
+ {#if multiple}
646
+ <button
647
+ type="button"
648
+ class="ts-checkbox"
649
+ class:ts-checked={isNodeChecked(node)}
650
+ class:ts-indeterminate={isNodeIndeterminate(node)}
651
+ onclick={(e) => handleCheck(node, e)}
652
+ aria-label={isNodeChecked(node) ? '取消选择' : '选择'}
653
+ >
654
+ {#if isNodeChecked(node)}
655
+ <span class="ts-check-icon">✓</span>
656
+ {:else if isNodeIndeterminate(node)}
657
+ <span class="ts-check-icon">−</span>
658
+ {/if}
659
+ </button>
660
+ {/if}
661
+
662
+ <button
663
+ type="button"
664
+ class="ts-label"
665
+ class:ts-label-selected={!multiple && selected.includes(node.value)}
666
+ onclick={(e) => handleNodeSelect(node, e)}
667
+ >
668
+ {node.label}
669
+ </button>
670
+ </div>
671
+ {/each}
672
+ {/if}
673
+ </div>
674
+ </div>
675
+ {/if}
676
+ </div>
677
+ </div>
678
+
679
+ <style>
680
+ .widget-tree-select {
681
+ position: relative;
682
+ }
683
+
684
+ .ts-trigger {
685
+ display: flex;
686
+ align-items: center;
687
+ gap: 0.375rem;
688
+ width: 100%;
689
+ padding: 0.625rem 0.75rem;
690
+ border: 1px solid #d9d9d9;
691
+ border-radius: 6px;
692
+ font-size: 0.875rem;
693
+ background-color: #ffffff;
694
+ cursor: pointer;
695
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
696
+ text-align: left;
697
+ }
698
+ .ts-trigger:hover { border-color: #4096ff; }
699
+ .ts-trigger:focus {
700
+ outline: none;
701
+ border-color: #1677ff;
702
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
703
+ }
704
+ .ts-trigger:disabled {
705
+ background-color: #f5f5f5;
706
+ color: rgba(0, 0, 0, 0.25);
707
+ border-color: #d9d9d9;
708
+ cursor: not-allowed;
709
+ }
710
+ .ts-trigger:disabled:hover { border-color: #d9d9d9; }
711
+
712
+ .ts-value {
713
+ flex: 1 1 auto;
714
+ min-width: 0;
715
+ overflow: hidden;
716
+ text-overflow: ellipsis;
717
+ white-space: nowrap;
718
+ color: rgba(0, 0, 0, 0.88);
719
+ }
720
+ .ts-placeholder {
721
+ flex: 1 1 auto;
722
+ color: rgba(0, 0, 0, 0.45);
723
+ }
724
+ .ts-placeholder-empty { color: rgba(0, 0, 0, 0.45); }
725
+ .ts-arrow { color: rgba(0, 0, 0, 0.45); font-size: 0.75rem; transition: transform 0.2s ease; }
726
+ .ts-arrow-open { transform: rotate(180deg); }
727
+ .ts-clear {
728
+ padding: 0 0.25rem;
729
+ color: rgba(0, 0, 0, 0.45);
730
+ font-size: 1rem;
731
+ line-height: 1;
732
+ }
733
+ .ts-clear:hover { color: rgba(0, 0, 0, 0.88); }
734
+
735
+ .ts-panel {
736
+ position: absolute;
737
+ z-index: 50;
738
+ top: calc(100% + 4px);
739
+ left: 0;
740
+ right: 0;
741
+ display: flex;
742
+ flex-direction: column;
743
+ background: #ffffff;
744
+ border: 1px solid #d9d9d9;
745
+ border-radius: 8px;
746
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
747
+ max-height: 280px;
748
+ overflow: hidden;
749
+ }
750
+
751
+ .ts-search {
752
+ padding: 0.5rem;
753
+ border-bottom: 1px solid #f0f0f0;
754
+ flex-shrink: 0;
755
+ }
756
+ .ts-search-input {
757
+ width: 100%;
758
+ padding: 0.375rem 0.625rem;
759
+ border: 1px solid #d9d9d9;
760
+ border-radius: 4px;
761
+ font-size: 0.8125rem;
762
+ box-sizing: border-box;
763
+ }
764
+ .ts-search-input:focus {
765
+ outline: none;
766
+ border-color: #1677ff;
767
+ box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1);
768
+ }
769
+
770
+ .ts-toolbar {
771
+ display: flex;
772
+ gap: 0.5rem;
773
+ padding: 0.375rem 0.5rem;
774
+ border-bottom: 1px solid #f0f0f0;
775
+ flex-shrink: 0;
776
+ }
777
+ .ts-tool-btn {
778
+ padding: 0.125rem 0.5rem;
779
+ border: 1px solid #d9d9d9;
780
+ border-radius: 4px;
781
+ background: #ffffff;
782
+ font-size: 0.75rem;
783
+ color: rgba(0, 0, 0, 0.65);
784
+ cursor: pointer;
785
+ transition: all 0.15s ease;
786
+ }
787
+ .ts-tool-btn:hover {
788
+ border-color: #1677ff;
789
+ color: #1677ff;
790
+ }
791
+
792
+ .ts-tree {
793
+ flex: 1 1 auto;
794
+ overflow-y: auto;
795
+ padding: 0.375rem 0;
796
+ min-height: 60px;
797
+ }
798
+
799
+ .ts-node {
800
+ display: flex;
801
+ align-items: center;
802
+ gap: 0.25rem;
803
+ padding: 0.25rem 0.5rem;
804
+ cursor: pointer;
805
+ border-radius: 4px;
806
+ transition: background 0.15s ease;
807
+ }
808
+ .ts-node:hover { background: #f5f5f5; }
809
+ .ts-node-selected { background: #e6f4ff; }
810
+ /* 树连接线(antd treeLine):横向连线 + 纵向缩进引导线 */
811
+ .ts-node.ts-tree-line { position: relative; }
812
+ .ts-node.ts-tree-line::before {
813
+ content: '';
814
+ position: absolute;
815
+ top: 0;
816
+ bottom: 0;
817
+ left: 8px;
818
+ border-left: 1px solid #d9d9d9;
819
+ }
820
+ .ts-node.ts-tree-line:first-child::before { top: 50%; }
821
+ .ts-node.ts-tree-line:last-child::before { bottom: 50%; }
822
+ .ts-node.ts-tree-line::after {
823
+ content: '';
824
+ position: absolute;
825
+ top: 50%;
826
+ left: 8px;
827
+ width: 12px;
828
+ border-top: 1px solid #d9d9d9;
829
+ }
830
+
831
+ .ts-expand-btn {
832
+ display: flex;
833
+ align-items: center;
834
+ justify-content: center;
835
+ width: 18px;
836
+ height: 18px;
837
+ border: none;
838
+ background: transparent;
839
+ padding: 0;
840
+ cursor: pointer;
841
+ flex-shrink: 0;
842
+ }
843
+ .ts-expand-arrow {
844
+ font-size: 0.625rem;
845
+ color: rgba(0, 0, 0, 0.45);
846
+ transition: transform 0.2s ease;
847
+ display: inline-block;
848
+ }
849
+ .ts-expand-arrow.ts-expanded { transform: rotate(90deg); }
850
+
851
+ .ts-loading-icon {
852
+ display: inline-block;
853
+ width: 12px;
854
+ height: 12px;
855
+ border: 2px solid #d9d9d9;
856
+ border-top-color: #1677ff;
857
+ border-radius: 50%;
858
+ animation: ts-spin 0.8s linear infinite;
859
+ }
860
+ @keyframes ts-spin {
861
+ to { transform: rotate(360deg); }
862
+ }
863
+
864
+ .ts-leaf-spacer {
865
+ display: inline-block;
866
+ width: 18px;
867
+ flex-shrink: 0;
868
+ }
869
+
870
+ .ts-checkbox {
871
+ display: flex;
872
+ align-items: center;
873
+ justify-content: center;
874
+ width: 16px;
875
+ height: 16px;
876
+ border: 1px solid #d9d9d9;
877
+ border-radius: 4px;
878
+ background: #ffffff;
879
+ cursor: pointer;
880
+ flex-shrink: 0;
881
+ transition: all 0.15s ease;
882
+ padding: 0;
883
+ }
884
+ .ts-checkbox:hover { border-color: #1677ff; }
885
+ .ts-checkbox.ts-checked {
886
+ background: #1677ff;
887
+ border-color: #1677ff;
888
+ color: #ffffff;
889
+ }
890
+ .ts-checkbox.ts-indeterminate {
891
+ background: #1677ff;
892
+ border-color: #1677ff;
893
+ color: #ffffff;
894
+ }
895
+
896
+ .ts-check-icon {
897
+ font-size: 0.75rem;
898
+ font-weight: 700;
899
+ line-height: 1;
900
+ }
901
+
902
+ .ts-label {
903
+ flex: 1 1 auto;
904
+ min-width: 0;
905
+ border: none;
906
+ background: transparent;
907
+ font-size: 0.875rem;
908
+ color: rgba(0, 0, 0, 0.88);
909
+ cursor: pointer;
910
+ text-align: left;
911
+ padding: 0;
912
+ overflow: hidden;
913
+ text-overflow: ellipsis;
914
+ white-space: nowrap;
915
+ }
916
+ .ts-label.ts-label-selected { color: #1677ff; font-weight: 500; }
917
+
918
+ .ts-empty {
919
+ padding: 0.75rem 0.5rem;
920
+ font-size: 0.8125rem;
921
+ color: rgba(0, 0, 0, 0.45);
922
+ text-align: center;
923
+ }
924
+ </style>