@aquiferre/ui-kit 0.4.0 → 0.4.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.
@@ -0,0 +1,193 @@
1
+ <template>
2
+ <div class="ui-tree" :data-testid="testId">
3
+ <div v-if="showSearch" class="mb-2">
4
+ <input
5
+ v-model="keyword"
6
+ type="text"
7
+ class="tree-search"
8
+ :placeholder="searchPlaceholder"
9
+ :data-testid="testId ? `${testId}-search` : undefined"
10
+ />
11
+ </div>
12
+ <div v-if="visibleRoot.length === 0" class="tree-empty">
13
+ <p>{{ emptyText }}</p>
14
+ </div>
15
+ <div v-else class="tree-scroll">
16
+ <TreeNodeItem
17
+ v-for="node in visibleRoot"
18
+ :key="node.id"
19
+ :node="node"
20
+ :depth="0"
21
+ :selected-key="selectedKey"
22
+ :expanded-keys="expandedKeys"
23
+ :loading-keys="loadingKeys"
24
+ :load-children="loadChildren"
25
+ :test-id="testId"
26
+ @toggle="toggleNode"
27
+ @select="handleSelect"
28
+ />
29
+ </div>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { ref, computed, watch } from 'vue'
35
+ import TreeNodeItem from './TreeNodeItem.vue'
36
+
37
+ /** 通用树节点:业务字段(type 等)可透传附加;children 由组件懒加载后写入 */
38
+ export interface TreeNode {
39
+ id: string
40
+ label: string
41
+ children?: TreeNode[]
42
+ /** 叶子节点(不触发懒加载);缺省时无 children 且无 loadChildren 视为叶子 */
43
+ isLeaf?: boolean
44
+ [key: string]: unknown
45
+ }
46
+
47
+ const props = withDefaults(
48
+ defineProps<{
49
+ /** 根级节点(顶层数据由调用方加载传入) */
50
+ nodes: TreeNode[]
51
+ /** 懒加载回调:展开节点时调用,返回其子节点(调用方封装数据请求) */
52
+ loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
53
+ /** 选中节点 id(受控,父组件通过 select 事件维护) */
54
+ selectedKey?: string | null
55
+ /** 回显展开链:挂载时按链逐级展开并选中末端节点(祖先 id 顺序 + 选中 id) */
56
+ defaultExpandedKeys?: string[]
57
+ showSearch?: boolean
58
+ searchPlaceholder?: string
59
+ emptyText?: string
60
+ testId?: string
61
+ }>(),
62
+ { showSearch: false, searchPlaceholder: '搜索...', emptyText: '暂无数据' }
63
+ )
64
+
65
+ const emit = defineEmits<{
66
+ (e: 'select', node: TreeNode | null): void
67
+ }>()
68
+
69
+ const keyword = ref('')
70
+ const expandedKeys = ref<Set<string>>(new Set())
71
+ const loadingKeys = ref<Set<string>>(new Set())
72
+ const selectedKey = ref<string | null>(props.selectedKey ?? null)
73
+
74
+ watch(
75
+ () => props.selectedKey,
76
+ (v) => {
77
+ if (v !== undefined) selectedKey.value = v
78
+ }
79
+ )
80
+
81
+ /** 懒加载展开:写入 node.children(共享引用,调用方数据同步可见) */
82
+ async function expandNode(node: TreeNode) {
83
+ if (node.children?.length || node.isLeaf || !props.loadChildren) return
84
+ if (loadingKeys.value.has(node.id)) return
85
+ loadingKeys.value.add(node.id)
86
+ try {
87
+ node.children = await props.loadChildren(node)
88
+ } catch {
89
+ node.children = []
90
+ } finally {
91
+ loadingKeys.value.delete(node.id)
92
+ }
93
+ }
94
+
95
+ function toggleNode(node: TreeNode) {
96
+ const next = new Set(expandedKeys.value)
97
+ if (next.has(node.id)) {
98
+ next.delete(node.id)
99
+ } else {
100
+ next.add(node.id)
101
+ expandNode(node)
102
+ }
103
+ expandedKeys.value = next
104
+ }
105
+
106
+ function handleSelect(node: TreeNode) {
107
+ // 再次点击已选中节点 → 取消选择
108
+ if (selectedKey.value === node.id) {
109
+ selectedKey.value = null
110
+ emit('select', null)
111
+ } else {
112
+ selectedKey.value = node.id
113
+ emit('select', node)
114
+ }
115
+ }
116
+
117
+ // 回显:按 defaultExpandedKeys 逐级展开(每级懒加载后定位下一节点),末端标记选中
118
+ watch(
119
+ () => props.defaultExpandedKeys,
120
+ async (keys) => {
121
+ if (!keys || keys.length === 0) return
122
+ let level = props.nodes
123
+ for (let i = 0; i < keys.length; i++) {
124
+ const target = level.find(n => n.id === keys[i])
125
+ if (!target) break
126
+ if (i < keys.length - 1) {
127
+ const next = new Set(expandedKeys.value)
128
+ next.add(target.id)
129
+ expandedKeys.value = next
130
+ await expandNode(target)
131
+ level = target.children || []
132
+ } else {
133
+ selectedKey.value = target.id
134
+ emit('select', target)
135
+ }
136
+ }
137
+ },
138
+ { immediate: true }
139
+ )
140
+
141
+ /** 展示层过滤(不修改原数据):命中节点或其子树保留 */
142
+ function filterNodes(nodes: TreeNode[]): TreeNode[] {
143
+ const kw = keyword.value.trim()
144
+ if (!kw) return nodes
145
+ const filtered: TreeNode[] = []
146
+ for (const n of nodes) {
147
+ const kids = n.children ? filterNodes(n.children) : undefined
148
+ if (n.label.includes(kw) || (kids && kids.length > 0)) {
149
+ filtered.push(kids ? { ...n, children: kids } : n)
150
+ }
151
+ }
152
+ return filtered
153
+ }
154
+
155
+ const visibleRoot = computed(() => filterNodes(props.nodes))
156
+ </script>
157
+
158
+ <style scoped>
159
+ .ui-tree {
160
+ font-size: 13px;
161
+ color: var(--color-text-primary, #e2e8f0);
162
+ }
163
+
164
+ .tree-search {
165
+ width: 100%;
166
+ padding: 6px 10px;
167
+ border-radius: 6px;
168
+ font-size: 13px;
169
+ background: var(--color-input-bg, rgba(15, 23, 42, 0.8));
170
+ color: var(--color-input-text, #e2e8f0);
171
+ border: 1px solid var(--color-border-primary, rgba(56, 189, 248, 0.3));
172
+ outline: none;
173
+ }
174
+
175
+ .tree-search::placeholder {
176
+ color: var(--color-input-placeholder, #64748b);
177
+ }
178
+
179
+ .tree-search:focus {
180
+ border-color: var(--color-accent-primary, #38bdf8);
181
+ }
182
+
183
+ .tree-empty {
184
+ padding: 20px 0;
185
+ text-align: center;
186
+ color: var(--color-text-tertiary, #94a3b8);
187
+ }
188
+
189
+ .tree-scroll {
190
+ max-height: 288px;
191
+ overflow-y: auto;
192
+ }
193
+ </style>
@@ -0,0 +1,145 @@
1
+ <template>
2
+ <div>
3
+ <div
4
+ class="tree-node"
5
+ :class="{ 'tree-node-selected': node.id === selectedKey }"
6
+ :style="{ paddingLeft: depth * 16 + 'px' }"
7
+ :data-testid="testId ? `${testId}-node-${node.id}` : undefined"
8
+ @click="emitSelect(node)"
9
+ >
10
+ <span class="tree-arrow" @click.stop="emitToggle(node)">
11
+ <span v-if="loading" class="tree-spinner"></span>
12
+ <span v-else-if="!expandable" class="tree-dot"></span>
13
+ <span v-else class="tree-caret" :class="{ 'tree-caret-open': expanded }">▸</span>
14
+ </span>
15
+ <span class="tree-label" :class="{ 'tree-label-selected': node.id === selectedKey }">{{ node.label }}</span>
16
+ </div>
17
+ <template v-if="expanded && node.children && node.children.length > 0">
18
+ <TreeNodeItem
19
+ v-for="child in node.children"
20
+ :key="child.id"
21
+ :node="child"
22
+ :depth="depth + 1"
23
+ :selected-key="selectedKey"
24
+ :expanded-keys="expandedKeys"
25
+ :loading-keys="loadingKeys"
26
+ :load-children="loadChildren"
27
+ :test-id="testId"
28
+ @toggle="emitToggle"
29
+ @select="emitSelect"
30
+ />
31
+ </template>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue'
37
+ import type { TreeNode } from './Tree.vue'
38
+
39
+ const props = defineProps<{
40
+ node: TreeNode
41
+ depth: number
42
+ selectedKey: string | null
43
+ expandedKeys: Set<string>
44
+ loadingKeys: Set<string>
45
+ loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
46
+ testId?: string
47
+ }>()
48
+
49
+ const emit = defineEmits<{
50
+ (e: 'toggle', node: TreeNode): void
51
+ (e: 'select', node: TreeNode): void
52
+ }>()
53
+
54
+ const expanded = computed(() => props.expandedKeys.has(props.node.id))
55
+ const loading = computed(() => props.loadingKeys.has(props.node.id))
56
+ const expandable = computed(() => {
57
+ if (props.node.isLeaf) return false
58
+ if (props.node.children && props.node.children.length > 0) return true
59
+ return !!props.loadChildren
60
+ })
61
+
62
+ function emitToggle(node: TreeNode) {
63
+ emit('toggle', node)
64
+ }
65
+
66
+ function emitSelect(node: TreeNode) {
67
+ emit('select', node)
68
+ }
69
+ </script>
70
+
71
+ <style scoped>
72
+ .tree-node {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 4px;
76
+ padding: 4px 6px;
77
+ border-radius: 6px;
78
+ cursor: pointer;
79
+ user-select: none;
80
+ transition: background 0.15s;
81
+ }
82
+
83
+ .tree-node:hover {
84
+ background: var(--color-bg-tertiary, rgba(30, 41, 59, 0.6));
85
+ }
86
+
87
+ .tree-node-selected {
88
+ background-color: color-mix(in srgb, var(--color-accent-primary, #38bdf8) 15%, transparent);
89
+ outline: 1px solid color-mix(in srgb, var(--color-accent-primary, #38bdf8) 35%, transparent);
90
+ outline-offset: -1px;
91
+ }
92
+
93
+ .tree-arrow {
94
+ display: inline-flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ width: 16px;
98
+ height: 16px;
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ .tree-caret {
103
+ font-size: 11px;
104
+ color: var(--color-text-tertiary, #94a3b8);
105
+ transition: transform 0.15s;
106
+ display: inline-block;
107
+ }
108
+
109
+ .tree-caret-open {
110
+ transform: rotate(90deg);
111
+ }
112
+
113
+ .tree-dot {
114
+ width: 5px;
115
+ height: 5px;
116
+ border-radius: 50%;
117
+ background: var(--color-text-tertiary, #94a3b8);
118
+ opacity: 0.5;
119
+ }
120
+
121
+ .tree-spinner {
122
+ width: 12px;
123
+ height: 12px;
124
+ border: 2px solid var(--color-border-primary, rgba(56, 189, 248, 0.3));
125
+ border-top-color: var(--color-accent-primary, #38bdf8);
126
+ border-radius: 50%;
127
+ animation: tree-spin 0.8s linear infinite;
128
+ }
129
+
130
+ @keyframes tree-spin {
131
+ to { transform: rotate(360deg); }
132
+ }
133
+
134
+ .tree-label {
135
+ color: var(--color-text-primary, #e2e8f0);
136
+ white-space: nowrap;
137
+ overflow: hidden;
138
+ text-overflow: ellipsis;
139
+ }
140
+
141
+ .tree-label-selected {
142
+ color: var(--color-accent-primary, #38bdf8);
143
+ font-weight: 600;
144
+ }
145
+ </style>
package/index.ts CHANGED
@@ -15,5 +15,7 @@ export { default as SearchSelect } from './components/SearchSelect.vue'
15
15
  export { default as SideMenu } from './components/SideMenu.vue'
16
16
  export { default as TopNavbar } from './components/TopNavbar.vue'
17
17
  export { default as DateTimePicker } from './components/DateTimePicker.vue'
18
+ export { default as Tree } from './components/Tree.vue'
19
+ export type { TreeNode } from './components/Tree.vue'
18
20
  export { useToast } from './composables/useToast'
19
21
  export type { Column, SelectOption, MenuItem, MenuGroup, NavModule, DropdownItem, ToastType, DateTimePreset } from './types'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aquiferre/ui-kit",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "index.ts",