@html-zj/demo 1.0.0
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/OrgTree.vue +57 -0
- package/OrgTreeSelect.vue +278 -0
- package/orgTreeApi.js +90 -0
- package/package.json +14 -0
package/OrgTree.vue
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="org-tree-page">
|
|
3
|
+
<h3>机构树公共组件示例</h3>
|
|
4
|
+
|
|
5
|
+
<el-card shadow="never">
|
|
6
|
+
<el-form label-width="110px" size="mini">
|
|
7
|
+
<el-form-item label="是否有默认值">
|
|
8
|
+
<el-switch v-model="useDefault"></el-switch>
|
|
9
|
+
</el-form-item>
|
|
10
|
+
<el-form-item label="机构选择">
|
|
11
|
+
<OrgTreeSelect v-model="selectedOrgId" />
|
|
12
|
+
</el-form-item>
|
|
13
|
+
</el-form>
|
|
14
|
+
<div class="selected-value">当前值:{{ selectedOrgId || '-' }}</div>
|
|
15
|
+
<div class="selected-value">当前模式:{{ useDefault ? '有默认值' : '无默认值' }}</div>
|
|
16
|
+
</el-card>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ORG_ID = '1320'
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
name: 'OrgTree',
|
|
27
|
+
components: {
|
|
28
|
+
OrgTreeSelect
|
|
29
|
+
},
|
|
30
|
+
data () {
|
|
31
|
+
return {
|
|
32
|
+
useDefault: false,
|
|
33
|
+
selectedOrgId: ''
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
watch: {
|
|
37
|
+
useDefault: {
|
|
38
|
+
immediate: true,
|
|
39
|
+
handler (value) {
|
|
40
|
+
this.selectedOrgId = value ? DEFAULT_ORG_ID : ''
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<style scoped>
|
|
48
|
+
.org-tree-page {
|
|
49
|
+
padding: 16px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.selected-value {
|
|
53
|
+
margin-top: 8px;
|
|
54
|
+
color: #606266;
|
|
55
|
+
font-size: 13px;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-popover
|
|
3
|
+
placement="bottom-start"
|
|
4
|
+
:width="width"
|
|
5
|
+
trigger="click"
|
|
6
|
+
v-model="visible"
|
|
7
|
+
@show="onPopoverShow"
|
|
8
|
+
>
|
|
9
|
+
<div class="tree-panel">
|
|
10
|
+
<el-input
|
|
11
|
+
size="mini"
|
|
12
|
+
clearable
|
|
13
|
+
:placeholder="searchPlaceholder"
|
|
14
|
+
v-model="searchKeyword"
|
|
15
|
+
@input="onSearchInput"
|
|
16
|
+
/>
|
|
17
|
+
<el-tree
|
|
18
|
+
v-if="!isSearchMode"
|
|
19
|
+
ref="lazyTree"
|
|
20
|
+
class="org-tree"
|
|
21
|
+
node-key="id"
|
|
22
|
+
lazy
|
|
23
|
+
:load="loadLazyTree"
|
|
24
|
+
:props="treeProps"
|
|
25
|
+
:expand-on-click-node="false"
|
|
26
|
+
@node-click="onNodeClick"
|
|
27
|
+
/>
|
|
28
|
+
<el-tree
|
|
29
|
+
v-else
|
|
30
|
+
:key="searchTreeKey"
|
|
31
|
+
class="org-tree"
|
|
32
|
+
node-key="id"
|
|
33
|
+
:data="searchTreeData"
|
|
34
|
+
:props="treeProps"
|
|
35
|
+
:default-expanded-keys="searchExpandedKeys"
|
|
36
|
+
:auto-expand-parent="true"
|
|
37
|
+
:expand-on-click-node="false"
|
|
38
|
+
@node-click="onNodeClick"
|
|
39
|
+
/>
|
|
40
|
+
<!-- <div v-show="loading" class="panel-tip">加载中...</div>
|
|
41
|
+
<div v-show="searchNoResult" class="panel-tip">没有匹配到机构</div> -->
|
|
42
|
+
</div>
|
|
43
|
+
<el-input
|
|
44
|
+
slot="reference"
|
|
45
|
+
class="reference-input"
|
|
46
|
+
size="mini"
|
|
47
|
+
readonly
|
|
48
|
+
clearable
|
|
49
|
+
:placeholder="placeholder"
|
|
50
|
+
v-model="selectedName"
|
|
51
|
+
@clear="clearSelection"
|
|
52
|
+
>
|
|
53
|
+
<i slot="suffix" class="el-input__icon el-icon-arrow-down"></i>
|
|
54
|
+
</el-input>
|
|
55
|
+
</el-popover>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<script>
|
|
59
|
+
import { fetchOrgById, fetchOrgChildren, searchOrgTreeByName } from '@/mock/orgTreeApi'
|
|
60
|
+
|
|
61
|
+
export default {
|
|
62
|
+
name: 'OrgTreeSelect',
|
|
63
|
+
// 组件对外参数说明:
|
|
64
|
+
// 1) value: 当前选中的机构 id(配合 v-model 使用)
|
|
65
|
+
// 2) placeholder/searchPlaceholder: 输入框提示文案
|
|
66
|
+
// 3) width: 下拉面板宽度
|
|
67
|
+
// 4) fetchChildrenApi/searchTreeApi/fetchByIdApi: 可注入的接口函数,默认使用 mock
|
|
68
|
+
props: {
|
|
69
|
+
value: {
|
|
70
|
+
type: String,
|
|
71
|
+
default: ''
|
|
72
|
+
},
|
|
73
|
+
placeholder: {
|
|
74
|
+
type: String,
|
|
75
|
+
default: '请选择机构'
|
|
76
|
+
},
|
|
77
|
+
searchPlaceholder: {
|
|
78
|
+
type: String,
|
|
79
|
+
default: '输入机构名称搜索'
|
|
80
|
+
},
|
|
81
|
+
width: {
|
|
82
|
+
type: Number,
|
|
83
|
+
default: 420
|
|
84
|
+
},
|
|
85
|
+
fetchChildrenApi: {
|
|
86
|
+
type: Function,
|
|
87
|
+
default: fetchOrgChildren
|
|
88
|
+
},
|
|
89
|
+
searchTreeApi: {
|
|
90
|
+
type: Function,
|
|
91
|
+
default: searchOrgTreeByName
|
|
92
|
+
},
|
|
93
|
+
fetchByIdApi: {
|
|
94
|
+
type: Function,
|
|
95
|
+
default: fetchOrgById
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
data () {
|
|
99
|
+
return {
|
|
100
|
+
// 控制 popover 展开/关闭
|
|
101
|
+
visible: false,
|
|
102
|
+
// 面板加载状态(懒加载和搜索共用)
|
|
103
|
+
loading: false,
|
|
104
|
+
// 输入框展示的机构名称(与 value 对应)
|
|
105
|
+
selectedName: '',
|
|
106
|
+
// 搜索关键词
|
|
107
|
+
searchKeyword: '',
|
|
108
|
+
// 搜索模式下的树数据
|
|
109
|
+
searchTreeData: [],
|
|
110
|
+
// 搜索模式下需要默认展开的节点 id 列表(用于展开根到目标路径)
|
|
111
|
+
searchExpandedKeys: [],
|
|
112
|
+
// 强制重建搜索树,保证 default-expanded-keys 每次搜索都生效
|
|
113
|
+
searchTreeKey: 0,
|
|
114
|
+
// 是否处于搜索模式:false=懒加载树,true=搜索结果树
|
|
115
|
+
isSearchMode: false,
|
|
116
|
+
searchNoResult: false,
|
|
117
|
+
// 搜索防抖定时器
|
|
118
|
+
searchTimer: null,
|
|
119
|
+
// el-tree 字段映射
|
|
120
|
+
treeProps: {
|
|
121
|
+
label: 'name',
|
|
122
|
+
children: 'children',
|
|
123
|
+
isLeaf: 'isLeaf'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
watch: {
|
|
128
|
+
value: {
|
|
129
|
+
immediate: true,
|
|
130
|
+
// 外部 v-model 变化时,同步回显机构名
|
|
131
|
+
handler (id) {
|
|
132
|
+
this.syncSelectedName(id)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
beforeDestroy () {
|
|
137
|
+
// 组件销毁前清理定时器,避免内存泄漏
|
|
138
|
+
this.clearSearchTimer()
|
|
139
|
+
},
|
|
140
|
+
methods: {
|
|
141
|
+
// 根据机构 id 拉取详情,用于默认值和回显
|
|
142
|
+
async syncSelectedName (id) {
|
|
143
|
+
if (!id) {
|
|
144
|
+
this.selectedName = ''
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const node = await this.fetchByIdApi(id)
|
|
148
|
+
this.selectedName = node ? node.name : ''
|
|
149
|
+
},
|
|
150
|
+
async onPopoverShow () {
|
|
151
|
+
// 每次打开面板,如果当前无搜索词,切回懒加载模式
|
|
152
|
+
if (!this.searchKeyword) {
|
|
153
|
+
this.isSearchMode = false
|
|
154
|
+
this.searchTreeData = []
|
|
155
|
+
this.searchNoResult = false
|
|
156
|
+
}
|
|
157
|
+
if (this.isSearchMode) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const treeRef = this.$refs.lazyTree
|
|
161
|
+
if (treeRef && treeRef.store) {
|
|
162
|
+
// 打开面板时确保根节点被加载出来
|
|
163
|
+
await treeRef.store.load(null, () => {})
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
// el-tree 懒加载函数:按父节点 id 加载直接子节点
|
|
167
|
+
async loadLazyTree (node, resolve) {
|
|
168
|
+
this.loading = true
|
|
169
|
+
try {
|
|
170
|
+
const parentId = node.level === 0 ? null : node.data.id
|
|
171
|
+
const children = await this.fetchChildrenApi(parentId)
|
|
172
|
+
resolve(
|
|
173
|
+
children.map(item => ({
|
|
174
|
+
id: item.id,
|
|
175
|
+
name: item.name,
|
|
176
|
+
isLeaf: !item.hasChildren
|
|
177
|
+
}))
|
|
178
|
+
)
|
|
179
|
+
} finally {
|
|
180
|
+
this.loading = false
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
// 输入搜索词时做防抖,减少接口调用频率
|
|
184
|
+
onSearchInput () {
|
|
185
|
+
this.clearSearchTimer()
|
|
186
|
+
this.searchTimer = setTimeout(() => {
|
|
187
|
+
this.searchTree()
|
|
188
|
+
}, 350)
|
|
189
|
+
},
|
|
190
|
+
clearSearchTimer () {
|
|
191
|
+
if (this.searchTimer) {
|
|
192
|
+
clearTimeout(this.searchTimer)
|
|
193
|
+
}
|
|
194
|
+
this.searchTimer = null
|
|
195
|
+
},
|
|
196
|
+
// 搜索机构:返回“根 -> 命中机构”的树路径结构并展开显示
|
|
197
|
+
async searchTree () {
|
|
198
|
+
const keyword = this.searchKeyword.trim()
|
|
199
|
+
if (!keyword) {
|
|
200
|
+
this.isSearchMode = false
|
|
201
|
+
this.searchTreeData = []
|
|
202
|
+
this.searchExpandedKeys = []
|
|
203
|
+
this.searchNoResult = false
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
this.loading = true
|
|
207
|
+
try {
|
|
208
|
+
const treeData = await this.searchTreeApi(keyword)
|
|
209
|
+
this.searchTreeData = treeData
|
|
210
|
+
this.searchExpandedKeys = this.collectExpandableNodeIds(treeData)
|
|
211
|
+
this.searchTreeKey += 1
|
|
212
|
+
this.isSearchMode = true
|
|
213
|
+
this.searchNoResult = treeData.length === 0
|
|
214
|
+
} finally {
|
|
215
|
+
this.loading = false
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
// 提取所有非叶子节点 id,让树从根级逐层展开
|
|
219
|
+
collectExpandableNodeIds (treeData) {
|
|
220
|
+
const keys = []
|
|
221
|
+
const loop = (nodes) => {
|
|
222
|
+
nodes.forEach(node => {
|
|
223
|
+
if (node.children && node.children.length > 0) {
|
|
224
|
+
keys.push(node.id)
|
|
225
|
+
loop(node.children)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
loop(treeData || [])
|
|
230
|
+
return keys
|
|
231
|
+
},
|
|
232
|
+
// 点击任意节点即选中,向外抛出 input/change
|
|
233
|
+
onNodeClick (nodeData) {
|
|
234
|
+
this.selectedName = nodeData.name
|
|
235
|
+
this.visible = false
|
|
236
|
+
this.$emit('input', nodeData.id)
|
|
237
|
+
this.$emit('change', { id: nodeData.id, name: nodeData.name })
|
|
238
|
+
},
|
|
239
|
+
// 清空当前选中
|
|
240
|
+
clearSelection () {
|
|
241
|
+
this.selectedName = ''
|
|
242
|
+
this.$emit('input', '')
|
|
243
|
+
this.$emit('change', { id: '', name: '' })
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
</script>
|
|
248
|
+
|
|
249
|
+
<style scoped>
|
|
250
|
+
.reference-input {
|
|
251
|
+
cursor: pointer;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* reference 插槽的输入框及图标统一显示手型 */
|
|
255
|
+
.reference-input ::v-deep .el-input__inner,
|
|
256
|
+
.reference-input ::v-deep .el-input__suffix {
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.tree-panel {
|
|
261
|
+
display: flex;
|
|
262
|
+
flex-direction: column;
|
|
263
|
+
gap: 8px;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.org-tree {
|
|
267
|
+
max-height: 280px;
|
|
268
|
+
overflow: auto;
|
|
269
|
+
border: 1px solid #ebeef5;
|
|
270
|
+
padding: 8px;
|
|
271
|
+
border-radius: 4px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.panel-tip {
|
|
275
|
+
color: #909399;
|
|
276
|
+
font-size: 12px;
|
|
277
|
+
}
|
|
278
|
+
</style>
|
package/orgTreeApi.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const ORG_LIST = [
|
|
2
|
+
{ id: '1000', name: '集团总部', parentId: null },
|
|
3
|
+
{ id: '1100', name: '华北大区', parentId: '1000' },
|
|
4
|
+
{ id: '1200', name: '华东大区', parentId: '1000' },
|
|
5
|
+
{ id: '1300', name: '华南大区', parentId: '1000' },
|
|
6
|
+
{ id: '1110', name: '北京分公司', parentId: '1100' },
|
|
7
|
+
{ id: '1120', name: '天津分公司', parentId: '1100' },
|
|
8
|
+
{ id: '1210', name: '上海分公司', parentId: '1200' },
|
|
9
|
+
{ id: '1220', name: '杭州分公司', parentId: '1200' },
|
|
10
|
+
{ id: '1310', name: '广州分公司', parentId: '1300' },
|
|
11
|
+
{ id: '1320', name: '深圳分公司', parentId: '1300' },
|
|
12
|
+
{ id: '1111', name: '北京一部', parentId: '1110' },
|
|
13
|
+
{ id: '1112', name: '北京二部', parentId: '1110' },
|
|
14
|
+
{ id: '1211', name: '上海浦东事业部', parentId: '1210' },
|
|
15
|
+
{ id: '1212', name: '上海闵行事业部', parentId: '1210' },
|
|
16
|
+
{ id: '1321', name: '深圳南山事业部', parentId: '1320' }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function delay (ms = 260) {
|
|
20
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getChildrenByParentId (parentId) {
|
|
24
|
+
const children = ORG_LIST.filter(item => item.parentId === parentId)
|
|
25
|
+
return children.map(item => ({
|
|
26
|
+
id: item.id,
|
|
27
|
+
name: item.name,
|
|
28
|
+
hasChildren: ORG_LIST.some(candidate => candidate.parentId === item.id)
|
|
29
|
+
}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getNodeById (id) {
|
|
33
|
+
return ORG_LIST.find(item => item.id === id) || null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getAncestorPathIds (id) {
|
|
37
|
+
const ids = []
|
|
38
|
+
let cursor = getNodeById(id)
|
|
39
|
+
while (cursor) {
|
|
40
|
+
ids.unshift(cursor.id)
|
|
41
|
+
cursor = cursor.parentId ? getNodeById(cursor.parentId) : null
|
|
42
|
+
}
|
|
43
|
+
return ids
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mergePathToTree (tree, pathIds) {
|
|
47
|
+
let currentLevel = tree
|
|
48
|
+
for (let i = 0; i < pathIds.length; i++) {
|
|
49
|
+
const id = pathIds[i]
|
|
50
|
+
const source = getNodeById(id)
|
|
51
|
+
if (!source) {
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
let currentNode = currentLevel.find(item => item.id === id)
|
|
55
|
+
if (!currentNode) {
|
|
56
|
+
currentNode = {
|
|
57
|
+
id: source.id,
|
|
58
|
+
name: source.name,
|
|
59
|
+
hasChildren: ORG_LIST.some(candidate => candidate.parentId === source.id),
|
|
60
|
+
children: []
|
|
61
|
+
}
|
|
62
|
+
currentLevel.push(currentNode)
|
|
63
|
+
}
|
|
64
|
+
currentLevel = currentNode.children
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchOrgChildren (parentId) {
|
|
69
|
+
await delay()
|
|
70
|
+
return getChildrenByParentId(parentId || null)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function searchOrgTreeByName (keyword) {
|
|
74
|
+
await delay(320)
|
|
75
|
+
const trimmed = (keyword || '').trim()
|
|
76
|
+
if (!trimmed) {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
const matchedNodes = ORG_LIST.filter(item => item.name.includes(trimmed))
|
|
80
|
+
const tree = []
|
|
81
|
+
matchedNodes.forEach(node => {
|
|
82
|
+
mergePathToTree(tree, getAncestorPathIds(node.id))
|
|
83
|
+
})
|
|
84
|
+
return tree
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function fetchOrgById (id) {
|
|
88
|
+
await delay(120)
|
|
89
|
+
return getNodeById(id)
|
|
90
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@html-zj/demo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "demo",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
}
|
|
14
|
+
}
|