@c2-markdown-ui/react 0.4.4 → 0.4.5
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/README.md +24 -6
- package/dist/index.js +4365 -3733
- package/package.json +2 -2
- package/src/lib/MarkdownUI.tsx +47 -9
- package/src/lib/widgets.css +2188 -1372
- package/src/widgets/Button.tsx +39 -39
- package/src/widgets/ButtonGroup.tsx +47 -47
- package/src/widgets/Cascader.tsx +3 -2
- package/src/widgets/Chart.tsx +237 -237
- package/src/widgets/DatePicker.tsx +451 -434
- package/src/widgets/Form.tsx +512 -372
- package/src/widgets/Incomplete.tsx +3 -3
- package/src/widgets/MultipleChoiceQuestion.tsx +82 -82
- package/src/widgets/Quiz.tsx +320 -320
- package/src/widgets/Radio.tsx +57 -26
- package/src/widgets/Select.tsx +178 -142
- package/src/widgets/SelectMulti.tsx +105 -72
- package/src/widgets/ShortAnswerQuestion.tsx +101 -101
- package/src/widgets/Slider.tsx +70 -56
- package/src/widgets/Switch.tsx +14 -2
- package/src/widgets/TextInput.tsx +76 -38
- package/src/widgets/TreeSelect.tsx +649 -0
- package/src/widgets/Widget.tsx +130 -82
- package/src/widgets/choiceUtils.ts +90 -45
- package/src/widgets/index.ts +17 -16
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/MarkdownUI.d.ts +0 -10
- package/dist/lib/MarkdownUI.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -4
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/widgets/Button.d.ts +0 -15
- package/dist/widgets/Button.d.ts.map +0 -1
- package/dist/widgets/ButtonGroup.d.ts +0 -11
- package/dist/widgets/ButtonGroup.d.ts.map +0 -1
- package/dist/widgets/Cascader.d.ts +0 -34
- package/dist/widgets/Cascader.d.ts.map +0 -1
- package/dist/widgets/Chart.d.ts +0 -20
- package/dist/widgets/Chart.d.ts.map +0 -1
- package/dist/widgets/DatePicker.d.ts +0 -28
- package/dist/widgets/DatePicker.d.ts.map +0 -1
- package/dist/widgets/Form.d.ts +0 -47
- package/dist/widgets/Form.d.ts.map +0 -1
- package/dist/widgets/Incomplete.d.ts +0 -3
- package/dist/widgets/Incomplete.d.ts.map +0 -1
- package/dist/widgets/MultipleChoiceQuestion.d.ts +0 -15
- package/dist/widgets/MultipleChoiceQuestion.d.ts.map +0 -1
- package/dist/widgets/NumberInput.d.ts +0 -26
- package/dist/widgets/NumberInput.d.ts.map +0 -1
- package/dist/widgets/Quiz.d.ts +0 -37
- package/dist/widgets/Quiz.d.ts.map +0 -1
- package/dist/widgets/Radio.d.ts +0 -24
- package/dist/widgets/Radio.d.ts.map +0 -1
- package/dist/widgets/Select.d.ts +0 -15
- package/dist/widgets/Select.d.ts.map +0 -1
- package/dist/widgets/SelectMulti.d.ts +0 -20
- package/dist/widgets/SelectMulti.d.ts.map +0 -1
- package/dist/widgets/ShortAnswerQuestion.d.ts +0 -16
- package/dist/widgets/ShortAnswerQuestion.d.ts.map +0 -1
- package/dist/widgets/Slider.d.ts +0 -13
- package/dist/widgets/Slider.d.ts.map +0 -1
- package/dist/widgets/Switch.d.ts +0 -17
- package/dist/widgets/Switch.d.ts.map +0 -1
- package/dist/widgets/TextInput.d.ts +0 -11
- package/dist/widgets/TextInput.d.ts.map +0 -1
- package/dist/widgets/Widget.d.ts +0 -7
- package/dist/widgets/Widget.d.ts.map +0 -1
- package/dist/widgets/chartRemoteData.d.ts +0 -18
- package/dist/widgets/chartRemoteData.d.ts.map +0 -1
- package/dist/widgets/choiceUtils.d.ts +0 -24
- package/dist/widgets/choiceUtils.d.ts.map +0 -1
- package/dist/widgets/index.d.ts +0 -18
- package/dist/widgets/index.d.ts.map +0 -1
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface TreeSelectProps {
|
|
4
|
+
choices: string[];
|
|
5
|
+
/** 嵌套对象树数据源(与扁平路径 choices 兼容共存,优先于 choices) */
|
|
6
|
+
data?: TreeNode[];
|
|
7
|
+
/** 远端数据源 URL */
|
|
8
|
+
url?: string;
|
|
9
|
+
/** 懒加载模式(url 存在时生效):首次只拉根级,点击父级后逐级请求子级 */
|
|
10
|
+
lazy?: boolean;
|
|
11
|
+
/** 多选模式(antd multiple,默认单选):开启后显示复选框,返回 value[] */
|
|
12
|
+
multiple?: boolean;
|
|
13
|
+
/** 父子节点选中不关联(antd checkStrictly) */
|
|
14
|
+
checkStrictly?: boolean;
|
|
15
|
+
/** 选中值回显策略:all / parent / child(antd showCheckedStrategy) */
|
|
16
|
+
showCheckedStrategy?: string;
|
|
17
|
+
/** 触发器最多显示的标签数(antd maxTagCount) */
|
|
18
|
+
maxTagCount?: number;
|
|
19
|
+
/** 树连接线(antd treeLine) */
|
|
20
|
+
treeLine?: boolean;
|
|
21
|
+
label?: string;
|
|
22
|
+
default?: string | string[];
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
hint?: string;
|
|
25
|
+
required?: boolean;
|
|
26
|
+
message?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
/** 是否禁用 */
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** 是否可搜索:开启后下拉面板内置搜索框(antd showSearch,默认关闭) */
|
|
31
|
+
searchable?: boolean;
|
|
32
|
+
onchange: (value: string | string[]) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TreeNode {
|
|
36
|
+
value?: string | number;
|
|
37
|
+
key?: string | number;
|
|
38
|
+
label?: string | number;
|
|
39
|
+
children?: TreeNode[];
|
|
40
|
+
loaded?: boolean;
|
|
41
|
+
[extra: string]: any;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Node {
|
|
45
|
+
value: string;
|
|
46
|
+
label: string;
|
|
47
|
+
children: Node[];
|
|
48
|
+
isLeaf: boolean;
|
|
49
|
+
loaded?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseSeg(seg: string): { value: string; label: string } {
|
|
53
|
+
const idx = seg.indexOf(':');
|
|
54
|
+
if (idx > 0) {
|
|
55
|
+
const v = seg.slice(0, idx).trim();
|
|
56
|
+
const l = seg.slice(idx + 1).trim();
|
|
57
|
+
if (v && l) return { value: v, label: l };
|
|
58
|
+
}
|
|
59
|
+
return { value: seg.trim(), label: seg.trim() };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nodesToTree(nodes: TreeNode[] | undefined, isLazy = false): Node[] {
|
|
63
|
+
if (!Array.isArray(nodes)) return [];
|
|
64
|
+
return nodes.map(n => {
|
|
65
|
+
const v = n.value ?? n.key;
|
|
66
|
+
const children = n.children;
|
|
67
|
+
const hasChildren = Array.isArray(children) && children.length > 0;
|
|
68
|
+
const wasLoaded = n.loaded === true;
|
|
69
|
+
return {
|
|
70
|
+
value: String(v ?? ''),
|
|
71
|
+
label: String(n.label ?? v ?? ''),
|
|
72
|
+
children: hasChildren ? nodesToTree(children, isLazy) : [],
|
|
73
|
+
isLeaf: isLazy ? !hasChildren && wasLoaded : !hasChildren,
|
|
74
|
+
loaded: hasChildren || wasLoaded,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeRemoteTree(json: any): TreeNode[] {
|
|
80
|
+
let list = json;
|
|
81
|
+
if (!Array.isArray(json)) {
|
|
82
|
+
list = Array.isArray(json?.data) ? json.data
|
|
83
|
+
: Array.isArray(json?.options) ? json.options
|
|
84
|
+
: Array.isArray(json?.choices) ? json.choices
|
|
85
|
+
: Array.isArray(json?.items) ? json.items
|
|
86
|
+
: Array.isArray(json?.tree) ? json.tree
|
|
87
|
+
: Array.isArray(json?.treeData) ? json.treeData
|
|
88
|
+
: [];
|
|
89
|
+
}
|
|
90
|
+
const map = (arr: any[]): TreeNode[] =>
|
|
91
|
+
(arr || [])
|
|
92
|
+
.map((it: any): TreeNode | null => {
|
|
93
|
+
if (!it || typeof it !== 'object') return null;
|
|
94
|
+
const value = it.value ?? it.key;
|
|
95
|
+
if (value === undefined || value === null || value === '') return null;
|
|
96
|
+
const node: TreeNode = { value: String(value) };
|
|
97
|
+
const label = it.label ?? it.name ?? it.text;
|
|
98
|
+
if (label !== undefined && label !== null) node.label = String(label);
|
|
99
|
+
if (Array.isArray(it.children) && it.children.length > 0) {
|
|
100
|
+
node.children = map(it.children);
|
|
101
|
+
}
|
|
102
|
+
return node;
|
|
103
|
+
})
|
|
104
|
+
.filter((n: TreeNode | null): n is TreeNode => n !== null);
|
|
105
|
+
return map(list);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const TreeSelect: React.FC<TreeSelectProps> = ({
|
|
109
|
+
choices,
|
|
110
|
+
data,
|
|
111
|
+
url = '',
|
|
112
|
+
lazy = false,
|
|
113
|
+
multiple = false,
|
|
114
|
+
checkStrictly = false,
|
|
115
|
+
showCheckedStrategy = 'all',
|
|
116
|
+
maxTagCount,
|
|
117
|
+
treeLine = false,
|
|
118
|
+
label = '',
|
|
119
|
+
default: initial,
|
|
120
|
+
placeholder = '',
|
|
121
|
+
hint = '',
|
|
122
|
+
required = false,
|
|
123
|
+
message = '',
|
|
124
|
+
error = '',
|
|
125
|
+
disabled = false,
|
|
126
|
+
searchable = false,
|
|
127
|
+
onchange
|
|
128
|
+
}) => {
|
|
129
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
131
|
+
const [open, setOpen] = useState(false);
|
|
132
|
+
const [query, setQuery] = useState('');
|
|
133
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
134
|
+
const [remoteData, setRemoteData] = useState<TreeNode[] | null>(null);
|
|
135
|
+
const [loading, setLoading] = useState(false);
|
|
136
|
+
const [loadingPath, setLoadingPath] = useState('');
|
|
137
|
+
|
|
138
|
+
/** 默认值语义(对齐 antd TreeSelect defaultValue):
|
|
139
|
+
* 逗号分隔多值;每个值支持 / 分隔路径(zj/hz)或节点 value(hz),
|
|
140
|
+
* 路径写法按段逐级查找(段先按 value 匹配、再按 label 匹配),定位到路径末端节点;
|
|
141
|
+
* 单段 value 在当前层级找不到时,从整棵树深度查找(antd 语义:value 全树唯一)。
|
|
142
|
+
* 返回选中节点 value 数组 + 路径祖先 value 数组(用于初始展开) */
|
|
143
|
+
const resolveInitial = (treeNodes: Node[]): { values: string[]; ancestors: string[] } => {
|
|
144
|
+
if (!initial) return { values: [], ancestors: [] };
|
|
145
|
+
const paths = Array.isArray(initial)
|
|
146
|
+
? initial.map(String)
|
|
147
|
+
: initial.split(',').map(s => s.trim()).filter(Boolean);
|
|
148
|
+
const values: string[] = [];
|
|
149
|
+
const ancestors: string[] = [];
|
|
150
|
+
/** 从整棵树深度查找节点,返回从根到命中节点的 value 链 */
|
|
151
|
+
const deepFind = (nodes: Node[], seg: string, chain: string[]): string[] | null => {
|
|
152
|
+
for (const n of nodes) {
|
|
153
|
+
if (n.value === seg || n.label === seg) return [...chain, n.value];
|
|
154
|
+
const sub = deepFind(n.children, seg, [...chain, n.value]);
|
|
155
|
+
if (sub) return sub;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
for (const path of paths) {
|
|
160
|
+
const segs = path.split('/').map(s => s.trim()).filter(Boolean);
|
|
161
|
+
let level = treeNodes;
|
|
162
|
+
let node: Node | undefined;
|
|
163
|
+
const chain: string[] = [];
|
|
164
|
+
for (const seg of segs) {
|
|
165
|
+
const found = level.find(n => n.value === seg) ?? level.find(n => n.label === seg);
|
|
166
|
+
if (!found) { node = undefined; break; }
|
|
167
|
+
chain.push(found.value);
|
|
168
|
+
node = found;
|
|
169
|
+
level = found.children;
|
|
170
|
+
}
|
|
171
|
+
if (node) {
|
|
172
|
+
values.push(node.value);
|
|
173
|
+
ancestors.push(...chain.slice(0, -1));
|
|
174
|
+
} else if (segs.length === 1) {
|
|
175
|
+
// 单段 value 逐级未命中(根级找不到):从整棵树深度查找(antd defaultValue 语义)
|
|
176
|
+
const deep = deepFind(treeNodes, segs[0], []);
|
|
177
|
+
if (deep) {
|
|
178
|
+
values.push(deep[deep.length - 1]);
|
|
179
|
+
ancestors.push(...deep.slice(0, -1));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
values: [...new Set(values)],
|
|
185
|
+
ancestors: [...new Set(ancestors)]
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
189
|
+
|
|
190
|
+
const tree = useMemo(() => {
|
|
191
|
+
if (remoteData) return nodesToTree(remoteData, lazy);
|
|
192
|
+
if (Array.isArray(data) && data.length > 0) return nodesToTree(data, lazy);
|
|
193
|
+
const root: Node[] = [];
|
|
194
|
+
for (const path of choices) {
|
|
195
|
+
if (!path) continue;
|
|
196
|
+
const segs = path.split('/').map(s => s.trim()).filter(Boolean);
|
|
197
|
+
if (segs.length === 0) continue;
|
|
198
|
+
let level = root;
|
|
199
|
+
for (let i = 0; i < segs.length; i++) {
|
|
200
|
+
const { value, label: lbl } = parseSeg(segs[i]);
|
|
201
|
+
let node = level.find(n => n.value === value);
|
|
202
|
+
if (!node) {
|
|
203
|
+
node = { value, label: lbl, children: [], isLeaf: i === segs.length - 1 };
|
|
204
|
+
level.push(node);
|
|
205
|
+
}
|
|
206
|
+
level = node.children;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return root;
|
|
210
|
+
}, [choices, data, remoteData, lazy]);
|
|
211
|
+
|
|
212
|
+
// 用户是否交互过(勾选/清空):交互后不再跟随 default 变化,避免覆盖用户选择
|
|
213
|
+
const touchedRef = useRef(false);
|
|
214
|
+
// 挂载/数据源就绪/default 变化时回填默认值(展开祖先采用合并而非重置,避免懒加载
|
|
215
|
+
// 填充子级引起 tree 引用变化时,把用户已展开的节点折叠掉 —— 对齐 vue 版行为)
|
|
216
|
+
const initialKey = Array.isArray(initial) ? initial.join(',') : (initial ?? '');
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (touchedRef.current) return;
|
|
219
|
+
const { values, ancestors } = resolveInitial(tree);
|
|
220
|
+
setSelected(values);
|
|
221
|
+
setExpanded(prev => {
|
|
222
|
+
const next = new Set(prev);
|
|
223
|
+
ancestors.forEach(a => next.add(a));
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
}, [initialKey, tree]);
|
|
227
|
+
|
|
228
|
+
// 懒加载
|
|
229
|
+
function fillChildren(nodes: TreeNode[], path: string[], children: TreeNode[]): TreeNode[] {
|
|
230
|
+
return nodes.map(n => {
|
|
231
|
+
const v = String(n.value ?? n.key ?? '');
|
|
232
|
+
if (v !== path[0]) return n;
|
|
233
|
+
if (path.length === 1) return { ...n, children, loaded: true };
|
|
234
|
+
return { ...n, children: fillChildren(n.children || [], path.slice(1), children) };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function loadChildren(node: Node, path: string[]) {
|
|
239
|
+
if (!url || node.loaded) return;
|
|
240
|
+
setLoadingPath(path.join('/'));
|
|
241
|
+
try {
|
|
242
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
243
|
+
const res = await fetch(`${url}${sep}parent=${encodeURIComponent(node.value)}`);
|
|
244
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
245
|
+
const json: any = await res.json();
|
|
246
|
+
const list = normalizeRemoteTree(json);
|
|
247
|
+
setRemoteData(prev => prev ? fillChildren(prev, path, list) : null);
|
|
248
|
+
} catch { /* 静默 */ }
|
|
249
|
+
finally { setLoadingPath(''); }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 远端拉取
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!url) { setRemoteData(null); return; }
|
|
255
|
+
setLoading(true);
|
|
256
|
+
let cancelled = false;
|
|
257
|
+
(async () => {
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch(url);
|
|
260
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
261
|
+
const json: any = await res.json();
|
|
262
|
+
if (cancelled) return;
|
|
263
|
+
const list = normalizeRemoteTree(json);
|
|
264
|
+
if (list.length === 0) throw new Error('响应中没有可用选项');
|
|
265
|
+
setRemoteData(list);
|
|
266
|
+
} catch { /* 静默回落 */ }
|
|
267
|
+
finally { if (!cancelled) setLoading(false); }
|
|
268
|
+
})();
|
|
269
|
+
return () => { cancelled = true; };
|
|
270
|
+
}, [url]);
|
|
271
|
+
|
|
272
|
+
// 外部点击关闭
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
function onDocClick(e: MouseEvent) {
|
|
275
|
+
if (rootRef.current && !rootRef.current.contains(e.target as globalThis.Node)) setOpen(false);
|
|
276
|
+
}
|
|
277
|
+
document.addEventListener('mousedown', onDocClick);
|
|
278
|
+
return () => document.removeEventListener('mousedown', onDocClick);
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
// 打开时聚焦搜索框
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (open && searchable && searchRef.current) {
|
|
284
|
+
setQuery('');
|
|
285
|
+
searchRef.current.focus();
|
|
286
|
+
}
|
|
287
|
+
}, [open, searchable]);
|
|
288
|
+
|
|
289
|
+
// 展开/折叠
|
|
290
|
+
function toggleExpand(value: string) {
|
|
291
|
+
setExpanded(prev => {
|
|
292
|
+
const next = new Set(prev);
|
|
293
|
+
if (next.has(value)) next.delete(value);
|
|
294
|
+
else next.add(value);
|
|
295
|
+
return next;
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function expandAll() {
|
|
300
|
+
const collect = (nodes: Node[], ancestors: string[] = []) => {
|
|
301
|
+
const result = new Set<string>();
|
|
302
|
+
const walk = (ns: Node[]) => {
|
|
303
|
+
for (const n of ns) {
|
|
304
|
+
if (n.isLeaf) continue;
|
|
305
|
+
// 懒加载模式:未加载子级的节点不预置展开(首次点击时展开并逐级拉取)
|
|
306
|
+
if (lazy && url && !n.loaded) continue;
|
|
307
|
+
result.add(n.value);
|
|
308
|
+
walk(n.children);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
walk(nodes);
|
|
312
|
+
return result;
|
|
313
|
+
};
|
|
314
|
+
setExpanded(collect(tree));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function collapseAll() {
|
|
318
|
+
setExpanded(new Set());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 选择逻辑
|
|
322
|
+
function getAllValues(node: Node): string[] {
|
|
323
|
+
const vals = [node.value];
|
|
324
|
+
for (const c of node.children) vals.push(...getAllValues(c));
|
|
325
|
+
return vals;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function findNode(value: string, nodes: Node[] = tree): Node | undefined {
|
|
329
|
+
for (const n of nodes) {
|
|
330
|
+
if (n.value === value) return n;
|
|
331
|
+
const found = findNode(value, n.children);
|
|
332
|
+
if (found) return found;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 节点是否被全选:checkStrictly 下仅看自身;关联模式下所有后代都被选中 */
|
|
337
|
+
function isNodeChecked(node: Node): boolean {
|
|
338
|
+
if (checkStrictly || !multiple) return selected.includes(node.value);
|
|
339
|
+
if (node.children.length === 0) return selected.includes(node.value);
|
|
340
|
+
return node.children.every(c => isNodeChecked(c));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isNodeIndeterminate(node: Node): boolean {
|
|
344
|
+
if (checkStrictly || node.children.length === 0) return false;
|
|
345
|
+
const childStates = node.children.map(c => isNodeChecked(c) || isNodeIndeterminate(c));
|
|
346
|
+
return childStates.some(Boolean) && !childStates.every(Boolean);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 按回显策略(antd showCheckedStrategy)过滤选中值:
|
|
350
|
+
* all 全部 / parent 仅返回最顶层的全选父级 / child 仅返回叶子 */
|
|
351
|
+
function applyStrategy(values: string[]): string[] {
|
|
352
|
+
if (!multiple || checkStrictly || showCheckedStrategy === 'all') return values;
|
|
353
|
+
const set = new Set(values);
|
|
354
|
+
if (showCheckedStrategy === 'child') {
|
|
355
|
+
return values.filter(v => {
|
|
356
|
+
const n = findNode(v);
|
|
357
|
+
return !n || n.children.length === 0;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// parent:级联模式下勾满的父级回显为父级本身,其全部后代不单独显示;
|
|
361
|
+
// 未勾满的父级不显示,只显示其下被选中的叶子/子父级。
|
|
362
|
+
// 注意"父级全选"不能只看 set.has(父级value) —— 级联勾子节点时 selected 里
|
|
363
|
+
// 只有叶子值,父级 value 不在其中,需按"所有后代均被选中"判定
|
|
364
|
+
const result: string[] = [];
|
|
365
|
+
const fullyChecked = (n: Node): boolean =>
|
|
366
|
+
n.children.length === 0 ? set.has(n.value) : n.children.every(fullyChecked);
|
|
367
|
+
const walk = (nodes: Node[]) => {
|
|
368
|
+
for (const n of nodes) {
|
|
369
|
+
if (n.children.length > 0 && fullyChecked(n)) {
|
|
370
|
+
// 全选父级:回显自身,跳过后代
|
|
371
|
+
result.push(n.value);
|
|
372
|
+
} else {
|
|
373
|
+
walk(n.children);
|
|
374
|
+
if (n.children.length === 0 && set.has(n.value)) result.push(n.value);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
walk(tree);
|
|
379
|
+
// selected 中可能存在树外的值(如懒加载未加载的分支),原样保留
|
|
380
|
+
const known = new Set<string>();
|
|
381
|
+
const collect = (nodes: Node[]) => {
|
|
382
|
+
for (const n of nodes) { known.add(n.value); collect(n.children); }
|
|
383
|
+
};
|
|
384
|
+
collect(tree);
|
|
385
|
+
for (const v of values) if (!known.has(v)) result.push(v);
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function toggleNode(node: Node) {
|
|
390
|
+
touchedRef.current = true;
|
|
391
|
+
if (multiple) {
|
|
392
|
+
if (checkStrictly) {
|
|
393
|
+
// 父子不关联:只切换单节点
|
|
394
|
+
const next = selected.includes(node.value)
|
|
395
|
+
? selected.filter(v => v !== node.value)
|
|
396
|
+
: [...selected, node.value];
|
|
397
|
+
setSelected(next);
|
|
398
|
+
onchange(next);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const allVals = getAllValues(node);
|
|
402
|
+
const allSelected = allVals.every(v => selected.includes(v));
|
|
403
|
+
let next: string[];
|
|
404
|
+
if (allSelected) {
|
|
405
|
+
next = selected.filter(v => !allVals.includes(v));
|
|
406
|
+
} else {
|
|
407
|
+
const newSelected = new Set(selected);
|
|
408
|
+
allVals.forEach(v => newSelected.add(v));
|
|
409
|
+
next = Array.from(newSelected);
|
|
410
|
+
}
|
|
411
|
+
setSelected(next);
|
|
412
|
+
onchange(applyStrategy(next));
|
|
413
|
+
} else {
|
|
414
|
+
setSelected([node.value]);
|
|
415
|
+
onchange(node.value);
|
|
416
|
+
setOpen(false);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 展示文案
|
|
421
|
+
function getLabel(value: string): string {
|
|
422
|
+
function find(nodes: Node[], v: string): string | undefined {
|
|
423
|
+
for (const n of nodes) {
|
|
424
|
+
if (n.value === v) return n.label;
|
|
425
|
+
const found = find(n.children, v);
|
|
426
|
+
if (found) return found;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return find(tree, value) ?? value;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 触发器最多显示的标签数(antd maxTagCount),不传则不限制
|
|
433
|
+
const limit = typeof maxTagCount === 'number' && maxTagCount >= 0 ? maxTagCount : Infinity;
|
|
434
|
+
|
|
435
|
+
// 策略过滤后的全部选中项(label 列表),悬浮提示展示用
|
|
436
|
+
const allTags = useMemo(() => {
|
|
437
|
+
if (!multiple || selected.length === 0) return [] as string[];
|
|
438
|
+
return applyStrategy(selected).map(v => getLabel(v));
|
|
439
|
+
}, [selected, tree, multiple, checkStrictly, showCheckedStrategy]);
|
|
440
|
+
|
|
441
|
+
const display = useMemo(() => {
|
|
442
|
+
if (selected.length === 0) return '';
|
|
443
|
+
if (!multiple) return getLabel(selected[0]);
|
|
444
|
+
const parts = allTags.slice(0, limit);
|
|
445
|
+
if (allTags.length > limit) parts.push(`+${allTags.length - limit}`);
|
|
446
|
+
return parts.join(', ');
|
|
447
|
+
}, [allTags, limit, multiple]);
|
|
448
|
+
|
|
449
|
+
// 过滤树
|
|
450
|
+
const filteredTree = useMemo(() => {
|
|
451
|
+
const q = query.trim().toLowerCase();
|
|
452
|
+
if (!q) return null;
|
|
453
|
+
function prune(node: Node): Node | null {
|
|
454
|
+
if (node.label.toLowerCase().includes(q)) return node;
|
|
455
|
+
const filteredChildren = node.children.map(c => prune(c)).filter((c): c is Node => c !== null);
|
|
456
|
+
if (filteredChildren.length > 0) return { ...node, children: filteredChildren };
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
const pruned = tree.map(n => prune(n)).filter((n): n is Node => n !== null);
|
|
460
|
+
// 无匹配时返回空数组(而非 null):null 表示"未搜索"会回退到全树,
|
|
461
|
+
// 空数组才能让 visibleNodes 为空、渲染 ts-empty"无匹配结果"
|
|
462
|
+
return pruned;
|
|
463
|
+
}, [tree, query]);
|
|
464
|
+
|
|
465
|
+
// 搜索时自动展开匹配路径的祖先
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (query.trim() && filteredTree !== null && filteredTree.length > 0) {
|
|
468
|
+
const collect = (nodes: Node[]) => {
|
|
469
|
+
const result = new Set<string>();
|
|
470
|
+
const walk = (ns: Node[]) => {
|
|
471
|
+
for (const n of ns) {
|
|
472
|
+
result.add(n.value);
|
|
473
|
+
if (n.children.length > 0) walk(n.children);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
walk(nodes);
|
|
477
|
+
return result;
|
|
478
|
+
};
|
|
479
|
+
setExpanded(collect(filteredTree));
|
|
480
|
+
}
|
|
481
|
+
}, [query, filteredTree]);
|
|
482
|
+
|
|
483
|
+
// 收集可见节点(扁平列表)
|
|
484
|
+
interface VisibleNode { node: Node; depth: number; isExpand: boolean; isLoadingNode: boolean; }
|
|
485
|
+
const visibleNodes = useMemo((): VisibleNode[] => {
|
|
486
|
+
const result: VisibleNode[] = [];
|
|
487
|
+
const treeToUse = filteredTree !== null ? filteredTree : tree;
|
|
488
|
+
const walk = (nodes: Node[], depth: number) => {
|
|
489
|
+
for (const n of nodes) {
|
|
490
|
+
result.push({ node: n, depth, isExpand: expanded.has(n.value), isLoadingNode: loadingPath === n.value });
|
|
491
|
+
if (!n.isLeaf && expanded.has(n.value)) walk(n.children, depth + 1);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
walk(treeToUse, 0);
|
|
495
|
+
return result;
|
|
496
|
+
}, [tree, filteredTree, expanded, loadingPath]);
|
|
497
|
+
|
|
498
|
+
// 从根到节点的完整 value 路径(fillChildren 定位挂载点用)
|
|
499
|
+
function nodePathOf(target: string, nodes: Node[] = tree): string[] | null {
|
|
500
|
+
for (const n of nodes) {
|
|
501
|
+
if (n.value === target) return [n.value];
|
|
502
|
+
if (n.children.length > 0) {
|
|
503
|
+
const sub = nodePathOf(target, n.children);
|
|
504
|
+
if (sub) return [n.value, ...sub];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function handleNodeClick(node: Node) {
|
|
511
|
+
if (disabled) return;
|
|
512
|
+
if (!node.isLeaf) {
|
|
513
|
+
toggleExpand(node.value);
|
|
514
|
+
if (lazy && url && !node.loaded) {
|
|
515
|
+
const path = nodePathOf(node.value) ?? [node.value];
|
|
516
|
+
void loadChildren(node, path);
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
toggleNode(node);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function handleCheck(node: Node, e: React.MouseEvent) {
|
|
524
|
+
e.stopPropagation();
|
|
525
|
+
toggleNode(node);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function handleNodeSelect(node: Node, e: React.MouseEvent) {
|
|
529
|
+
e.stopPropagation();
|
|
530
|
+
toggleNode(node);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const shownError = error || '';
|
|
534
|
+
const showClear = !disabled && selected.length > 0;
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<div className="selector widget-tree-select" ref={rootRef}>
|
|
538
|
+
{(label || hint || required) && (
|
|
539
|
+
<div className="widget-label-row">
|
|
540
|
+
{required && <span className="widget-required-mark" aria-hidden="true">*</span>}
|
|
541
|
+
{label && <label>{label}</label>}
|
|
542
|
+
{hint && <span className="widget-hint" data-hint={hint}>ⓘ</span>}
|
|
543
|
+
</div>
|
|
544
|
+
)}
|
|
545
|
+
<div className="widget-field-content">
|
|
546
|
+
<button
|
|
547
|
+
type="button"
|
|
548
|
+
className={`ts-trigger${shownError ? ' widget-input-error' : ''}`}
|
|
549
|
+
onClick={() => { if (!disabled) setOpen(prev => !prev); }}
|
|
550
|
+
aria-expanded={open}
|
|
551
|
+
disabled={disabled}
|
|
552
|
+
data-full={allTags.join(', ')}
|
|
553
|
+
>
|
|
554
|
+
{loading ? (
|
|
555
|
+
<span className="ts-placeholder">加载中…</span>
|
|
556
|
+
) : selected.length > 0 ? (
|
|
557
|
+
<span className="ts-value">{display}</span>
|
|
558
|
+
) : (
|
|
559
|
+
<span className="ts-placeholder ts-placeholder-empty">{placeholder || '请选择'}</span>
|
|
560
|
+
)}
|
|
561
|
+
{showClear ? (
|
|
562
|
+
<span className="ts-clear" role="button" tabIndex={-1} aria-label="清空"
|
|
563
|
+
onClick={(e) => { e.stopPropagation(); touchedRef.current = true; setSelected([]); onchange(multiple ? [] : ''); }}>×</span>
|
|
564
|
+
) : !disabled && (
|
|
565
|
+
<span className={`ts-arrow${open ? ' ts-arrow-open' : ''}`} aria-hidden="true">▾</span>
|
|
566
|
+
)}
|
|
567
|
+
</button>
|
|
568
|
+
{shownError && <div className="widget-error-text" role="alert">{shownError}</div>}
|
|
569
|
+
|
|
570
|
+
{open && (
|
|
571
|
+
<div className="ts-panel">
|
|
572
|
+
{searchable && (
|
|
573
|
+
<div className="ts-search">
|
|
574
|
+
<input
|
|
575
|
+
ref={searchRef}
|
|
576
|
+
type="text"
|
|
577
|
+
className="ts-search-input"
|
|
578
|
+
value={query}
|
|
579
|
+
placeholder="搜索节点…"
|
|
580
|
+
onChange={e => setQuery(e.target.value)}
|
|
581
|
+
/>
|
|
582
|
+
</div>
|
|
583
|
+
)}
|
|
584
|
+
<div className="ts-toolbar">
|
|
585
|
+
<button type="button" className="ts-tool-btn" onClick={expandAll}>展开全部</button>
|
|
586
|
+
<button type="button" className="ts-tool-btn" onClick={collapseAll}>折叠全部</button>
|
|
587
|
+
</div>
|
|
588
|
+
<div className="ts-tree" role="tree">
|
|
589
|
+
{visibleNodes.length === 0 ? (
|
|
590
|
+
<div className="ts-empty">{loading ? '加载中…' : query.trim() ? '无匹配结果' : '暂无数据'}</div>
|
|
591
|
+
) : (
|
|
592
|
+
visibleNodes.map(({ node, depth, isExpand, isLoadingNode }) => {
|
|
593
|
+
const checked = multiple ? isNodeChecked(node) : selected.includes(node.value);
|
|
594
|
+
const indeterminate = multiple && isNodeIndeterminate(node);
|
|
595
|
+
return (
|
|
596
|
+
<div
|
|
597
|
+
key={node.value}
|
|
598
|
+
className={`ts-node${!multiple && selected.includes(node.value) ? ' ts-node-selected' : ''}${treeLine ? ' ts-tree-line' : ''}`}
|
|
599
|
+
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
|
600
|
+
role="treeitem"
|
|
601
|
+
aria-expanded={!node.isLeaf ? isExpand : undefined}
|
|
602
|
+
>
|
|
603
|
+
{!node.isLeaf ? (
|
|
604
|
+
<button
|
|
605
|
+
type="button"
|
|
606
|
+
className="ts-expand-btn"
|
|
607
|
+
onClick={() => handleNodeClick(node)}
|
|
608
|
+
aria-label={isExpand ? '折叠' : '展开'}
|
|
609
|
+
>
|
|
610
|
+
{isLoadingNode ? (
|
|
611
|
+
<span className="ts-loading-icon"></span>
|
|
612
|
+
) : (
|
|
613
|
+
<span className={`ts-expand-arrow${isExpand ? ' ts-expanded' : ''}`}>▶</span>
|
|
614
|
+
)}
|
|
615
|
+
</button>
|
|
616
|
+
) : (
|
|
617
|
+
<span className="ts-leaf-spacer"></span>
|
|
618
|
+
)}
|
|
619
|
+
|
|
620
|
+
{multiple && (
|
|
621
|
+
<button
|
|
622
|
+
type="button"
|
|
623
|
+
className={`ts-checkbox${checked ? ' ts-checked' : ''}${indeterminate ? ' ts-indeterminate' : ''}`}
|
|
624
|
+
onClick={e => handleCheck(node, e)}
|
|
625
|
+
aria-label={checked ? '取消选择' : '选择'}
|
|
626
|
+
>
|
|
627
|
+
{checked && <span className="ts-check-icon">✓</span>}
|
|
628
|
+
{indeterminate && !checked && <span className="ts-check-icon">−</span>}
|
|
629
|
+
</button>
|
|
630
|
+
)}
|
|
631
|
+
|
|
632
|
+
<button
|
|
633
|
+
type="button"
|
|
634
|
+
className={`ts-label${!multiple && selected.includes(node.value) ? ' ts-label-selected' : ''}`}
|
|
635
|
+
onClick={e => handleNodeSelect(node, e)}
|
|
636
|
+
>
|
|
637
|
+
{node.label}
|
|
638
|
+
</button>
|
|
639
|
+
</div>
|
|
640
|
+
);
|
|
641
|
+
})
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
);
|
|
649
|
+
};
|