@xlui/xux-ui 0.3.0 → 1.1.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/dist/assets/areas.worker-Ci--LHMH.js +1 -0
- package/dist/browser-Bfcp93e9.js +8 -0
- package/dist/browser-Dj1SWzn2.mjs +1456 -0
- package/dist/index.css +1 -1
- package/dist/index.js +34 -34
- package/dist/index.mjs +18004 -2748
- package/package.json +70 -63
- package/src/components/Button/index.vue +6 -6
- package/src/components/DateTimePicker/index.vue +121 -39
- package/src/components/Qrcode/index.vue +390 -0
- package/src/components/RegionCascader/areas.worker.ts +196 -0
- package/src/components/RegionCascader/data/china-areas.full.json +14464 -0
- package/src/components/RegionCascader/data/init.mjs +377 -0
- package/src/components/RegionCascader/index.vue +870 -0
- package/src/components/Select/index.vue +25 -22
- package/src/components/SpecialEffects/fireworks.vue +134 -0
- package/src/components/SpecialEffects/glow.vue +377 -0
- package/src/components/Switch/index.vue +127 -26
- package/src/index.ts +8 -0
- package/LICENSE +0 -194
@@ -0,0 +1,870 @@
|
|
1
|
+
<template>
|
2
|
+
<div ref="containerRef" class="region-cascader-container relative">
|
3
|
+
<!-- 输入框 -->
|
4
|
+
<div
|
5
|
+
@click="toggleDropdown"
|
6
|
+
:class="[
|
7
|
+
'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#1a1a1a] focus:border-[#1a1a1a] transition-colors duration-200 cursor-pointer bg-white flex items-center justify-between',
|
8
|
+
customClass,
|
9
|
+
{ 'opacity-60 cursor-not-allowed': disabled }
|
10
|
+
]"
|
11
|
+
:tabindex="disabled ? -1 : 0"
|
12
|
+
@keydown.enter="toggleDropdown"
|
13
|
+
@keydown.space="toggleDropdown"
|
14
|
+
@keydown.escape="closeDropdown"
|
15
|
+
>
|
16
|
+
<div class="flex items-center gap-2 flex-1 overflow-hidden">
|
17
|
+
<span v-if="displayValue" class="truncate">{{ displayValue }}</span>
|
18
|
+
<span v-else class="text-gray-400">{{ placeholder }}</span>
|
19
|
+
</div>
|
20
|
+
<svg
|
21
|
+
class="w-5 h-5 text-gray-400 transition-transform duration-200 flex-shrink-0"
|
22
|
+
:class="{ 'rotate-180': isOpen }"
|
23
|
+
fill="none"
|
24
|
+
stroke="currentColor"
|
25
|
+
viewBox="0 0 24 24"
|
26
|
+
>
|
27
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
28
|
+
</svg>
|
29
|
+
</div>
|
30
|
+
|
31
|
+
<!-- 级联下拉框 -->
|
32
|
+
<div
|
33
|
+
v-if="isOpen && !disabled"
|
34
|
+
class="absolute z-[9999] mt-1 bg-white border border-gray-300 rounded-md shadow-lg"
|
35
|
+
:style="{ minWidth: '100%', width: 'max-content' }"
|
36
|
+
>
|
37
|
+
<!-- 搜索框 -->
|
38
|
+
<div v-if="searchable" class="p-3 border-b border-gray-200">
|
39
|
+
<div class="relative">
|
40
|
+
<input
|
41
|
+
ref="searchInputRef"
|
42
|
+
v-model="searchKeyword"
|
43
|
+
type="text"
|
44
|
+
:placeholder="searchPlaceholder"
|
45
|
+
class="w-full px-3 py-2 pl-9 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#1a1a1a] focus:border-[#1a1a1a] transition-colors"
|
46
|
+
@input="handleSearch"
|
47
|
+
@keydown.stop
|
48
|
+
/>
|
49
|
+
<svg
|
50
|
+
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
51
|
+
fill="none"
|
52
|
+
stroke="currentColor"
|
53
|
+
viewBox="0 0 24 24"
|
54
|
+
>
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
56
|
+
</svg>
|
57
|
+
<button
|
58
|
+
v-if="searchKeyword"
|
59
|
+
@click="clearSearch"
|
60
|
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
61
|
+
>
|
62
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
63
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
64
|
+
</svg>
|
65
|
+
</button>
|
66
|
+
</div>
|
67
|
+
<!-- 当前选择路径提示 -->
|
68
|
+
<div v-if="currentPath" class="mt-2 text-xs text-gray-500 flex items-center gap-1">
|
69
|
+
<span>当前:</span>
|
70
|
+
<span class="font-medium">{{ currentPath }}</span>
|
71
|
+
</div>
|
72
|
+
</div>
|
73
|
+
|
74
|
+
<!-- 全局搜索结果 -->
|
75
|
+
<div v-if="searchable && searchKeyword && searchResults.length > 0" class="max-h-80 overflow-y-auto" style="min-width: 600px;">
|
76
|
+
<div class="px-3 py-2 text-xs text-gray-500 bg-gray-50 sticky top-0">
|
77
|
+
找到 {{ searchResults.length }} 个结果(点击自动定位)
|
78
|
+
</div>
|
79
|
+
<div
|
80
|
+
v-for="result in searchResults"
|
81
|
+
:key="result.value"
|
82
|
+
@click.stop="selectFromGlobalSearch(result)"
|
83
|
+
class="px-3 py-2 hover:bg-gray-50 cursor-pointer transition-colors border-b border-gray-100"
|
84
|
+
>
|
85
|
+
<div class="flex items-center justify-between">
|
86
|
+
<div class="flex-1">
|
87
|
+
<span v-html="highlightText(result.label, searchKeyword)" class="font-medium"></span>
|
88
|
+
<span class="ml-2 text-xs text-gray-400">
|
89
|
+
{{ ['', '省', '市', '区'][result.level || 0] }}
|
90
|
+
</span>
|
91
|
+
</div>
|
92
|
+
</div>
|
93
|
+
<div v-if="result.pathLabels && result.pathLabels.length > 1" class="text-xs text-gray-500 mt-1">
|
94
|
+
{{ result.pathLabels.slice(0, -1).join(' > ') }}
|
95
|
+
</div>
|
96
|
+
</div>
|
97
|
+
</div>
|
98
|
+
|
99
|
+
<!-- 级联列表 -->
|
100
|
+
<div v-if="!searchable || !searchKeyword || searchResults.length === 0" class="flex" style="min-width: 600px;">
|
101
|
+
<!-- 省份列表 -->
|
102
|
+
<div ref="provinceListRef" class="flex-1 border-r border-gray-200 max-h-60 overflow-y-auto min-w-[200px]">
|
103
|
+
<div
|
104
|
+
v-for="province in filteredProvinces"
|
105
|
+
:key="province.value"
|
106
|
+
:ref="el => { if (selectedProvince?.value === province.value) selectedProvinceRef = el as HTMLElement }"
|
107
|
+
@click.stop="selectProvince(province)"
|
108
|
+
class="px-3 py-2 hover:bg-gray-50 cursor-pointer transition-colors whitespace-nowrap"
|
109
|
+
:class="{ 'bg-blue-50 text-blue-600': selectedProvince?.value === province.value }"
|
110
|
+
v-html="highlightText(province.label, searchKeyword)"
|
111
|
+
>
|
112
|
+
</div>
|
113
|
+
</div>
|
114
|
+
|
115
|
+
<!-- 城市列表 -->
|
116
|
+
<div
|
117
|
+
v-if="filteredCities.length > 0"
|
118
|
+
ref="cityListRef"
|
119
|
+
class="flex-1 border-r border-gray-200 max-h-60 overflow-y-auto min-w-[200px]"
|
120
|
+
>
|
121
|
+
<div
|
122
|
+
v-for="city in filteredCities"
|
123
|
+
:key="city.value"
|
124
|
+
:ref="el => { if (selectedCity?.value === city.value) selectedCityRef = el as HTMLElement }"
|
125
|
+
@click.stop="selectCity(city)"
|
126
|
+
class="px-3 py-2 hover:bg-gray-50 cursor-pointer transition-colors whitespace-nowrap"
|
127
|
+
:class="{ 'bg-blue-50 text-blue-600': selectedCity?.value === city.value }"
|
128
|
+
v-html="highlightText(city.label, searchKeyword)"
|
129
|
+
>
|
130
|
+
</div>
|
131
|
+
</div>
|
132
|
+
|
133
|
+
<!-- 区县列表 -->
|
134
|
+
<div
|
135
|
+
v-if="filteredDistricts.length > 0"
|
136
|
+
ref="districtListRef"
|
137
|
+
class="flex-1 border-r border-gray-200 max-h-60 overflow-y-auto min-w-[200px]"
|
138
|
+
>
|
139
|
+
<div
|
140
|
+
v-for="district in filteredDistricts"
|
141
|
+
:key="district.value"
|
142
|
+
:ref="el => { if (selectedDistrict?.value === district.value) selectedDistrictRef = el as HTMLElement }"
|
143
|
+
@click.stop="selectDistrict(district)"
|
144
|
+
class="px-3 py-2 hover:bg-gray-50 cursor-pointer transition-colors whitespace-nowrap"
|
145
|
+
:class="{ 'bg-blue-50 text-blue-600': selectedDistrict?.value === district.value }"
|
146
|
+
v-html="highlightText(district.label, searchKeyword)"
|
147
|
+
>
|
148
|
+
</div>
|
149
|
+
</div>
|
150
|
+
|
151
|
+
</div>
|
152
|
+
|
153
|
+
<!-- 数据加载提示 -->
|
154
|
+
<div v-if="!workerReady" class="px-4 py-8 text-center text-gray-400">
|
155
|
+
<div class="mb-2">正在初始化地区数据...</div>
|
156
|
+
<div class="text-xs">请稍候</div>
|
157
|
+
</div>
|
158
|
+
<div v-else-if="provinces.length === 0" class="px-4 py-8 text-center text-gray-400">
|
159
|
+
<div class="mb-2">暂无数据</div>
|
160
|
+
</div>
|
161
|
+
</div>
|
162
|
+
|
163
|
+
<!-- 遮罩层 -->
|
164
|
+
<div
|
165
|
+
v-if="isOpen && !disabled"
|
166
|
+
class="fixed inset-0 z-[9998]"
|
167
|
+
@click="closeDropdown"
|
168
|
+
></div>
|
169
|
+
</div>
|
170
|
+
</template>
|
171
|
+
|
172
|
+
<script setup lang="ts">
|
173
|
+
/**
|
174
|
+
* RegionCascader 中国地区级联选择器(支持5级)
|
175
|
+
* @displayName XRegionCascader
|
176
|
+
*/
|
177
|
+
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
178
|
+
import chinaAreasData from './data/china-areas.full.json'
|
179
|
+
|
180
|
+
interface AreaNode {
|
181
|
+
value: string
|
182
|
+
label: string
|
183
|
+
leaf: boolean
|
184
|
+
level?: number
|
185
|
+
path?: string[]
|
186
|
+
pathLabels?: string[]
|
187
|
+
}
|
188
|
+
|
189
|
+
export interface RegionCascaderProps {
|
190
|
+
modelValue?: string[]
|
191
|
+
placeholder?: string
|
192
|
+
disabled?: boolean
|
193
|
+
customClass?: string
|
194
|
+
separator?: string
|
195
|
+
maxLevel?: 1 | 2 | 3 // 最大级别:1-省,2-省市,3-省市区
|
196
|
+
searchable?: boolean // 是否启用搜索功能
|
197
|
+
}
|
198
|
+
|
199
|
+
const props = withDefaults(defineProps<RegionCascaderProps>(), {
|
200
|
+
modelValue: () => [],
|
201
|
+
placeholder: '请选择地区',
|
202
|
+
disabled: false,
|
203
|
+
customClass: '',
|
204
|
+
separator: ' / ',
|
205
|
+
maxLevel: 3,
|
206
|
+
searchable: false
|
207
|
+
})
|
208
|
+
|
209
|
+
const emit = defineEmits<{
|
210
|
+
'update:modelValue': [value: string[]]
|
211
|
+
'change': [value: string[], labels: string[]]
|
212
|
+
}>()
|
213
|
+
|
214
|
+
// 下拉框状态
|
215
|
+
const isOpen = ref(false)
|
216
|
+
const containerRef = ref<HTMLElement>()
|
217
|
+
const searchInputRef = ref<HTMLInputElement>()
|
218
|
+
const provinceListRef = ref<HTMLElement>()
|
219
|
+
const cityListRef = ref<HTMLElement>()
|
220
|
+
const districtListRef = ref<HTMLElement>()
|
221
|
+
const selectedProvinceRef = ref<HTMLElement>()
|
222
|
+
const selectedCityRef = ref<HTMLElement>()
|
223
|
+
const selectedDistrictRef = ref<HTMLElement>()
|
224
|
+
const workerReady = ref(false)
|
225
|
+
|
226
|
+
// 搜索关键词
|
227
|
+
const searchKeyword = ref('')
|
228
|
+
const searchResults = ref<AreaNode[]>([])
|
229
|
+
const isSearching = ref(false)
|
230
|
+
|
231
|
+
// Worker实例
|
232
|
+
let worker: Worker | null = null
|
233
|
+
|
234
|
+
// 缓存已加载的children
|
235
|
+
const childrenCache = new Map<string, AreaNode[]>()
|
236
|
+
|
237
|
+
// 各级选中的值
|
238
|
+
const selectedProvince = ref<AreaNode | null>(null)
|
239
|
+
const selectedCity = ref<AreaNode | null>(null)
|
240
|
+
const selectedDistrict = ref<AreaNode | null>(null)
|
241
|
+
|
242
|
+
// 各级列表数据
|
243
|
+
const provinces = ref<AreaNode[]>([])
|
244
|
+
const cities = ref<AreaNode[]>([])
|
245
|
+
const districts = ref<AreaNode[]>([])
|
246
|
+
|
247
|
+
// 显示的值
|
248
|
+
const displayValue = computed(() => {
|
249
|
+
const labels: string[] = []
|
250
|
+
if (selectedProvince.value) labels.push(selectedProvince.value.label)
|
251
|
+
if (selectedCity.value) labels.push(selectedCity.value.label)
|
252
|
+
if (selectedDistrict.value) labels.push(selectedDistrict.value.label)
|
253
|
+
return labels.length > 0 ? labels.join(props.separator) : ''
|
254
|
+
})
|
255
|
+
|
256
|
+
// 搜索占位符(全局搜索)
|
257
|
+
const searchPlaceholder = computed(() => {
|
258
|
+
return '搜索省/市/区,支持智能定位...'
|
259
|
+
})
|
260
|
+
|
261
|
+
// 当前选择路径
|
262
|
+
const currentPath = computed(() => {
|
263
|
+
const labels: string[] = []
|
264
|
+
if (selectedProvince.value) labels.push(selectedProvince.value.label)
|
265
|
+
if (selectedCity.value) labels.push(selectedCity.value.label)
|
266
|
+
if (selectedDistrict.value) labels.push(selectedDistrict.value.label)
|
267
|
+
return labels.length > 0 ? labels.join(' > ') : ''
|
268
|
+
})
|
269
|
+
|
270
|
+
// 过滤后的列表
|
271
|
+
const filteredProvinces = computed(() => {
|
272
|
+
if (!searchKeyword.value.trim()) return provinces.value
|
273
|
+
const keyword = searchKeyword.value.toLowerCase().trim()
|
274
|
+
return provinces.value.filter(p =>
|
275
|
+
p.label.toLowerCase().includes(keyword) || p.value.includes(keyword)
|
276
|
+
)
|
277
|
+
})
|
278
|
+
|
279
|
+
const filteredCities = computed(() => {
|
280
|
+
if (!searchKeyword.value.trim()) return cities.value
|
281
|
+
const keyword = searchKeyword.value.toLowerCase().trim()
|
282
|
+
return cities.value.filter(c =>
|
283
|
+
c.label.toLowerCase().includes(keyword) || c.value.includes(keyword)
|
284
|
+
)
|
285
|
+
})
|
286
|
+
|
287
|
+
const filteredDistricts = computed(() => {
|
288
|
+
if (!searchKeyword.value.trim()) return districts.value
|
289
|
+
const keyword = searchKeyword.value.toLowerCase().trim()
|
290
|
+
return districts.value.filter(d =>
|
291
|
+
d.label.toLowerCase().includes(keyword) || d.value.includes(keyword)
|
292
|
+
)
|
293
|
+
})
|
294
|
+
|
295
|
+
// 高亮文本
|
296
|
+
function highlightText(text: string, keyword: string): string {
|
297
|
+
if (!keyword || !keyword.trim()) return text
|
298
|
+
|
299
|
+
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
300
|
+
const regex = new RegExp(`(${escapedKeyword})`, 'gi')
|
301
|
+
return text.replace(regex, '<span class="bg-yellow-200 font-semibold">$1</span>')
|
302
|
+
}
|
303
|
+
|
304
|
+
// 从Worker获取子节点
|
305
|
+
function loadChildren(parentValue: string): Promise<AreaNode[]> {
|
306
|
+
return new Promise((resolve) => {
|
307
|
+
if (!worker || !workerReady.value) {
|
308
|
+
resolve([])
|
309
|
+
return
|
310
|
+
}
|
311
|
+
|
312
|
+
// 检查缓存
|
313
|
+
if (childrenCache.has(parentValue)) {
|
314
|
+
resolve(childrenCache.get(parentValue)!)
|
315
|
+
return
|
316
|
+
}
|
317
|
+
|
318
|
+
const handler = (e: MessageEvent) => {
|
319
|
+
if (e.data?.type === 'children' && e.data.parent === parentValue) {
|
320
|
+
const list = e.data.list || []
|
321
|
+
childrenCache.set(parentValue, list)
|
322
|
+
resolve(list)
|
323
|
+
worker!.removeEventListener('message', handler)
|
324
|
+
}
|
325
|
+
}
|
326
|
+
|
327
|
+
worker.addEventListener('message', handler)
|
328
|
+
worker.postMessage({ type: 'children', payload: { parent: parentValue } })
|
329
|
+
|
330
|
+
// 超时处理
|
331
|
+
setTimeout(() => {
|
332
|
+
worker!.removeEventListener('message', handler)
|
333
|
+
resolve([])
|
334
|
+
}, 5000)
|
335
|
+
})
|
336
|
+
}
|
337
|
+
|
338
|
+
// 加载根节点(省份)
|
339
|
+
function loadRoots(): Promise<AreaNode[]> {
|
340
|
+
return new Promise((resolve) => {
|
341
|
+
if (!worker || !workerReady.value) {
|
342
|
+
resolve([])
|
343
|
+
return
|
344
|
+
}
|
345
|
+
|
346
|
+
const handler = (e: MessageEvent) => {
|
347
|
+
if (e.data?.type === 'roots') {
|
348
|
+
const list = e.data.list || []
|
349
|
+
resolve(list)
|
350
|
+
worker!.removeEventListener('message', handler)
|
351
|
+
}
|
352
|
+
}
|
353
|
+
|
354
|
+
worker.addEventListener('message', handler)
|
355
|
+
worker.postMessage({ type: 'roots' })
|
356
|
+
|
357
|
+
// 超时处理
|
358
|
+
setTimeout(() => {
|
359
|
+
worker!.removeEventListener('message', handler)
|
360
|
+
resolve([])
|
361
|
+
}, 5000)
|
362
|
+
})
|
363
|
+
}
|
364
|
+
|
365
|
+
// 切换下拉框
|
366
|
+
const toggleDropdown = async () => {
|
367
|
+
if (!props.disabled) {
|
368
|
+
isOpen.value = !isOpen.value
|
369
|
+
|
370
|
+
if (isOpen.value) {
|
371
|
+
// 打开时加载省份列表
|
372
|
+
if (provinces.value.length === 0) {
|
373
|
+
provinces.value = await loadRoots()
|
374
|
+
}
|
375
|
+
|
376
|
+
// 恢复之前的选择状态
|
377
|
+
await restoreSelection()
|
378
|
+
|
379
|
+
// 打开时聚焦搜索框(仅当启用搜索时)
|
380
|
+
if (props.searchable) {
|
381
|
+
setTimeout(() => {
|
382
|
+
searchInputRef.value?.focus()
|
383
|
+
}, 100)
|
384
|
+
}
|
385
|
+
}
|
386
|
+
}
|
387
|
+
}
|
388
|
+
|
389
|
+
// 恢复选择状态(关闭再打开时)
|
390
|
+
const restoreSelection = async () => {
|
391
|
+
// 如果有选中的省份,加载对应的城市列表
|
392
|
+
if (selectedProvince.value && props.maxLevel >= 2) {
|
393
|
+
const citiesList = await loadChildren(selectedProvince.value.value)
|
394
|
+
cities.value = citiesList
|
395
|
+
console.log('[RegionCascader] 恢复省份选择,加载城市:', citiesList.length)
|
396
|
+
|
397
|
+
// 滚动到选中的省份
|
398
|
+
await nextTick()
|
399
|
+
scrollToSelectedProvince()
|
400
|
+
}
|
401
|
+
|
402
|
+
// 如果有选中的城市,加载对应的区县列表
|
403
|
+
if (selectedCity.value && props.maxLevel >= 3) {
|
404
|
+
const districtsList = await loadChildren(selectedCity.value.value)
|
405
|
+
districts.value = districtsList
|
406
|
+
console.log('[RegionCascader] 恢复城市选择,加载区县:', districtsList.length)
|
407
|
+
|
408
|
+
// 滚动到选中的城市
|
409
|
+
await nextTick()
|
410
|
+
scrollToSelectedCity()
|
411
|
+
}
|
412
|
+
|
413
|
+
// 如果有选中的区县,也滚动到选中的区县
|
414
|
+
if (selectedDistrict.value && props.maxLevel >= 3) {
|
415
|
+
await nextTick()
|
416
|
+
scrollToSelectedDistrict()
|
417
|
+
}
|
418
|
+
}
|
419
|
+
|
420
|
+
// 通用滚动函数(无感滚动)
|
421
|
+
const scrollToSelected = (containerRef: HTMLElement | undefined, elementRef: HTMLElement | undefined, label: string) => {
|
422
|
+
if (!elementRef || !containerRef) return
|
423
|
+
|
424
|
+
// 计算滚动位置,让选中项居中显示
|
425
|
+
const containerHeight = containerRef.clientHeight
|
426
|
+
const elementTop = elementRef.offsetTop
|
427
|
+
const elementHeight = elementRef.clientHeight
|
428
|
+
|
429
|
+
const scrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2)
|
430
|
+
|
431
|
+
// 使用 auto 实现瞬间滚动(无感)
|
432
|
+
containerRef.scrollTo({
|
433
|
+
top: Math.max(0, scrollTop),
|
434
|
+
behavior: 'auto'
|
435
|
+
})
|
436
|
+
|
437
|
+
console.log('[RegionCascader] 滚动到:', label)
|
438
|
+
}
|
439
|
+
|
440
|
+
// 滚动到选中的省份
|
441
|
+
const scrollToSelectedProvince = () => {
|
442
|
+
scrollToSelected(provinceListRef.value, selectedProvinceRef.value, selectedProvince.value?.label || '')
|
443
|
+
}
|
444
|
+
|
445
|
+
// 滚动到选中的城市
|
446
|
+
const scrollToSelectedCity = () => {
|
447
|
+
scrollToSelected(cityListRef.value, selectedCityRef.value, selectedCity.value?.label || '')
|
448
|
+
}
|
449
|
+
|
450
|
+
// 滚动到选中的区县
|
451
|
+
const scrollToSelectedDistrict = () => {
|
452
|
+
scrollToSelected(districtListRef.value, selectedDistrictRef.value, selectedDistrict.value?.label || '')
|
453
|
+
}
|
454
|
+
|
455
|
+
const closeDropdown = () => {
|
456
|
+
isOpen.value = false
|
457
|
+
searchKeyword.value = ''
|
458
|
+
}
|
459
|
+
|
460
|
+
// 搜索处理
|
461
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
462
|
+
const handleSearch = () => {
|
463
|
+
if (searchTimeout) {
|
464
|
+
clearTimeout(searchTimeout)
|
465
|
+
}
|
466
|
+
|
467
|
+
// 防抖处理
|
468
|
+
searchTimeout = setTimeout(() => {
|
469
|
+
performSearch()
|
470
|
+
}, 300)
|
471
|
+
}
|
472
|
+
|
473
|
+
// 执行搜索(全局搜索)
|
474
|
+
const performSearch = () => {
|
475
|
+
const keyword = searchKeyword.value.trim()
|
476
|
+
if (!keyword) {
|
477
|
+
searchResults.value = []
|
478
|
+
isSearching.value = false
|
479
|
+
return
|
480
|
+
}
|
481
|
+
|
482
|
+
isSearching.value = true
|
483
|
+
|
484
|
+
// 使用Worker进行全局搜索
|
485
|
+
if (worker && workerReady.value) {
|
486
|
+
const handler = (e: MessageEvent) => {
|
487
|
+
if (e.data?.type === 'searchResults' && e.data.keyword === keyword) {
|
488
|
+
searchResults.value = e.data.results || []
|
489
|
+
isSearching.value = false
|
490
|
+
worker!.removeEventListener('message', handler)
|
491
|
+
}
|
492
|
+
}
|
493
|
+
|
494
|
+
worker.addEventListener('message', handler)
|
495
|
+
// 全局搜索,不指定parentValue
|
496
|
+
worker.postMessage({
|
497
|
+
type: 'search',
|
498
|
+
payload: { keyword, parentValue: null }
|
499
|
+
})
|
500
|
+
|
501
|
+
// 超时处理
|
502
|
+
setTimeout(() => {
|
503
|
+
worker!.removeEventListener('message', handler)
|
504
|
+
isSearching.value = false
|
505
|
+
}, 3000)
|
506
|
+
} else {
|
507
|
+
isSearching.value = false
|
508
|
+
}
|
509
|
+
}
|
510
|
+
|
511
|
+
// 清除搜索
|
512
|
+
const clearSearch = () => {
|
513
|
+
searchKeyword.value = ''
|
514
|
+
searchResults.value = []
|
515
|
+
isSearching.value = false
|
516
|
+
searchInputRef.value?.focus()
|
517
|
+
}
|
518
|
+
|
519
|
+
// 选择省份
|
520
|
+
const selectProvince = async (province: AreaNode) => {
|
521
|
+
console.log('[RegionCascader] 选择省份:', province.label, 'maxLevel:', props.maxLevel)
|
522
|
+
|
523
|
+
selectedProvince.value = province
|
524
|
+
selectedCity.value = null
|
525
|
+
selectedDistrict.value = null
|
526
|
+
|
527
|
+
cities.value = []
|
528
|
+
districts.value = []
|
529
|
+
|
530
|
+
// 清除搜索,准备搜索下一级
|
531
|
+
searchKeyword.value = ''
|
532
|
+
|
533
|
+
// 立即滚动到选中的省份(无感滚动)
|
534
|
+
nextTick(() => {
|
535
|
+
scrollToSelectedProvince()
|
536
|
+
})
|
537
|
+
|
538
|
+
// 如果maxLevel为1,直接完成
|
539
|
+
if (props.maxLevel === 1) {
|
540
|
+
console.log('[RegionCascader] maxLevel=1,关闭下拉框')
|
541
|
+
emitValue()
|
542
|
+
closeDropdown()
|
543
|
+
return
|
544
|
+
}
|
545
|
+
|
546
|
+
// 加载下一级
|
547
|
+
if (!province.leaf && props.maxLevel >= 2) {
|
548
|
+
console.log('[RegionCascader] 加载城市列表...')
|
549
|
+
const citiesList = await loadChildren(province.value)
|
550
|
+
cities.value = citiesList
|
551
|
+
console.log('[RegionCascader] 城市列表加载完成:', citiesList.length, '个城市')
|
552
|
+
console.log('[RegionCascader] 下拉框状态 isOpen:', isOpen.value)
|
553
|
+
|
554
|
+
// 聚焦搜索框,准备搜索城市(仅当启用搜索时)
|
555
|
+
if (props.searchable) {
|
556
|
+
setTimeout(() => {
|
557
|
+
searchInputRef.value?.focus()
|
558
|
+
}, 100)
|
559
|
+
}
|
560
|
+
} else {
|
561
|
+
console.log('[RegionCascader] 省份是叶子节点或maxLevel<2,关闭下拉框')
|
562
|
+
emitValue()
|
563
|
+
closeDropdown()
|
564
|
+
}
|
565
|
+
}
|
566
|
+
|
567
|
+
// 选择城市
|
568
|
+
const selectCity = async (city: AreaNode) => {
|
569
|
+
selectedCity.value = city
|
570
|
+
selectedDistrict.value = null
|
571
|
+
|
572
|
+
districts.value = []
|
573
|
+
|
574
|
+
// 清除搜索,准备搜索下一级
|
575
|
+
searchKeyword.value = ''
|
576
|
+
|
577
|
+
// 立即滚动到选中的城市(无感滚动)
|
578
|
+
nextTick(() => {
|
579
|
+
scrollToSelectedCity()
|
580
|
+
})
|
581
|
+
|
582
|
+
// 如果maxLevel为2,直接完成
|
583
|
+
if (props.maxLevel === 2) {
|
584
|
+
emitValue()
|
585
|
+
closeDropdown()
|
586
|
+
return
|
587
|
+
}
|
588
|
+
|
589
|
+
// 加载下一级
|
590
|
+
if (!city.leaf && props.maxLevel >= 3) {
|
591
|
+
districts.value = await loadChildren(city.value)
|
592
|
+
// 聚焦搜索框,准备搜索区县(仅当启用搜索时)
|
593
|
+
if (props.searchable) {
|
594
|
+
setTimeout(() => {
|
595
|
+
searchInputRef.value?.focus()
|
596
|
+
}, 100)
|
597
|
+
}
|
598
|
+
} else {
|
599
|
+
emitValue()
|
600
|
+
closeDropdown()
|
601
|
+
}
|
602
|
+
}
|
603
|
+
|
604
|
+
// 选择区县
|
605
|
+
const selectDistrict = (district: AreaNode) => {
|
606
|
+
selectedDistrict.value = district
|
607
|
+
|
608
|
+
// 立即滚动到选中的区县(无感滚动)
|
609
|
+
nextTick(() => {
|
610
|
+
scrollToSelectedDistrict()
|
611
|
+
})
|
612
|
+
|
613
|
+
// 区县是最后一级,直接完成
|
614
|
+
emitValue()
|
615
|
+
closeDropdown()
|
616
|
+
}
|
617
|
+
|
618
|
+
// 从全局搜索结果中选择(智能反填)
|
619
|
+
const selectFromGlobalSearch = async (result: AreaNode) => {
|
620
|
+
if (!result.path || !result.pathLabels) return
|
621
|
+
|
622
|
+
// 清除搜索
|
623
|
+
searchKeyword.value = ''
|
624
|
+
searchResults.value = []
|
625
|
+
|
626
|
+
// 根据路径长度判断层级并智能反填
|
627
|
+
const path = result.path
|
628
|
+
const pathLabels = result.pathLabels
|
629
|
+
const level = result.level || path.length
|
630
|
+
|
631
|
+
// 确保provinces列表已加载
|
632
|
+
if (provinces.value.length === 0) {
|
633
|
+
provinces.value = await loadRoots()
|
634
|
+
}
|
635
|
+
|
636
|
+
// 根据选择的层级,调用对应的选择函数(复用现有逻辑)
|
637
|
+
if (level === 1) {
|
638
|
+
// 选择的是省份,调用selectProvince
|
639
|
+
const province: AreaNode = {
|
640
|
+
value: path[0],
|
641
|
+
label: pathLabels[0],
|
642
|
+
leaf: false
|
643
|
+
}
|
644
|
+
await selectProvince(province)
|
645
|
+
} else if (level === 2) {
|
646
|
+
// 选择的是城市,先设置省份,再调用selectCity
|
647
|
+
selectedProvince.value = {
|
648
|
+
value: path[0],
|
649
|
+
label: pathLabels[0],
|
650
|
+
leaf: false
|
651
|
+
}
|
652
|
+
cities.value = await loadChildren(path[0])
|
653
|
+
|
654
|
+
const city: AreaNode = {
|
655
|
+
value: path[1],
|
656
|
+
label: pathLabels[1],
|
657
|
+
leaf: false
|
658
|
+
}
|
659
|
+
await selectCity(city)
|
660
|
+
} else if (level === 3) {
|
661
|
+
// 选择的是区县,先设置省份和城市,再调用selectDistrict
|
662
|
+
selectedProvince.value = {
|
663
|
+
value: path[0],
|
664
|
+
label: pathLabels[0],
|
665
|
+
leaf: false
|
666
|
+
}
|
667
|
+
cities.value = await loadChildren(path[0])
|
668
|
+
|
669
|
+
selectedCity.value = {
|
670
|
+
value: path[1],
|
671
|
+
label: pathLabels[1],
|
672
|
+
leaf: false
|
673
|
+
}
|
674
|
+
districts.value = await loadChildren(path[1])
|
675
|
+
|
676
|
+
const district: AreaNode = {
|
677
|
+
value: path[2],
|
678
|
+
label: pathLabels[2],
|
679
|
+
leaf: true
|
680
|
+
}
|
681
|
+
selectDistrict(district)
|
682
|
+
}
|
683
|
+
}
|
684
|
+
|
685
|
+
// 发送选中的值
|
686
|
+
const emitValue = () => {
|
687
|
+
const values: string[] = []
|
688
|
+
const labels: string[] = []
|
689
|
+
|
690
|
+
if (selectedProvince.value) {
|
691
|
+
values.push(selectedProvince.value.value)
|
692
|
+
labels.push(selectedProvince.value.label)
|
693
|
+
}
|
694
|
+
if (selectedCity.value) {
|
695
|
+
values.push(selectedCity.value.value)
|
696
|
+
labels.push(selectedCity.value.label)
|
697
|
+
}
|
698
|
+
if (selectedDistrict.value) {
|
699
|
+
values.push(selectedDistrict.value.value)
|
700
|
+
labels.push(selectedDistrict.value.label)
|
701
|
+
}
|
702
|
+
|
703
|
+
emit('update:modelValue', values)
|
704
|
+
emit('change', values, labels)
|
705
|
+
}
|
706
|
+
|
707
|
+
// 初始化Worker
|
708
|
+
const initWorker = () => {
|
709
|
+
try {
|
710
|
+
// 创建Worker
|
711
|
+
worker = new Worker(
|
712
|
+
new URL('./areas.worker.ts', import.meta.url),
|
713
|
+
{ type: 'module' }
|
714
|
+
)
|
715
|
+
|
716
|
+
worker.onmessage = (e: MessageEvent) => {
|
717
|
+
if (e.data?.type === 'ready') {
|
718
|
+
workerReady.value = true
|
719
|
+
console.log('[RegionCascader] Worker初始化完成')
|
720
|
+
} else if (e.data?.type === 'error') {
|
721
|
+
console.error('[RegionCascader] Worker错误:', e.data.error)
|
722
|
+
}
|
723
|
+
}
|
724
|
+
|
725
|
+
worker.onerror = (error) => {
|
726
|
+
console.error('[RegionCascader] Worker error:', error)
|
727
|
+
}
|
728
|
+
|
729
|
+
// 发送初始化数据
|
730
|
+
worker.postMessage({
|
731
|
+
type: 'init',
|
732
|
+
payload: { root: chinaAreasData }
|
733
|
+
})
|
734
|
+
} catch (error) {
|
735
|
+
console.error('[RegionCascader] Failed to create worker:', error)
|
736
|
+
}
|
737
|
+
}
|
738
|
+
|
739
|
+
// 点击外部关闭下拉框
|
740
|
+
onMounted(async () => {
|
741
|
+
// 初始化Worker
|
742
|
+
initWorker()
|
743
|
+
|
744
|
+
const handleClickOutside = (event: Event) => {
|
745
|
+
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
746
|
+
closeDropdown()
|
747
|
+
}
|
748
|
+
}
|
749
|
+
|
750
|
+
document.addEventListener('click', handleClickOutside)
|
751
|
+
|
752
|
+
onUnmounted(() => {
|
753
|
+
document.removeEventListener('click', handleClickOutside)
|
754
|
+
// 清理Worker
|
755
|
+
if (worker) {
|
756
|
+
worker.terminate()
|
757
|
+
worker = null
|
758
|
+
}
|
759
|
+
})
|
760
|
+
})
|
761
|
+
|
762
|
+
// 根据 modelValue 恢复选中状态
|
763
|
+
async function restoreFromModelValue(codes: string[]) {
|
764
|
+
if (!codes || codes.length === 0) {
|
765
|
+
// 清空选择
|
766
|
+
selectedProvince.value = null
|
767
|
+
selectedCity.value = null
|
768
|
+
selectedDistrict.value = null
|
769
|
+
provinces.value = []
|
770
|
+
cities.value = []
|
771
|
+
districts.value = []
|
772
|
+
return
|
773
|
+
}
|
774
|
+
|
775
|
+
console.log('[RegionCascader] 恢复选中状态:', codes)
|
776
|
+
|
777
|
+
try {
|
778
|
+
// 确保provinces列表已加载
|
779
|
+
if (provinces.value.length === 0) {
|
780
|
+
provinces.value = await loadRoots()
|
781
|
+
}
|
782
|
+
|
783
|
+
// 恢复省份
|
784
|
+
if (codes[0]) {
|
785
|
+
const province = provinces.value.find(p => p.value === codes[0])
|
786
|
+
|
787
|
+
if (province) {
|
788
|
+
selectedProvince.value = province
|
789
|
+
|
790
|
+
// 如果有市级代码,恢复城市
|
791
|
+
if (codes[1] && props.maxLevel >= 2) {
|
792
|
+
cities.value = await loadChildren(codes[0])
|
793
|
+
const city = cities.value.find(c => c.value === codes[1])
|
794
|
+
|
795
|
+
if (city) {
|
796
|
+
selectedCity.value = city
|
797
|
+
|
798
|
+
// 如果有区级代码,恢复区县
|
799
|
+
if (codes[2] && props.maxLevel >= 3) {
|
800
|
+
districts.value = await loadChildren(codes[1])
|
801
|
+
const district = districts.value.find(d => d.value === codes[2])
|
802
|
+
|
803
|
+
if (district) {
|
804
|
+
selectedDistrict.value = district
|
805
|
+
}
|
806
|
+
}
|
807
|
+
}
|
808
|
+
}
|
809
|
+
} else {
|
810
|
+
console.warn('[RegionCascader] 未找到省份代码:', codes[0])
|
811
|
+
}
|
812
|
+
}
|
813
|
+
|
814
|
+
console.log('[RegionCascader] 恢复完成:', {
|
815
|
+
province: selectedProvince.value?.label,
|
816
|
+
city: selectedCity.value?.label,
|
817
|
+
district: selectedDistrict.value?.label
|
818
|
+
})
|
819
|
+
} catch (error) {
|
820
|
+
console.error('[RegionCascader] 恢复选中状态失败:', error)
|
821
|
+
}
|
822
|
+
}
|
823
|
+
|
824
|
+
// 监听 modelValue 变化
|
825
|
+
watch(() => props.modelValue, async (newValue) => {
|
826
|
+
console.log('[RegionCascader] modelValue changed:', newValue)
|
827
|
+
await restoreFromModelValue(newValue)
|
828
|
+
}, { deep: true, immediate: false })
|
829
|
+
|
830
|
+
// 在 Worker 就绪后恢复初始值
|
831
|
+
watch(workerReady, async (ready) => {
|
832
|
+
if (ready && props.modelValue && props.modelValue.length > 0) {
|
833
|
+
console.log('[RegionCascader] Worker就绪,开始恢复初始值:', props.modelValue)
|
834
|
+
await restoreFromModelValue(props.modelValue)
|
835
|
+
}
|
836
|
+
}, { immediate: true })
|
837
|
+
|
838
|
+
// 暴露方法和数据
|
839
|
+
defineExpose({
|
840
|
+
worker,
|
841
|
+
workerReady,
|
842
|
+
loadChildren,
|
843
|
+
clearCache: () => childrenCache.clear()
|
844
|
+
})
|
845
|
+
</script>
|
846
|
+
|
847
|
+
<style scoped>
|
848
|
+
.region-cascader-container {
|
849
|
+
position: relative;
|
850
|
+
}
|
851
|
+
|
852
|
+
/* 自定义滚动条样式 */
|
853
|
+
.region-cascader-container ::-webkit-scrollbar {
|
854
|
+
width: 6px;
|
855
|
+
}
|
856
|
+
|
857
|
+
.region-cascader-container ::-webkit-scrollbar-track {
|
858
|
+
background: #f1f1f1;
|
859
|
+
border-radius: 3px;
|
860
|
+
}
|
861
|
+
|
862
|
+
.region-cascader-container ::-webkit-scrollbar-thumb {
|
863
|
+
background: #888;
|
864
|
+
border-radius: 3px;
|
865
|
+
}
|
866
|
+
|
867
|
+
.region-cascader-container ::-webkit-scrollbar-thumb:hover {
|
868
|
+
background: #555;
|
869
|
+
}
|
870
|
+
</style>
|