Skip to content

Commit 7b32876

Browse files
committed
feat (core): 拆分 Select 为独立组件
1 parent 801c05a commit 7b32876

3 files changed

Lines changed: 318 additions & 19 deletions

File tree

src/components/AppHeader.vue

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,14 @@
44
<div class="relative">
55
<!-- 自定义下拉选择器 -->
66
<div class="relative">
7-
<select v-model="selectedLanguage"
8-
class="w-32 h-10 backdrop-blur-sm appearance-none bg-white/90 text-gray-800 text-sm font-medium border border-gray-200 rounded-lg px-2.5 pr-8 cursor-pointer focus:outline-none hover:bg-white/95 transition-all duration-200"
9-
:class="{ 'opacity-50 cursor-not-allowed': isRunning }"
7+
<Select v-model="selectedLanguage"
8+
:options="supportedLanguages as any"
109
:disabled="isRunning"
10+
placeholder="选择语言"
11+
value-key="value"
12+
label-key="name"
1113
@change="handleLanguageChange">
12-
<option v-for="language in supportedLanguages"
13-
class="text-gray-800 bg-white py-3 px-3 text-sm font-medium hover:bg-blue-50 hover:text-blue-800 focus:bg-blue-100 focus:text-blue-900 border-b border-gray-100 last:border-b-0"
14-
:key="language.value"
15-
:value="language.value">
16-
{{ language.name }}
17-
</option>
18-
</select>
14+
</Select>
1915
</div>
2016
</div>
2117
</div>
@@ -45,6 +41,7 @@
4541
<script setup lang="ts">
4642
import { ref, watch } from 'vue'
4743
import { Play, Square, Trash2 } from 'lucide-vue-next'
44+
import Select from '../ui/Select.vue'
4845
4946
interface Language
5047
{

src/components/Settings.vue

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,11 @@
105105
自动清理日志
106106
</label>
107107
<div class="flex items-center gap-3">
108-
<select v-model="keepDays"
109-
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
110-
<option value="7">保留 7 天</option>
111-
<option value="14">保留 14 天</option>
112-
<option value="30">保留 30 天</option>
113-
<option value="90">保留 90 天</option>
114-
</select>
108+
<Select v-model="keepDays"
109+
class="w-36"
110+
:options="keepDaysOptions"
111+
placeholder="选择保留天数">
112+
</Select>
115113
<button class="cursor-pointer px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-md transition-colors"
116114
@click="clearLogs">
117115
立即清理
@@ -125,7 +123,7 @@
125123
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200/50 dark:border-gray-600/50">
126124
<button
127125
@click="closeSettings"
128-
class="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white text-sm font-medium rounded-md transition-colors">
126+
class="cursor-pointer px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white text-sm font-medium rounded-md transition-colors">
129127
关闭
130128
</button>
131129
</div>
@@ -137,14 +135,21 @@
137135
import { nextTick, onMounted, ref } from 'vue'
138136
import { invoke } from '@tauri-apps/api/core'
139137
import { open as openDialog } from '@tauri-apps/plugin-dialog'
140-
import { openPath, revealItemInDir } from '@tauri-apps/plugin-opener'
138+
import { openPath } from '@tauri-apps/plugin-opener'
141139
import { FileText, Folder, Settings2, X } from 'lucide-vue-next'
140+
import Select from '../ui/Select.vue'
142141
143142
const isVisible = ref(false)
144143
const currentLogDir = ref('')
145144
const newLogDir = ref('')
146145
const logFiles = ref<string[]>([])
147146
const keepDays = ref(30)
147+
const keepDaysOptions = [
148+
{ label: '保留 7 天', value: 7 },
149+
{ label: '保留 14 天', value: 14 },
150+
{ label: '保留 30 天', value: 30 },
151+
{ label: '保留 90 天', value: 90 }
152+
]
148153
149154
const emit = defineEmits<{
150155
close: []

src/ui/Select.vue

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
<template>
2+
<div class="relative" ref="selectContainer">
3+
<!-- Select 按钮 -->
4+
<button type="button"
5+
@click="toggleDropdown"
6+
@keydown.enter.prevent="toggleDropdown"
7+
@keydown.space.prevent="toggleDropdown"
8+
@keydown.escape="closeDropdown"
9+
@keydown.arrow-down.prevent="openDropdown"
10+
@keydown.arrow-up.prevent="openDropdown"
11+
:class="[
12+
'relative w-full cursor-pointer rounded-lg border bg-white py-1 pl-3 pr-10 text-left shadow-sm transition-all duration-200',
13+
disabled ? 'cursor-not-allowed bg-gray-50 text-gray-400' : 'hover:border-gray-400',
14+
...buttonClasses
15+
]"
16+
:disabled="disabled"
17+
:aria-expanded="isOpen"
18+
:aria-haspopup="true"
19+
role="combobox">
20+
<span class="block truncate">
21+
{{ selectedLabel || placeholder }}
22+
</span>
23+
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
24+
<ArrowUpIcon class="h-5 w-5 text-gray-400 transition-transform duration-200"
25+
:class="{ 'rotate-180': isOpen }"
26+
aria-hidden="true">
27+
</ArrowUpIcon>
28+
</span>
29+
</button>
30+
31+
<!-- Dropdown 列表 -->
32+
<Transition enter-active-class="transition duration-200 ease-out"
33+
enter-from-class="transform scale-95 opacity-0"
34+
enter-to-class="transform scale-100 opacity-100"
35+
leave-active-class="transition duration-150 ease-in"
36+
leave-from-class="transform scale-100 opacity-100"
37+
leave-to-class="transform scale-95 opacity-0"
38+
@before-enter="$emit('before-open')"
39+
@after-enter="$emit('after-open')"
40+
@before-leave="$emit('before-close')"
41+
@after-leave="$emit('after-close')">
42+
<div v-show="isOpen"
43+
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
44+
:class="dropdownClasses"
45+
role="listbox"
46+
:aria-labelledby="buttonId">
47+
<!-- 搜索框 (可选) -->
48+
<div v-if="searchable" class="sticky top-0 bg-white p-2 border-b border-gray-100">
49+
<input v-model="searchQuery"
50+
type="text"
51+
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
52+
:placeholder="searchPlaceholder"
53+
@click.stop
54+
ref="searchInput"/>
55+
</div>
56+
57+
<!-- 选项列表 -->
58+
<template v-if="filteredOptions.length > 0">
59+
<div v-for="(option, index) in filteredOptions"
60+
:key="getOptionValue(option)"
61+
@click="selectOption(option)"
62+
@keydown.enter.prevent="selectOption(option)"
63+
@keydown.space.prevent="selectOption(option)"
64+
:class="[
65+
'relative cursor-pointer select-none py-1 my-1 pl-3 pr-9 transition-colors duration-150',
66+
isSelected(option)
67+
? 'bg-blue-400 text-white'
68+
: 'text-gray-900 hover:bg-blue-50',
69+
highlightedIndex === index ? 'bg-blue-100' : ''
70+
]"
71+
:aria-selected="isSelected(option)"
72+
role="option"
73+
tabindex="-1">
74+
<span :class="['block truncate', isSelected(option) ? 'font-medium' : 'font-normal']">
75+
{{ getOptionLabel(option) }}
76+
</span>
77+
78+
<!-- 选中图标 -->
79+
<span v-if="isSelected(option)"
80+
class="absolute inset-y-0 right-0 flex items-center pr-2">
81+
<CheckIcon class="h-5 w-5" aria-hidden="true"/>
82+
</span>
83+
</div>
84+
</template>
85+
86+
<!-- 无选项提示 -->
87+
<div v-else class="px-3 py-2 text-gray-500 text-sm">
88+
{{ noOptionsText }}
89+
</div>
90+
</div>
91+
</Transition>
92+
</div>
93+
</template>
94+
95+
<script setup lang="ts">
96+
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
97+
import { ArrowUpIcon, CheckIcon } from 'lucide-vue-next'
98+
99+
// Props 定义
100+
interface Option
101+
{
102+
label: string
103+
value: any
104+
disabled?: boolean
105+
106+
[key: string]: any
107+
}
108+
109+
interface Props
110+
{
111+
modelValue?: any
112+
options: Option[] | string[] | number[]
113+
placeholder?: string
114+
disabled?: boolean
115+
searchable?: boolean
116+
searchPlaceholder?: string
117+
noOptionsText?: string
118+
valueKey?: string
119+
labelKey?: string
120+
buttonClasses?: string[]
121+
dropdownClasses?: string[]
122+
}
123+
124+
const props = withDefaults(defineProps<Props>(), {
125+
placeholder: '请选择...',
126+
disabled: false,
127+
searchable: false,
128+
searchPlaceholder: '搜索选项...',
129+
noOptionsText: '无可用选项',
130+
valueKey: 'value',
131+
labelKey: 'label',
132+
buttonClasses: () => [],
133+
dropdownClasses: () => []
134+
})
135+
136+
// Emits 定义
137+
const emit = defineEmits<{
138+
'update:modelValue': [value: any]
139+
'change': [value: any, option: Option | string | number]
140+
'before-open': []
141+
'after-open': []
142+
'before-close': []
143+
'after-close': []
144+
'search': [query: string]
145+
}>()
146+
147+
// 响应式数据
148+
const isOpen = ref(false)
149+
const searchQuery = ref('')
150+
const highlightedIndex = ref(-1)
151+
const selectContainer = ref<HTMLElement>()
152+
const searchInput = ref<HTMLInputElement>()
153+
const buttonId = `select-button-${ Math.random().toString(36).substr(2, 9) }`
154+
155+
// 计算属性
156+
const normalizedOptions = computed(() => {
157+
return props.options.map(option => {
158+
if (typeof option === 'string' || typeof option === 'number') {
159+
return { label: String(option), value: option }
160+
}
161+
return {
162+
...option,
163+
label: option[props.labelKey] || option.label,
164+
value: option[props.valueKey] || option.value,
165+
disabled: option.disabled || false
166+
}
167+
})
168+
})
169+
170+
const filteredOptions = computed(() => {
171+
if (!props.searchable || !searchQuery.value) {
172+
return normalizedOptions.value
173+
}
174+
175+
const query = searchQuery.value.toLowerCase()
176+
return normalizedOptions.value.filter(option =>
177+
option.label.toLowerCase().includes(query)
178+
)
179+
})
180+
181+
const selectedOption = computed(() => {
182+
return normalizedOptions.value.find(option =>
183+
option.value === props.modelValue
184+
)
185+
})
186+
187+
const selectedLabel = computed(() => {
188+
return selectedOption.value?.label || ''
189+
})
190+
191+
// 方法
192+
const getOptionValue = (option: Option) => option.value
193+
const getOptionLabel = (option: Option) => option.label
194+
195+
const isSelected = (option: Option) => {
196+
return option.value === props.modelValue
197+
}
198+
199+
const toggleDropdown = () => {
200+
if (props.disabled) {
201+
return
202+
}
203+
204+
if (isOpen.value) {
205+
closeDropdown()
206+
}
207+
else {
208+
openDropdown()
209+
}
210+
}
211+
212+
const openDropdown = () => {
213+
if (props.disabled) {
214+
return
215+
}
216+
217+
isOpen.value = true
218+
highlightedIndex.value = -1
219+
220+
if (props.searchable) {
221+
nextTick(() => {
222+
searchInput.value?.focus()
223+
})
224+
}
225+
}
226+
227+
const closeDropdown = () => {
228+
isOpen.value = false
229+
searchQuery.value = ''
230+
highlightedIndex.value = -1
231+
}
232+
233+
const selectOption = (option: Option) => {
234+
if (option.disabled) {
235+
return
236+
}
237+
238+
emit('update:modelValue', option.value)
239+
emit('change', option.value, option)
240+
closeDropdown()
241+
}
242+
243+
// 键盘导航
244+
const handleKeydown = (event: KeyboardEvent) => {
245+
if (!isOpen.value) {
246+
return
247+
}
248+
249+
switch (event.key) {
250+
case 'ArrowDown':
251+
event.preventDefault()
252+
highlightedIndex.value = Math.min(
253+
highlightedIndex.value + 1,
254+
filteredOptions.value.length - 1
255+
)
256+
break
257+
case 'ArrowUp':
258+
event.preventDefault()
259+
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
260+
break
261+
case 'Enter':
262+
event.preventDefault()
263+
if (highlightedIndex.value >= 0) {
264+
selectOption(filteredOptions.value[highlightedIndex.value])
265+
}
266+
break
267+
case 'Escape':
268+
event.preventDefault()
269+
closeDropdown()
270+
break
271+
}
272+
}
273+
274+
// 点击外部关闭
275+
const handleClickOutside = (event: Event) => {
276+
if (selectContainer.value && !selectContainer.value.contains(event.target as Node)) {
277+
closeDropdown()
278+
}
279+
}
280+
281+
// 监听搜索查询
282+
watch(searchQuery, (newQuery) => {
283+
emit('search', newQuery)
284+
highlightedIndex.value = -1
285+
})
286+
287+
// 生命周期
288+
onMounted(() => {
289+
document.addEventListener('click', handleClickOutside)
290+
document.addEventListener('keydown', handleKeydown)
291+
})
292+
293+
onUnmounted(() => {
294+
document.removeEventListener('click', handleClickOutside)
295+
document.removeEventListener('keydown', handleKeydown)
296+
})
297+
</script>

0 commit comments

Comments
 (0)