react-extjs 0.0.1

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +36 -0
  3. package/src/components/AbsoluteLayout.tsx +206 -0
  4. package/src/components/Accordion.tsx +266 -0
  5. package/src/components/AnchorLayout.tsx +131 -0
  6. package/src/components/BorderLayout.tsx +512 -0
  7. package/src/components/Box.tsx +289 -0
  8. package/src/components/Button.tsx +300 -0
  9. package/src/components/ButtonGroup.tsx +170 -0
  10. package/src/components/CalendarPicker.tsx +431 -0
  11. package/src/components/CardLayout.tsx +122 -0
  12. package/src/components/CenterLayout.tsx +95 -0
  13. package/src/components/CheckboxGroup.tsx +200 -0
  14. package/src/components/ColorMenu.tsx +195 -0
  15. package/src/components/ColorPalette.tsx +126 -0
  16. package/src/components/ColumnLayout.tsx +183 -0
  17. package/src/components/ColumnTree.tsx +383 -0
  18. package/src/components/CycleButton.tsx +302 -0
  19. package/src/components/DataView.tsx +244 -0
  20. package/src/components/DataViewMore.tsx +337 -0
  21. package/src/components/DataViewTransition.tsx +235 -0
  22. package/src/components/DateMenu.tsx +163 -0
  23. package/src/components/DatePicker.tsx +387 -0
  24. package/src/components/DisplayField.tsx +73 -0
  25. package/src/components/Editor.tsx +214 -0
  26. package/src/components/EditorGrid.tsx +361 -0
  27. package/src/components/FieldLabeler.tsx +81 -0
  28. package/src/components/FieldReplicator.tsx +135 -0
  29. package/src/components/FieldSet.tsx +149 -0
  30. package/src/components/FitLayout.tsx +76 -0
  31. package/src/components/Form.tsx +3484 -0
  32. package/src/components/Grid.tsx +2173 -0
  33. package/src/components/GridFilters.tsx +332 -0
  34. package/src/components/GroupTab.tsx +274 -0
  35. package/src/components/HtmlEditor.tsx +391 -0
  36. package/src/components/ImageOrganizer.tsx +984 -0
  37. package/src/components/ImageOrganizerService.ts +361 -0
  38. package/src/components/ListView.tsx +221 -0
  39. package/src/components/LoadMask.tsx +74 -0
  40. package/src/components/Menu.tsx +227 -0
  41. package/src/components/MessageBox.tsx +138 -0
  42. package/src/components/MultiSlider.tsx +369 -0
  43. package/src/components/Organizer.tsx +403 -0
  44. package/src/components/PagingToolbar.tsx +255 -0
  45. package/src/components/Panel.tsx +451 -0
  46. package/src/components/ProgressBar.tsx +127 -0
  47. package/src/components/PropertyGrid.tsx +142 -0
  48. package/src/components/QuickTip.tsx +174 -0
  49. package/src/components/Resizable.tsx +241 -0
  50. package/src/components/RowLayout.tsx +126 -0
  51. package/src/components/Slider.tsx +353 -0
  52. package/src/components/SliderField.tsx +206 -0
  53. package/src/components/SpinnerField.tsx +141 -0
  54. package/src/components/SplitBar.tsx +215 -0
  55. package/src/components/Spotlight.tsx +262 -0
  56. package/src/components/StatusBar.tsx +118 -0
  57. package/src/components/TabPanel.tsx +540 -0
  58. package/src/components/TableLayout.tsx +132 -0
  59. package/src/components/ThemeSwitcher.tsx +167 -0
  60. package/src/components/ToolTip.tsx +226 -0
  61. package/src/components/Toolbar.tsx +321 -0
  62. package/src/components/Tree.tsx +809 -0
  63. package/src/components/TreeGrid.tsx +418 -0
  64. package/src/components/Viewport.tsx +218 -0
  65. package/src/components/Window.tsx +441 -0
  66. package/src/components/Writer.tsx +303 -0
  67. package/src/components/XmlTreeLoader.tsx +233 -0
  68. package/src/components/dnd/DndContext.tsx +328 -0
  69. package/src/components/dnd/DragSource.tsx +202 -0
  70. package/src/components/dnd/DragZone.tsx +66 -0
  71. package/src/components/dnd/DropTarget.tsx +86 -0
  72. package/src/components/dnd/DropZone.tsx +166 -0
  73. package/src/components/dnd/PanelProxy.tsx +81 -0
  74. package/src/components/dnd/PanelResizer.tsx +143 -0
  75. package/src/components/dnd/Portal.tsx +254 -0
  76. package/src/components/dnd/Reorderer.tsx +73 -0
  77. package/src/components/dnd/ToolbarReorderer.tsx +226 -0
  78. package/src/main.tsx +2649 -0
  79. package/src/styles/ext-theme.css +889 -0
  80. package/src/util/theme.ts +95 -0
