@aquiferre/ui-kit 0.3.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.
- package/components/DateTimePicker.vue +563 -0
- package/components/Tree.vue +193 -0
- package/components/TreeNodeItem.vue +145 -0
- package/index.ts +4 -1
- package/package.json +1 -1
- package/types.ts +5 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="rootRef" class="relative" :data-testid="baseTestId">
|
|
3
|
+
<!-- Trigger -->
|
|
4
|
+
<div
|
|
5
|
+
ref="triggerRef"
|
|
6
|
+
class="input flex items-center gap-1 cursor-pointer select-none"
|
|
7
|
+
:class="[disabled ? 'opacity-60 cursor-not-allowed' : '']"
|
|
8
|
+
@click="toggle"
|
|
9
|
+
>
|
|
10
|
+
<span v-if="hasValue" class="flex-1 min-w-0 truncate text-input-text text-xs">{{ displayText }}</span>
|
|
11
|
+
<span v-else class="flex-1 min-w-0"></span>
|
|
12
|
+
<button
|
|
13
|
+
v-if="clearable && hasValue && !disabled"
|
|
14
|
+
type="button"
|
|
15
|
+
class="text-text-tertiary hover:text-text-primary text-xs px-1"
|
|
16
|
+
@click.stop="clear"
|
|
17
|
+
tabindex="-1"
|
|
18
|
+
>✕</button>
|
|
19
|
+
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
20
|
+
<rect x="2" y="3" width="12" height="11" rx="1.5" />
|
|
21
|
+
<path d="M2 6.5h12" />
|
|
22
|
+
<path d="M5 1.5v3M11 1.5v3" />
|
|
23
|
+
</svg>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Panel -->
|
|
27
|
+
<Teleport to="body">
|
|
28
|
+
<div
|
|
29
|
+
v-if="open"
|
|
30
|
+
class="fixed card shadow-lg p-3"
|
|
31
|
+
:style="panelStyle"
|
|
32
|
+
@mousedown.stop
|
|
33
|
+
@click.stop
|
|
34
|
+
>
|
|
35
|
+
<!-- Calendar mode -->
|
|
36
|
+
<div v-if="pickerStyle === 'calendar'">
|
|
37
|
+
<div class="flex items-center justify-between mb-2">
|
|
38
|
+
<div class="relative">
|
|
39
|
+
<button type="button" class="btn-sm btn-ghost text-xs font-semibold" @click="yearDropdownOpen = !yearDropdownOpen">
|
|
40
|
+
{{ viewYear }}年 ▾
|
|
41
|
+
</button>
|
|
42
|
+
<div
|
|
43
|
+
v-if="yearDropdownOpen"
|
|
44
|
+
class="absolute top-full left-0 mt-1 card shadow-lg z-30 p-2 min-w-[160px]"
|
|
45
|
+
@mousedown.prevent
|
|
46
|
+
>
|
|
47
|
+
<div class="flex items-center justify-between mb-1.5 px-1">
|
|
48
|
+
<button type="button" class="btn-icon text-text-secondary hover:text-text-primary" @click="decadeStart -= 10">◀</button>
|
|
49
|
+
<span class="text-xs text-text-secondary">{{ decadeStart }}—{{ decadeStart + 9 }}</span>
|
|
50
|
+
<button type="button" class="btn-icon text-text-secondary hover:text-text-primary" @click="decadeStart += 10">▶</button>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="grid grid-cols-2 gap-1">
|
|
53
|
+
<button
|
|
54
|
+
v-for="y in decadeYears" :key="y" type="button"
|
|
55
|
+
class="px-2 py-1 text-xs rounded-md transition-colors"
|
|
56
|
+
:class="y === viewYear ? 'bg-accent-primary text-white' : 'text-text-primary hover:bg-bg-hover'"
|
|
57
|
+
@click="selectYear(y)"
|
|
58
|
+
>{{ y }}</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="flex items-center gap-1">
|
|
63
|
+
<button type="button" class="btn-icon text-text-secondary hover:text-text-primary" @click="prevMonth">◀</button>
|
|
64
|
+
<span class="text-xs font-semibold text-text-primary min-w-[3rem] text-center">{{ viewMonth }}月</span>
|
|
65
|
+
<button type="button" class="btn-icon text-text-secondary hover:text-text-primary" @click="nextMonth">▶</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div class="grid grid-cols-7 gap-0.5 mb-1">
|
|
69
|
+
<div v-for="d in weekDays" :key="d" class="text-center text-xs text-text-tertiary py-0.5 font-medium">{{ d }}</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="grid grid-cols-7 gap-0.5">
|
|
72
|
+
<button
|
|
73
|
+
v-for="(day, i) in calendarDays" :key="i" type="button"
|
|
74
|
+
class="h-7 w-7 text-xs rounded-md transition-colors mx-auto"
|
|
75
|
+
:class="dayClass(day)"
|
|
76
|
+
:disabled="isDisabled(day.dateStr)"
|
|
77
|
+
@click="selectDate(day)"
|
|
78
|
+
>{{ day.day }}</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Grid mode -->
|
|
83
|
+
<div v-else class="flex gap-3">
|
|
84
|
+
<div>
|
|
85
|
+
<div class="flex items-center justify-between mb-1.5 px-1">
|
|
86
|
+
<button type="button" class="btn-icon text-text-secondary hover:text-text-primary" @click="decadeStart -= 10">◀</button>
|
|
87
|
+
<span class="text-xs text-text-secondary">{{ decadeStart }}—{{ decadeStart + 9 }}</span>
|
|
88
|
+
<button type="button" class="btn-icon text-text-secondary hover:text-text-primary" @click="decadeStart += 10">▶</button>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="grid grid-cols-2 gap-1">
|
|
91
|
+
<button
|
|
92
|
+
v-for="y in decadeYears" :key="y" type="button"
|
|
93
|
+
class="px-2 py-1 text-xs rounded-md transition-colors min-w-[3rem]"
|
|
94
|
+
:class="y === viewYear ? 'bg-accent-primary text-white' : 'bg-bg-secondary text-text-primary hover:bg-bg-hover'"
|
|
95
|
+
@click="selectYear(y)"
|
|
96
|
+
>{{ y }}</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="w-px bg-border-primary" />
|
|
100
|
+
<div>
|
|
101
|
+
<div class="mb-1.5 px-1"><span class="text-xs text-text-secondary">月份</span></div>
|
|
102
|
+
<div class="grid grid-cols-2 gap-1">
|
|
103
|
+
<button
|
|
104
|
+
v-for="m in 12" :key="m" type="button"
|
|
105
|
+
class="px-2 py-1 text-xs rounded-md transition-colors min-w-[2.5rem]"
|
|
106
|
+
:class="m === viewMonth ? 'bg-accent-primary text-white' : 'bg-bg-secondary text-text-primary hover:bg-bg-hover'"
|
|
107
|
+
@click="selectMonth(m)"
|
|
108
|
+
>{{ m }}月</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="w-px bg-border-primary" />
|
|
112
|
+
<div>
|
|
113
|
+
<div class="grid grid-cols-7 gap-0.5 mb-1">
|
|
114
|
+
<div v-for="d in weekDays" :key="d" class="text-center text-xs text-text-tertiary py-0.5 font-medium">{{ d }}</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="grid grid-cols-7 gap-0.5">
|
|
117
|
+
<button
|
|
118
|
+
v-for="(day, i) in calendarDays" :key="i" type="button"
|
|
119
|
+
class="h-7 w-7 text-xs rounded-md transition-colors mx-auto"
|
|
120
|
+
:class="dayClass(day)"
|
|
121
|
+
:disabled="isDisabled(day.dateStr)"
|
|
122
|
+
@click="selectDate(day)"
|
|
123
|
+
>{{ day.day }}</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<!-- Time section -->
|
|
129
|
+
<div v-if="mode === 'datetime' || mode === 'time'" class="flex items-center justify-center gap-3 mt-3 pt-3 border-t border-border-primary">
|
|
130
|
+
<div v-for="col in timeColumns" :key="col.key" class="flex flex-col items-center gap-0.5">
|
|
131
|
+
<button type="button" class="text-text-tertiary hover:text-text-primary text-xs leading-none" @click="incTime(col.key)">▲</button>
|
|
132
|
+
<input
|
|
133
|
+
v-if="editingTime === col.key"
|
|
134
|
+
ref="timeInputRefs"
|
|
135
|
+
:value="pad(col.val)"
|
|
136
|
+
class="input text-center text-xs w-10 py-0.5"
|
|
137
|
+
:maxlength="2"
|
|
138
|
+
@blur="finishTimeEdit(col.key, $event)"
|
|
139
|
+
@keydown.enter="finishTimeEdit(col.key, $event)"
|
|
140
|
+
/>
|
|
141
|
+
<button v-else type="button" class="text-sm font-mono font-semibold text-text-primary hover:bg-bg-hover rounded px-2 py-0.5" @click="startTimeEdit(col.key)">{{ pad(col.val) }}</button>
|
|
142
|
+
<button type="button" class="text-text-tertiary hover:text-text-primary text-xs leading-none" @click="decTime(col.key)">▼</button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<!-- Confirm button -->
|
|
147
|
+
<div v-if="mode !== 'date'" class="flex justify-end mt-3 pt-2 border-t border-border-primary">
|
|
148
|
+
<button type="button" class="btn-sm btn-primary" @click="confirm">确认</button>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</Teleport>
|
|
152
|
+
</div>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<script setup lang="ts">
|
|
156
|
+
import { ref, computed, watch, onMounted, onUnmounted, nextTick, useAttrs } from 'vue'
|
|
157
|
+
import type { DateTimePreset } from '../types'
|
|
158
|
+
|
|
159
|
+
const props = withDefaults(defineProps<{
|
|
160
|
+
modelValue: string | [string, string]
|
|
161
|
+
mode?: 'date' | 'time' | 'datetime'
|
|
162
|
+
pickerStyle?: 'calendar' | 'grid'
|
|
163
|
+
range?: boolean
|
|
164
|
+
minuteStep?: number
|
|
165
|
+
secondStep?: number
|
|
166
|
+
disabledDate?: (date: string) => boolean
|
|
167
|
+
presets?: DateTimePreset[]
|
|
168
|
+
placeholder?: string
|
|
169
|
+
disabled?: boolean
|
|
170
|
+
clearable?: boolean
|
|
171
|
+
testId?: string
|
|
172
|
+
}>(), {
|
|
173
|
+
mode: 'date',
|
|
174
|
+
pickerStyle: 'calendar',
|
|
175
|
+
range: false,
|
|
176
|
+
minuteStep: 1,
|
|
177
|
+
secondStep: 1,
|
|
178
|
+
placeholder: '',
|
|
179
|
+
disabled: false,
|
|
180
|
+
clearable: true,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const emit = defineEmits<{
|
|
184
|
+
'update:modelValue': [value: string | [string, string]]
|
|
185
|
+
'change': [value: string | [string, string]]
|
|
186
|
+
'clear': []
|
|
187
|
+
}>()
|
|
188
|
+
|
|
189
|
+
const attrs = useAttrs()
|
|
190
|
+
const baseTestId = computed(() => props.testId ?? (attrs['data-testid'] as string | undefined))
|
|
191
|
+
|
|
192
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
193
|
+
const triggerRef = ref<HTMLElement | null>(null)
|
|
194
|
+
const timeInputRefs = ref<HTMLInputElement[]>([])
|
|
195
|
+
const open = ref(false)
|
|
196
|
+
const yearDropdownOpen = ref(false)
|
|
197
|
+
|
|
198
|
+
const now = new Date()
|
|
199
|
+
const viewYear = ref(now.getFullYear())
|
|
200
|
+
const viewMonth = ref(now.getMonth() + 1)
|
|
201
|
+
const decadeStart = ref(Math.floor(now.getFullYear() / 10) * 10)
|
|
202
|
+
|
|
203
|
+
const selectedDate = ref('')
|
|
204
|
+
const rangeStart = ref('')
|
|
205
|
+
const rangeEnd = ref('')
|
|
206
|
+
const rangePending = ref(false)
|
|
207
|
+
|
|
208
|
+
const hours = ref(0)
|
|
209
|
+
const minutes = ref(0)
|
|
210
|
+
const seconds = ref(0)
|
|
211
|
+
const editingTime = ref<'h' | 'm' | 's' | null>(null)
|
|
212
|
+
|
|
213
|
+
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
|
214
|
+
|
|
215
|
+
const decadeYears = computed(() => Array.from({ length: 10 }, (_, i) => decadeStart.value + i))
|
|
216
|
+
|
|
217
|
+
const hasValue = computed(() => {
|
|
218
|
+
if (props.range) {
|
|
219
|
+
const v = props.modelValue as [string, string]
|
|
220
|
+
return Array.isArray(v) && v[0] !== '' && v[1] !== ''
|
|
221
|
+
}
|
|
222
|
+
return typeof props.modelValue === 'string' && props.modelValue !== ''
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const displayText = computed(() => {
|
|
226
|
+
if (props.mode === 'time') {
|
|
227
|
+
if (typeof props.modelValue === 'string' && props.modelValue) return props.modelValue
|
|
228
|
+
return ''
|
|
229
|
+
}
|
|
230
|
+
if (props.range) {
|
|
231
|
+
const v = props.modelValue as [string, string]
|
|
232
|
+
if (Array.isArray(v) && v[0] && v[1]) return `${fmtDisplay(v[0])} → ${fmtDisplay(v[1])}`
|
|
233
|
+
return ''
|
|
234
|
+
}
|
|
235
|
+
if (typeof props.modelValue === 'string' && props.modelValue) return fmtDisplay(props.modelValue)
|
|
236
|
+
return ''
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
function fmtDisplay(val: string): string {
|
|
240
|
+
if (props.mode === 'date') return val.substring(0, 10)
|
|
241
|
+
const d = val.substring(0, 10)
|
|
242
|
+
const t = val.includes('T') ? val.substring(11, 19) : '00:00:00'
|
|
243
|
+
return `${d} ${t}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pad(n: number): string { return String(n).padStart(2, '0') }
|
|
247
|
+
|
|
248
|
+
interface CalendarDay { day: number; dateStr: string; currentMonth: boolean }
|
|
249
|
+
|
|
250
|
+
const calendarDays = computed<CalendarDay[]>(() => {
|
|
251
|
+
const y = viewYear.value, m = viewMonth.value
|
|
252
|
+
const first = new Date(y, m - 1, 1)
|
|
253
|
+
const startDow = first.getDay()
|
|
254
|
+
const dim = daysInMonth(y, m)
|
|
255
|
+
const prevDim = daysInMonth(y, m - 1 || 12)
|
|
256
|
+
const days: CalendarDay[] = []
|
|
257
|
+
|
|
258
|
+
const pm = m - 1 || 12, py = m - 1 === 0 ? y - 1 : y
|
|
259
|
+
for (let i = startDow - 1; i >= 0; i--) {
|
|
260
|
+
const d = prevDim - i
|
|
261
|
+
days.push({ day: d, dateStr: `${py}-${pad(pm)}-${pad(d)}`, currentMonth: false })
|
|
262
|
+
}
|
|
263
|
+
for (let d = 1; d <= dim; d++) {
|
|
264
|
+
days.push({ day: d, dateStr: `${y}-${pad(m)}-${pad(d)}`, currentMonth: true })
|
|
265
|
+
}
|
|
266
|
+
const nm = m + 1 > 12 ? 1 : m + 1, ny = m + 1 > 12 ? y + 1 : y
|
|
267
|
+
const rem = 42 - days.length
|
|
268
|
+
for (let d = 1; d <= rem; d++) {
|
|
269
|
+
days.push({ day: d, dateStr: `${ny}-${pad(nm)}-${pad(d)}`, currentMonth: false })
|
|
270
|
+
}
|
|
271
|
+
return days
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
function daysInMonth(y: number, m: number): number {
|
|
275
|
+
if (m <= 0) { m += 12; y-- }
|
|
276
|
+
if (m > 12) { m -= 12; y++ }
|
|
277
|
+
return new Date(y, m, 0).getDate()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function dayClass(day: CalendarDay) {
|
|
281
|
+
const cls: string[] = []
|
|
282
|
+
if (!day.currentMonth) cls.push('text-text-tertiary')
|
|
283
|
+
else cls.push('text-text-primary')
|
|
284
|
+
|
|
285
|
+
if (isSelected(day.dateStr)) {
|
|
286
|
+
cls.push('bg-accent-primary', 'text-white')
|
|
287
|
+
} else if (isRangeStart(day.dateStr)) {
|
|
288
|
+
cls.push('bg-accent-primary', 'text-white', 'rounded-l-md')
|
|
289
|
+
} else if (isRangeEnd(day.dateStr)) {
|
|
290
|
+
cls.push('bg-accent-primary', 'text-white', 'rounded-r-md')
|
|
291
|
+
} else if (isInRange(day.dateStr)) {
|
|
292
|
+
cls.push('bg-accent-primary/15')
|
|
293
|
+
} else if (isToday(day.dateStr)) {
|
|
294
|
+
cls.push('ring-1', 'ring-accent-primary')
|
|
295
|
+
} else if (day.currentMonth) {
|
|
296
|
+
cls.push('hover:bg-bg-hover')
|
|
297
|
+
}
|
|
298
|
+
return cls
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isSelected(ds: string) {
|
|
302
|
+
if (props.range) return false
|
|
303
|
+
return selectedDate.value === ds
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isRangeStart(ds: string) {
|
|
307
|
+
if (!props.range || !rangeStart.value) return false
|
|
308
|
+
return ds === rangeStart.value && rangePending.value
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isRangeEnd(ds: string) {
|
|
312
|
+
if (!props.range || !rangeEnd.value) return false
|
|
313
|
+
if (rangePending.value) return false
|
|
314
|
+
return ds === rangeEnd.value
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isInRange(ds: string) {
|
|
318
|
+
if (!props.range || !rangeStart.value || !rangeEnd.value || rangePending.value) return false
|
|
319
|
+
return ds > rangeStart.value && ds < rangeEnd.value
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isToday(ds: string) {
|
|
323
|
+
const t = new Date()
|
|
324
|
+
return ds === `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isDisabled(ds: string) {
|
|
328
|
+
return props.disabledDate ? props.disabledDate(ds) : false
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const timeColumns = computed(() => [
|
|
332
|
+
{ key: 'h' as const, val: hours.value },
|
|
333
|
+
{ key: 'm' as const, val: minutes.value },
|
|
334
|
+
{ key: 's' as const, val: seconds.value },
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
const panelPos = ref({ top: 0, left: 0 })
|
|
338
|
+
|
|
339
|
+
const panelStyle = computed(() => ({
|
|
340
|
+
top: `${panelPos.value.top}px`,
|
|
341
|
+
left: `${panelPos.value.left}px`,
|
|
342
|
+
zIndex: 9999,
|
|
343
|
+
}))
|
|
344
|
+
|
|
345
|
+
function updatePosition() {
|
|
346
|
+
if (!triggerRef.value) return
|
|
347
|
+
const rect = triggerRef.value.getBoundingClientRect()
|
|
348
|
+
panelPos.value = { top: rect.bottom + 4, left: rect.left }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function prevMonth() {
|
|
352
|
+
if (viewMonth.value === 1) { viewMonth.value = 12; viewYear.value-- }
|
|
353
|
+
else viewMonth.value--
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function nextMonth() {
|
|
357
|
+
if (viewMonth.value === 12) { viewMonth.value = 1; viewYear.value++ }
|
|
358
|
+
else viewMonth.value++
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function selectYear(y: number) {
|
|
362
|
+
viewYear.value = y
|
|
363
|
+
yearDropdownOpen.value = false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function selectMonth(m: number) {
|
|
367
|
+
viewMonth.value = m
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function selectDate(day: CalendarDay) {
|
|
371
|
+
if (isDisabled(day.dateStr)) return
|
|
372
|
+
|
|
373
|
+
if (props.range) {
|
|
374
|
+
if (!rangePending.value || rangeEnd.value) {
|
|
375
|
+
rangeStart.value = day.dateStr
|
|
376
|
+
rangeEnd.value = ''
|
|
377
|
+
rangePending.value = true
|
|
378
|
+
} else {
|
|
379
|
+
if (day.dateStr < rangeStart.value) {
|
|
380
|
+
rangeEnd.value = rangeStart.value
|
|
381
|
+
rangeStart.value = day.dateStr
|
|
382
|
+
} else {
|
|
383
|
+
rangeEnd.value = day.dateStr
|
|
384
|
+
}
|
|
385
|
+
rangePending.value = false
|
|
386
|
+
emitRange()
|
|
387
|
+
if (props.mode === 'date') { open.value = false; emit('change', props.modelValue) }
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
selectedDate.value = day.dateStr
|
|
391
|
+
if (props.mode === 'date') {
|
|
392
|
+
emitValue(day.dateStr)
|
|
393
|
+
open.value = false
|
|
394
|
+
emit('change', props.modelValue)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function emitValue(dateStr: string) {
|
|
400
|
+
let v: string
|
|
401
|
+
if (props.mode === 'date') v = dateStr
|
|
402
|
+
else if (props.mode === 'time') v = `${pad(hours.value)}:${pad(minutes.value)}:${pad(seconds.value)}`
|
|
403
|
+
else v = `${dateStr}T${pad(hours.value)}:${pad(minutes.value)}:${pad(seconds.value)}`
|
|
404
|
+
emit('update:modelValue', v)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function emitRange() {
|
|
408
|
+
const s = buildFull(rangeStart.value)
|
|
409
|
+
const e = buildFull(rangeEnd.value)
|
|
410
|
+
emit('update:modelValue', [s, e])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function buildFull(ds: string) {
|
|
414
|
+
if (props.mode === 'date') return ds
|
|
415
|
+
if (props.mode === 'time') return `${pad(hours.value)}:${pad(minutes.value)}:${pad(seconds.value)}`
|
|
416
|
+
return `${ds}T${pad(hours.value)}:${pad(minutes.value)}:${pad(seconds.value)}`
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function confirm() {
|
|
420
|
+
if (props.range) {
|
|
421
|
+
if (!rangeStart.value) rangeStart.value = selectedDate.value || todayStr()
|
|
422
|
+
if (!rangeEnd.value) rangeEnd.value = rangeStart.value
|
|
423
|
+
emitRange()
|
|
424
|
+
} else {
|
|
425
|
+
const ds = selectedDate.value || todayStr()
|
|
426
|
+
emitValue(ds)
|
|
427
|
+
}
|
|
428
|
+
open.value = false
|
|
429
|
+
emit('change', props.modelValue)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function clear() {
|
|
433
|
+
if (props.range) emit('update:modelValue', ['', ''])
|
|
434
|
+
else emit('update:modelValue', props.mode === 'time' ? '' : '')
|
|
435
|
+
selectedDate.value = ''
|
|
436
|
+
rangeStart.value = ''
|
|
437
|
+
rangeEnd.value = ''
|
|
438
|
+
rangePending.value = false
|
|
439
|
+
open.value = false
|
|
440
|
+
emit('clear')
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function toggle() {
|
|
444
|
+
if (props.disabled) return
|
|
445
|
+
open.value = !open.value
|
|
446
|
+
if (open.value) {
|
|
447
|
+
initFromModel()
|
|
448
|
+
yearDropdownOpen.value = false
|
|
449
|
+
nextTick(updatePosition)
|
|
450
|
+
} else {
|
|
451
|
+
editingTime.value = null
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function todayStr() {
|
|
456
|
+
const t = new Date()
|
|
457
|
+
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function initFromModel() {
|
|
461
|
+
const v = props.modelValue
|
|
462
|
+
if (props.range && Array.isArray(v)) {
|
|
463
|
+
const [s, e] = v as [string, string]
|
|
464
|
+
if (s) {
|
|
465
|
+
const p = parseDatePart(s)
|
|
466
|
+
viewYear.value = p.y; viewMonth.value = p.m
|
|
467
|
+
rangeStart.value = p.ds
|
|
468
|
+
const pe = parseDatePart(e)
|
|
469
|
+
rangeEnd.value = pe.ds
|
|
470
|
+
rangePending.value = false
|
|
471
|
+
if (props.mode !== 'date') { setTimeFromStr(s) }
|
|
472
|
+
}
|
|
473
|
+
} else if (typeof v === 'string' && v) {
|
|
474
|
+
if (props.mode === 'time') {
|
|
475
|
+
setTimeFromStr(v)
|
|
476
|
+
} else {
|
|
477
|
+
const p = parseDatePart(v)
|
|
478
|
+
viewYear.value = p.y; viewMonth.value = p.m
|
|
479
|
+
selectedDate.value = p.ds
|
|
480
|
+
if (props.mode === 'datetime') setTimeFromStr(v)
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
const t = new Date()
|
|
484
|
+
viewYear.value = t.getFullYear()
|
|
485
|
+
viewMonth.value = t.getMonth() + 1
|
|
486
|
+
hours.value = t.getHours()
|
|
487
|
+
minutes.value = t.getMinutes()
|
|
488
|
+
seconds.value = t.getSeconds()
|
|
489
|
+
selectedDate.value = todayStr()
|
|
490
|
+
rangeStart.value = ''
|
|
491
|
+
rangeEnd.value = ''
|
|
492
|
+
rangePending.value = false
|
|
493
|
+
}
|
|
494
|
+
decadeStart.value = Math.floor(viewYear.value / 10) * 10
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function parseDatePart(s: string) {
|
|
498
|
+
const ds = s.substring(0, 10)
|
|
499
|
+
const parts = ds.split('-')
|
|
500
|
+
return { y: +parts[0], m: +parts[1], ds }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function setTimeFromStr(s: string) {
|
|
504
|
+
const idx = s.indexOf('T')
|
|
505
|
+
const ts = idx >= 0 ? s.substring(idx + 1) : (s.includes(':') ? s : '00:00:00')
|
|
506
|
+
const p = ts.split(':')
|
|
507
|
+
hours.value = +p[0] || 0
|
|
508
|
+
minutes.value = +p[1] || 0
|
|
509
|
+
seconds.value = +p[2] || 0
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function incTime(key: 'h' | 'm' | 's') {
|
|
513
|
+
if (key === 'h') hours.value = (hours.value + 1) % 24
|
|
514
|
+
else if (key === 'm') minutes.value = (minutes.value + props.minuteStep) % 60
|
|
515
|
+
else seconds.value = (seconds.value + props.secondStep) % 60
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function decTime(key: 'h' | 'm' | 's') {
|
|
519
|
+
if (key === 'h') hours.value = (hours.value + 23) % 24
|
|
520
|
+
else if (key === 'm') minutes.value = (minutes.value - props.minuteStep + 60) % 60
|
|
521
|
+
else seconds.value = (seconds.value - props.secondStep + 60) % 60
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function startTimeEdit(key: 'h' | 'm' | 's') {
|
|
525
|
+
editingTime.value = key
|
|
526
|
+
nextTick(() => {
|
|
527
|
+
const idx = key === 'h' ? 0 : key === 'm' ? 1 : 2
|
|
528
|
+
const inp = timeInputRefs.value[idx]
|
|
529
|
+
if (inp) { inp.focus(); inp.select() }
|
|
530
|
+
})
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function finishTimeEdit(key: 'h' | 'm' | 's', e: Event) {
|
|
534
|
+
editingTime.value = null
|
|
535
|
+
const val = parseInt((e.target as HTMLInputElement).value, 10)
|
|
536
|
+
if (isNaN(val)) return
|
|
537
|
+
if (key === 'h') hours.value = Math.max(0, Math.min(23, val))
|
|
538
|
+
else if (key === 'm') minutes.value = Math.max(0, Math.min(59, Math.round(val / props.minuteStep) * props.minuteStep))
|
|
539
|
+
else seconds.value = Math.max(0, Math.min(59, Math.round(val / props.secondStep) * props.secondStep))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function handleOutside(e: MouseEvent) {
|
|
543
|
+
if (rootRef.value && !rootRef.value.contains(e.target as Node)) {
|
|
544
|
+
open.value = false
|
|
545
|
+
editingTime.value = null
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
onMounted(() => {
|
|
550
|
+
document.addEventListener('mousedown', handleOutside)
|
|
551
|
+
window.addEventListener('scroll', updatePosition, true)
|
|
552
|
+
window.addEventListener('resize', updatePosition)
|
|
553
|
+
})
|
|
554
|
+
onUnmounted(() => {
|
|
555
|
+
document.removeEventListener('mousedown', handleOutside)
|
|
556
|
+
window.removeEventListener('scroll', updatePosition, true)
|
|
557
|
+
window.removeEventListener('resize', updatePosition)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
watch(() => props.modelValue, () => {
|
|
561
|
+
if (open.value) initFromModel()
|
|
562
|
+
}, { deep: true })
|
|
563
|
+
</script>
|
|
@@ -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
|
@@ -14,5 +14,8 @@ export { default as UserPicker } from './components/UserPicker.vue'
|
|
|
14
14
|
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
|
+
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'
|
|
17
20
|
export { useToast } from './composables/useToast'
|
|
18
|
-
export type { Column, SelectOption, MenuItem, MenuGroup, NavModule, DropdownItem, ToastType } from './types'
|
|
21
|
+
export type { Column, SelectOption, MenuItem, MenuGroup, NavModule, DropdownItem, ToastType, DateTimePreset } from './types'
|
package/package.json
CHANGED