@@ -0,0 +1,303 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import { EditorGrid, type EditorColumn, type EditorGridHandle } from './EditorGrid';
10
+ import { FormPanel, FormField, TextField } from './Form';
11
+ import { Panel } from './Panel';
12
+ import { Toolbar, type ToolbarItem } from './Toolbar';
13
+ import { MessageBox, type MessageBoxIcon } from './MessageBox';
14
+
15
+ export interface WriterUser {
16
+ id: number;
17
+ email: string;
18
+ first: string;
19
+ last: string;
20
+ }
21
+
22
+ export interface WriterProps {
23
+ /** 初始数据(前端 JSON 模拟,保留数据接口形态;等价于原版 Store 的本地数据加载) */
24
+ data?: WriterUser[];
25
+ width?: number;
26
+ height?: number;
27
+ /** 保留"写回后端"接口(等价于原版 JsonWriter 的 save 调用);纯前端模拟下作为状态条回调 */
28
+ onSave?: (data: WriterUser[]) => void;
29
+ }
30
+
31
+ export interface WriterHandle {
32
+ setData: (data: WriterUser[]) => void;
33
+ getData: () => WriterUser[];
34
+ }
35
+
36
+ const DEFAULT_USERS: WriterUser[] = [
37
+ { id: 1, email: 'ed@extjs.com', first: 'Ed', last: 'Spencer' },
38
+ { id: 2, email: 'abe@extjs.com', first: 'Abe', last: 'Elias' },
39
+ { id: 3, email: 'aram@extjs.com', first: 'Aram', last: 'Misk' },
40
+ { id: 4, email: 'jack@extjs.com', first: 'Jack', last: 'Slocum' },
41
+ { id: 5, email: 'tommy@extjs.com', first: 'Tommy', last: 'Maintz' },
42
+ ];
43
+
44
+ const columns: EditorColumn[] = [
45
+ { header: 'ID', dataIndex: 'id', width: 40, editor: false },
46
+ { header: 'Email', dataIndex: 'email', width: 130, editor: true },
47
+ { header: 'First', dataIndex: 'first', width: 80, editor: true },
48
+ { header: 'Last', dataIndex: 'last', width: 80, editor: true },
49
+ ];
50
+
51
+ // 全局提示改用项目自有 MessageBox 组件(对齐 Ext.MessageBox 外观、跟随 xtheme 主题)。
52
+ // status 沿用原生 App.setAlert 语义:'ok'(写成功) | 'notice'(提示) | 'error'(阻断/异常),
53
+ // 映射到 MessageBox 的标题与图标;停留时长沿用原生 App.addMessage 算法(msg.length/13.3 钳制 3~9 秒)。
54
+ type ToastStatus = 'ok' | 'notice' | 'error';
55
+ const STATUS_TO_MB: Record<ToastStatus, { title: string; icon: MessageBoxIcon }> = {
56
+ ok: { title: 'Success', icon: 'info' },
57
+ notice: { title: 'Notice', icon: 'info' },
58
+ error: { title: 'Error', icon: 'error' },
59
+ };
60
+ type WriterMsgState = { title: string; msg: string; icon: MessageBoxIcon; key: number };
61
+
62
+ // EditorGrid 数据层是通用 Record<string,unknown>,本组件用强类型 WriterUser;
63
+ // 二者转换统一走 unknown 断言(用户数据是受控 JSON,结构由列定义保证)。
64
+ const asUsers = (rows: Record<string, unknown>[]): WriterUser[] => rows as unknown as WriterUser[];
65
+ const asRecords = (users: WriterUser[]): Record<string, unknown>[] =>
66
+ users as unknown as Record<string, unknown>[];
67
+
68
+ /**
69
+ * 保真复刻 examples/writer(writer.js + UserGrid.js + UserForm.js):
70
+ * - 左侧 Panel(frame, title=Users) 包 EditorGrid(可编辑用户表),tbar=[Add,Delete]、
71
+ * bbar=[autoSave/batch/writeAllFields 三个 toggle]、fbar=[Save];
72
+ * - 右侧 FormPanel(frame, labelAlign=right, title=User) 含 email(vtype email, allowBlank false)/
73
+ * first/last 三个受控 TextField(各由 FormField 承载 label),底部 Toolbar=[Save,Create,Reset];
74
+ * - 行点击 → 表单 loadRecord 填充;Add 顶部插入空记录、Delete 删选中行、Form Save 更新选中记录、
75
+ * Form Create 顶部新增、Form Reset 清空;数据层用本地 state 模拟 Store+JsonWriter 写回。
76
+ * - 全局消息提示(等价原版 App.setAlert 的写成功/阻断反馈)改用项目自有 MessageBox 组件
77
+ * (对齐 Ext.MessageBox 外观、跟随 xtheme 主题),停留时长沿用原生 App.addMessage 算法,可手动关闭。
78
+ */
79
+ export const Writer = forwardRef<WriterHandle, WriterProps>(function Writer(
80
+ { data = DEFAULT_USERS, width = 840, height = 300, onSave },
81
+ ref,
82
+ ) {
83
+ const [users, setUsers] = useState<WriterUser[]>(() => data.map((u) => ({ ...u })));
84
+ const [selectedRow, setSelectedRow] = useState<number>(-1);
85
+ const [form, setForm] = useState<WriterUser>({ id: 0, email: '', first: '', last: '' });
86
+ const [autoSave, setAutoSave] = useState(true);
87
+ const [batch, setBatch] = useState(false);
88
+ const [writeAllFields, setWriteAllFields] = useState(true);
89
+ const [msg, setMsg] = useState<WriterMsgState | null>(null);
90
+
91
+ const gridRef = useRef<EditorGridHandle>(null);
92
+ // 初始数据副本(稳定引用),仅用于首次装配 EditorGrid;后续内部修改走 ref 句柄 +
93
+ // onDataChange 单向回写 users,避免把 users 回传给 data 触发 EditorGrid 内部
94
+ // setRows→onDataChange→setUsers 的引用循环。
95
+ const initialData = useRef(asRecords(data.map((u) => ({ ...u })))).current;
96
+ const nextId = useRef(Math.max(0, ...data.map((u) => u.id)) + 1);
97
+ const msgTimer = useRef<number | undefined>(undefined);
98
+
99
+ // 提示改用 MessageBox:status → 标题/图标(STATUS_TO_MB),停留时长沿用原生 App.addMessage 算法。
100
+ const showToast = useCallback((text: string, status: ToastStatus = 'ok') => {
101
+ const { title, icon } = STATUS_TO_MB[status];
102
+ setMsg({ title, msg: text, icon, key: Date.now() });
103
+ window.clearTimeout(msgTimer.current);
104
+ const delay = Math.min(9, Math.max(3, text.length / 13.3)) * 1000;
105
+ msgTimer.current = window.setTimeout(() => setMsg(null), delay);
106
+ }, []);
107
+
108
+ useEffect(() => () => window.clearTimeout(msgTimer.current), []);
109
+
110
+ // 外部 data prop 变化 → 单向同步进 EditorGrid(不回流 users,防循环)
111
+ useEffect(() => {
112
+ gridRef.current?.setData(asRecords(data.map((u) => ({ ...u }))));
113
+ }, [data]);
114
+
115
+ // 同步 EditorGrid 内部数据源到本地 users(单一数据源,等价 Store)
116
+ const handleDataChange = useCallback((rows: Record<string, unknown>[]) => {
117
+ setUsers(asUsers(rows));
118
+ }, []);
119
+
120
+ const onRowClick = useCallback(
121
+ (idx: number) => {
122
+ setSelectedRow(idx);
123
+ },
124
+ [],
125
+ );
126
+
127
+ // 选中行 / 网格数据变化 → 右侧表单实时同步该行最新值(等价 Ext 选中即回填,
128
+ // 且单元格编辑提交后右侧表单随之刷新,避免「右侧不实时更新」)。
129
+ useEffect(() => {
130
+ if (selectedRow >= 0 && users[selectedRow]) {
131
+ setForm({ ...users[selectedRow] });
132
+ }
133
+ }, [users, selectedRow]);
134
+
135
+ // —— 网格工具栏动作 ——
136
+ const onAdd = useCallback(() => {
137
+ const rec: WriterUser = { id: nextId.current++, email: 'new@extjs.com', first: '', last: '' };
138
+ const current = asUsers(gridRef.current?.getData() ?? []);
139
+ gridRef.current?.setData(asRecords([rec, ...current])); // 顶部插入(等价 Ext.Store.insert(0, rec))
140
+ setSelectedRow(0);
141
+ setForm({ ...rec });
142
+ showToast('Add', 'ok');
143
+ }, [showToast]);
144
+
145
+ const onDelete = useCallback(() => {
146
+ if (selectedRow < 0) {
147
+ showToast('Select a row first', 'error');
148
+ return;
149
+ }
150
+ gridRef.current?.removeRecord(selectedRow);
151
+ setSelectedRow(-1);
152
+ setForm({ id: 0, email: '', first: '', last: '' });
153
+ showToast('Delete', 'ok');
154
+ }, [selectedRow, showToast]);
155
+
156
+ const onGridSave = useCallback(() => {
157
+ const current = asUsers(gridRef.current?.getData() ?? []);
158
+ onSave?.(current);
159
+ showToast(`Saved ${current.length} record(s)`, 'ok');
160
+ }, [onSave, showToast]);
161
+
162
+ // autoSave:单元格编辑提交即写回(等价 JsonWriter 自动保存)
163
+ const handleCellEdit = useCallback(() => {
164
+ if (autoSave) {
165
+ onSave?.(asUsers(gridRef.current?.getData() ?? []));
166
+ showToast('Auto saved', 'ok');
167
+ }
168
+ }, [autoSave, onSave, showToast]);
169
+
170
+ // —— 表单动作 ——
171
+ const onFormSave = useCallback(() => {
172
+ if (selectedRow < 0) {
173
+ showToast('Select a row first', 'error');
174
+ return;
175
+ }
176
+ gridRef.current?.updateRecord(
177
+ selectedRow,
178
+ { ...form, id: users[selectedRow].id } as unknown as Record<string, unknown>,
179
+ );
180
+ showToast('Save');
181
+ }, [selectedRow, users, form, showToast]);
182
+
183
+ const onCreate = useCallback(() => {
184
+ const rec: WriterUser = {
185
+ id: nextId.current++,
186
+ email: form.email,
187
+ first: form.first,
188
+ last: form.last,
189
+ };
190
+ const current = asUsers(gridRef.current?.getData() ?? []);
191
+ gridRef.current?.setData(asRecords([rec, ...current])); // 顶部新增
192
+ setSelectedRow(0);
193
+ showToast('Create', 'ok');
194
+ }, [form, showToast]);
195
+
196
+ const onReset = useCallback(() => {
197
+ setForm({ id: 0, email: '', first: '', last: '' });
198
+ setSelectedRow(-1);
199
+ }, []);
200
+
201
+ useImperativeHandle(
202
+ ref,
203
+ () => ({
204
+ setData: (d) => gridRef.current?.setData(asRecords(d)),
205
+ getData: () => asUsers(gridRef.current?.getData() ?? []),
206
+ }),
207
+ [],
208
+ );
209
+
210
+ const gridTbar: ToolbarItem[] = [
211
+ { text: 'Add', handler: onAdd },
212
+ { text: 'Delete', handler: onDelete },
213
+ ];
214
+ const gridBbar: ToolbarItem[] = [
215
+ { text: 'autoSave', enableToggle: true, pressed: autoSave, onToggle: (v) => setAutoSave(v) },
216
+ { text: 'batch', enableToggle: true, pressed: batch, onToggle: (v) => setBatch(v) },
217
+ { text: 'writeAllFields', enableToggle: true, pressed: writeAllFields, onToggle: (v) => setWriteAllFields(v) },
218
+ ];
219
+ const gridFbar: ToolbarItem[] = [{ text: 'Save', handler: onGridSave }];
220
+ const formButtons: ToolbarItem[] = [
221
+ { text: 'Save', handler: onFormSave },
222
+ { text: 'Create', handler: onCreate },
223
+ { text: 'Reset', handler: onReset },
224
+ ];
225
+
226
+ // Panel 高度分配:header + tbar + body(grid) + bbar + fbar + 底框 之和应等于 height。
227
+ // 实测各项:header 23、tbar/bbar/fbar 各 27、body 上下内边距各 8、底框 6,
228
+ // 故 gridH = height - (23+27+27+27+16+6) = height - 126,使网格恰好填满、无底部空隙。
229
+ const gridH = height - 126;
230
+
231
+ return (
232
+ <div style={{ position: 'relative', display: 'inline-flex', gap: 8, width, height }}>
233
+ <Panel
234
+ title="Users"
235
+ frame
236
+ width={500}
237
+ height={height}
238
+ tbar={<Toolbar items={gridTbar} />}
239
+ bbar={<Toolbar items={gridBbar} />}
240
+ fbar={<Toolbar items={gridFbar} />}
241
+ >
242
+ <EditorGrid
243
+ ref={gridRef}
244
+ columns={columns}
245
+ data={initialData}
246
+ width={500}
247
+ height={gridH}
248
+ stripeRows
249
+ selectedRow={selectedRow >= 0 ? selectedRow : undefined}
250
+ onRowClick={onRowClick}
251
+ onDataChange={handleDataChange}
252
+ onCellEdit={handleCellEdit}
253
+ />
254
+ </Panel>
255
+ <FormPanel title="User" labelAlign="right" width={300} height={height}>
256
+ <FormField fieldLabel="Email" labelAlign="right">
257
+ <TextField
258
+ name="email"
259
+ vtype="email"
260
+ allowBlank={false}
261
+ value={form.email}
262
+ onChange={(v) => setForm((f) => ({ ...f, email: v }))}
263
+ />
264
+ </FormField>
265
+ <FormField fieldLabel="First" labelAlign="right">
266
+ <TextField
267
+ name="first"
268
+ value={form.first}
269
+ onChange={(v) => setForm((f) => ({ ...f, first: v }))}
270
+ />
271
+ </FormField>
272
+ <FormField fieldLabel="Last" labelAlign="right">
273
+ <TextField
274
+ name="last"
275
+ value={form.last}
276
+ onChange={(v) => setForm((f) => ({ ...f, last: v }))}
277
+ />
278
+ </FormField>
279
+ <Toolbar items={formButtons} />
280
+ </FormPanel>
281
+ {msg && (
282
+ // 项目自有 MessageBox 提示(对齐 Ext.MessageBox 外观、跟随 xtheme 主题),顶部居中覆盖在
283
+ // Writer 之上;容器定宽并居中,内部 MessageBox 绝对定位 0,0 即与容器同宽居中。OK 按钮或关闭
284
+ // 可手动消失,否则停留原生 App.addMessage 算法时长后自动卸载隐藏。
285
+ <div
286
+ key={msg.key}
287
+ style={{ position: 'absolute', top: 8, left: '50%', transform: 'translateX(-50%)', width: 320, zIndex: 9000 }}
288
+ >
289
+ <MessageBox
290
+ title={msg.title}
291
+ msg={msg.msg}
292
+ width={320}
293
+ icon={msg.icon}
294
+ buttons={[{ text: 'OK', onClick: () => setMsg(null), width: 75 }]}
295
+ onClose={() => setMsg(null)}
296
+ />
297
+ </div>
298
+ )}
299
+ </div>
300
+ );
301
+ });
302
+
303
+ export default Writer;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Ext.ux.tree.XmlTreeLoader 保真复刻(来源 examples/ux/XmlTreeLoader.js + src/widgets/tree/TreeLoader.js)。
3
+ *
4
+ * 保真契约(行为对齐源码):
5
+ * - 继承 Ext.tree.TreeLoader 的「XML 文档 → TreeNode 层级」转换能力:
6
+ * processResponse → 读取 responseXML,取 documentElement 作为根;
7
+ * parseXml → 递归遍历 XML 元素节点,元素节点经 createNode 转为树节点,
8
+ * 元素内的纯文本节点值作为 innerText 属性挂到父节点;
9
+ * createNode → 收集元素的所有 XML 属性,额外写入 tagName,调用 processAttributes
10
+ * (模板方法,供子类/调用方定制属性,等价于 Ext 的 processAttributes),
11
+ * 最终转为 React TreePanel 使用的 TreeNodeData。
12
+ * - XML_NODE_ELEMENT = 1,XML_NODE_TEXT = 3(与 Ext 一致,只读常量)。
13
+ * - 兼容两种数据源:
14
+ * dataUrl:通过 fetch 拉取 XML 文本后解析(对齐 Ext 的 dataUrl 远程加载);
15
+ * data:直接传入 XML 字符串或已解析的 Document(便于内联/测试,对齐 Ext 内存数据)。
16
+ *
17
+ * 与原 Ext 的差异(刻意修正,不影响保真):
18
+ * - Ext 的 parseXml 在「元素节点 + 文本节点共存」时会因 `return nodes = text` 把元素节点丢弃;
19
+ * 此处改为:元素子节点恒转为 children,纯文本恒合并为 innerText,二者互不覆盖,结果更正确。
20
+ * - Ext 的 TreeNode 自动 id 由 TreePanel 分配;此处用「父路径 + 标签名 + 序号」生成稳定 id,
21
+ * 保证 React key / 选中 / 异步加载定位可用。
22
+ */
23
+ import { type TreeNodeData } from './Tree';
24
+
25
+ export type XmlAttrMap = Record<string, any>;
26
+
27
+ /** processAttributes 模板方法签名:直接修改 attr(无需返回值),对齐 Ext 同名钩子 */
28
+ export type XmlProcessAttributes = (attr: XmlAttrMap) => void;
29
+
30
+ export interface XmlTreeLoaderConfig {
31
+ /** 远程 XML 地址(与 data 二选一) */
32
+ dataUrl?: string;
33
+ /** 内联 XML:字符串或已解析的 Document(与 dataUrl 二选一) */
34
+ data?: string | Document;
35
+ /**
36
+ * 模板方法,在创建每个节点前定制属性(对齐 Ext.ux.tree.XmlTreeLoader#processAttributes)。
37
+ * 例:根据 author/book 标签设置 text、iconCls、leaf 等。
38
+ */
39
+ processAttributes?: XmlProcessAttributes;
40
+ /** 应用到所有节点的基础属性(对齐 Ext TreeLoader#baseAttrs,已有属性优先) */
41
+ baseAttrs?: XmlAttrMap;
42
+ }
43
+
44
+ /** 由 XmlTreeLoader 产出的节点:在 TreeNodeData 基础上保留全部原始 XML 属性,供模板/详情读取 */
45
+ export type XmlTreeNode = TreeNodeData & XmlAttrMap;
46
+
47
+ export class XmlTreeLoader {
48
+ /** XML 元素节点(只读,对齐 Ext.XML_NODE_ELEMENT) */
49
+ static readonly XML_NODE_ELEMENT = 1;
50
+ /** XML 文本节点(只读,对齐 Ext.XML_NODE_TEXT) */
51
+ static readonly XML_NODE_TEXT = 3;
52
+
53
+ dataUrl?: string;
54
+ data?: string | Document;
55
+ processAttributes: XmlProcessAttributes;
56
+ baseAttrs?: XmlAttrMap;
57
+
58
+ constructor(config: XmlTreeLoaderConfig = {}) {
59
+ this.dataUrl = config.dataUrl;
60
+ this.data = config.data;
61
+ this.baseAttrs = config.baseAttrs;
62
+ this.processAttributes = config.processAttributes ?? (() => {});
63
+ }
64
+
65
+ /**
66
+ * 加载并解析为 TreeNodeData[](等价于 Ext 的 load + processResponse)。
67
+ * 返回的数组可直接作为 TreePanel 的 `nodes`(或 root.children)使用。
68
+ */
69
+ async load(): Promise<XmlTreeNode[]> {
70
+ let doc: Document;
71
+ if (this.data) {
72
+ doc = typeof this.data === 'string' ? this.parseString(this.data) : this.data;
73
+ } else if (this.dataUrl) {
74
+ const res = await fetch(this.dataUrl);
75
+ if (!res.ok) throw new Error(`XmlTreeLoader: fetch ${this.dataUrl} failed (${res.status})`);
76
+ const text = await res.text();
77
+ doc = this.parseString(text);
78
+ } else {
79
+ throw new Error('XmlTreeLoader: 必须提供 data 或 dataUrl 之一');
80
+ }
81
+ const root = doc.documentElement || doc;
82
+ return this.parseXml(root);
83
+ }
84
+
85
+ // private:字符串 → Document
86
+ private parseString(xml: string): Document {
87
+ const parser = new DOMParser();
88
+ const doc = parser.parseFromString(xml, 'application/xml');
89
+ const err = doc.querySelector('parsererror');
90
+ if (err) throw new Error('XmlTreeLoader: XML 解析失败 - ' + err.textContent);
91
+ return doc;
92
+ }
93
+
94
+ /**
95
+ * 递归解析 XML 元素(对齐 Ext.ux.tree.XmlTreeLoader#parseXml)。
96
+ * 返回该元素直接子节点对应的树节点数组;元素内的纯文本合并为 innerText 挂到父节点属性上。
97
+ */
98
+ private parseXml(node: Element | Document, parentPath = ''): XmlTreeNode[] {
99
+ const out: XmlTreeNode[] = [];
100
+ let index = 0;
101
+ const childNodes = node.childNodes;
102
+ for (let i = 0; i < childNodes.length; i++) {
103
+ const n = childNodes[i];
104
+ if (n.nodeType === XmlTreeLoader.XML_NODE_ELEMENT) {
105
+ const el = n as Element;
106
+ const treeNode = this.createNode(el, parentPath, index);
107
+ // 递归子元素(转 children)
108
+ const kids = this.parseXml(el, treeNode.id);
109
+ if (kids.length) treeNode.children = kids;
110
+ // 元素内的纯文本 → innerText(对齐 Ext 把文本节点值挂为 innerText)
111
+ const text = this.collectText(el);
112
+ if (text) (treeNode as XmlAttrMap).innerText = text;
113
+ out.push(treeNode);
114
+ index++;
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // private:收集元素内直接文本节点的拼接值(trim)
121
+ private collectText(el: Element): string {
122
+ let buf = '';
123
+ const childNodes = el.childNodes;
124
+ for (let i = 0; i < childNodes.length; i++) {
125
+ const n = childNodes[i];
126
+ if (n.nodeType === XmlTreeLoader.XML_NODE_TEXT && n.nodeValue) {
127
+ buf += n.nodeValue;
128
+ }
129
+ }
130
+ return buf.trim();
131
+ }
132
+
133
+ /**
134
+ * 由 XML 元素创建树节点(对齐 Ext.ux.tree.XmlTreeLoader#createNode):
135
+ * 1. 写入 tagName;
136
+ * 2. 拷贝全部 XML 属性;
137
+ * 3. 调用 processAttributes 允许调用方定制;
138
+ * 4. 合并 baseAttrs(已有属性不覆盖);
139
+ * 5. 生成稳定 id 并转为 XmlTreeNode。
140
+ */
141
+ private createNode(el: Element, parentPath: string, index: number): XmlTreeNode {
142
+ const attr: XmlAttrMap = { tagName: el.tagName };
143
+ for (let i = 0; i < el.attributes.length; i++) {
144
+ const a = el.attributes[i];
145
+ attr[a.nodeName] = a.nodeValue;
146
+ }
147
+
148
+ this.processAttributes(attr);
149
+
150
+ if (this.baseAttrs) {
151
+ for (const k in this.baseAttrs) {
152
+ if (!(k in attr)) attr[k] = this.baseAttrs[k];
153
+ }
154
+ }
155
+
156
+ const baseId = attr.id ?? `${parentPath ? parentPath + '/' : ''}${el.tagName}-${index}`;
157
+ attr.id = baseId;
158
+
159
+ return this.toTreeNodeData(attr);
160
+ }
161
+
162
+ // private:属性映射 → XmlTreeNode(已知字段映射到 TreeNodeData,其余原样保留)
163
+ private toTreeNodeData(attr: XmlAttrMap): XmlTreeNode {
164
+ const node: XmlTreeNode = {
165
+ id: attr.id,
166
+ text: attr.text ?? '',
167
+ };
168
+ const known = ['id', 'text', 'tagName', 'innerText'];
169
+ if (attr.iconCls) node.iconCls = attr.iconCls;
170
+ if (attr.icon) node.icon = attr.icon;
171
+ if (attr.expanded) node.expanded = true;
172
+ if (attr.expandable) node.expandable = true;
173
+ if (attr.leaf) node.leaf = true;
174
+ if (attr.disabled) node.disabled = true;
175
+ if (attr.checked !== undefined) node.checked = !!attr.checked;
176
+ if (attr.cls) node.cls = attr.cls;
177
+ if (attr.innerText !== undefined) node.innerText = attr.innerText;
178
+ // 保留其它原始 XML 属性,供模板/详情面板读取(如 first/last/gender/published/url/title 等)
179
+ for (const k of Object.keys(attr)) {
180
+ if (!known.includes(k) && !(k in node)) (node as XmlAttrMap)[k] = attr[k];
181
+ }
182
+ return node;
183
+ }
184
+
185
+ /**
186
+ * 便捷方法:返回一个兼容 TreePanel `loader` 属性的异步加载器。
187
+ * 适用于「每次展开节点时按 id 拉取该节点的 XML 子节点」的场景:
188
+ * 配置 dataUrl 时,loader 会请求 `${dataUrl}?node=<id>`(与 Ext 行为一致,由服务端按 node 参数返回子 XML)。
189
+ */
190
+ makeLoader(): (node: TreeNodeData) => Promise<TreeNodeData[]> {
191
+ return async (node: TreeNodeData) => {
192
+ let doc: Document;
193
+ if (this.dataUrl) {
194
+ const sep = this.dataUrl.includes('?') ? '&' : '?';
195
+ const res = await fetch(`${this.dataUrl}${sep}node=${encodeURIComponent(node.id)}`);
196
+ if (!res.ok) throw new Error(`XmlTreeLoader: fetch ${this.dataUrl} failed (${res.status})`);
197
+ doc = this.parseString(await res.text());
198
+ } else if (this.data && typeof this.data === 'string') {
199
+ doc = this.parseString(this.data);
200
+ } else if (this.data) {
201
+ doc = this.data as Document;
202
+ } else {
203
+ throw new Error('XmlTreeLoader: makeLoader 需要 dataUrl 或 data');
204
+ }
205
+ const root = doc.documentElement || doc;
206
+ // 以命中 id 的子树作为该节点的子节点
207
+ const nodeEl = this.findElementById(root, node.id);
208
+ const target = nodeEl ?? root;
209
+ return this.parseXml(target, node.id);
210
+ };
211
+ }
212
+
213
+ private findElementById(root: Element | Document, id: string): Element | null {
214
+ const els = (root as Element).querySelectorAll?.('*') ?? [];
215
+ for (let i = 0; i < els.length; i++) {
216
+ const el = els[i] as Element;
217
+ if (el.getAttribute('id') === id) return el;
218
+ }
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /** 便捷函数:直接把 XML 解析为 XmlTreeNode[](不构造实例时使用) */
224
+ export function parseXmlTree(
225
+ xml: string | Document,
226
+ processAttributes?: XmlProcessAttributes,
227
+ baseAttrs?: XmlAttrMap,
228
+ ): XmlTreeNode[] {
229
+ const loader = new XmlTreeLoader({ data: xml, processAttributes, baseAttrs });
230
+ const doc = typeof xml === 'string' ? loader['parseString'](xml) : xml;
231
+ const root = doc.documentElement || doc;
232
+ return loader['parseXml'](root);
233
+ }