@aquiferre/ui-kit 0.4.1 → 0.4.3

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.
@@ -39,8 +39,9 @@
39
39
  :data-testid="rowPrefix ? `${rowPrefix}-${row[rowKey] ?? index}` : undefined"
40
40
  class="table-row"
41
41
  @click="handleRowClick(row)"
42
+ @dblclick="handleRowDblClick(row)"
42
43
  >
43
- <td v-if="selectable" class="table-cell" @click.stop>
44
+ <td v-if="selectable" class="table-cell" @click.stop @dblclick.stop>
44
45
  <input type="checkbox" :checked="isSelected(row)" :data-testid="rowPrefix ? `${rowPrefix}-checkbox-${row[rowKey] ?? index}` : undefined" @change="toggleRow(row)" />
45
46
  </td>
46
47
  <td
@@ -98,6 +99,7 @@ const rowPrefix = computed<string | undefined>(() => props.rowTestIdPrefix ?? ba
98
99
  const emit = defineEmits<{
99
100
  'update:selected': [ids: string[]]
100
101
  'row-click': [row: any]
102
+ 'row-dblclick': [row: any]
101
103
  'sort-change': [payload: { field: string; order: 'asc' | 'desc' } | null]
102
104
  }>()
103
105
 
@@ -148,4 +150,8 @@ function toggleRow(row: any) {
148
150
  function handleRowClick(row: any) {
149
151
  emit('row-click', row)
150
152
  }
153
+
154
+ function handleRowDblClick(row: any) {
155
+ emit('row-dblclick', row)
156
+ }
151
157
  </script>
@@ -1,18 +1,28 @@
1
1
  <template>
2
- <aside class="sidebar" style="width: 200px;" :data-testid="baseTestId">
3
- <nav class="mt-3">
2
+ <aside class="sidebar transition-all duration-200" :style="{ width: collapsed ? '52px' : '200px' }" :data-testid="baseTestId">
3
+ <div class="flex items-center justify-end px-1 pt-2 pb-1">
4
+ <button
5
+ v-if="collapsible"
6
+ class="sidebar-collapse-btn text-xs w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 transition-colors"
7
+ :title="collapsed ? '展开菜单' : '收起菜单'"
8
+ @click="collapsed = !collapsed"
9
+ >{{ collapsed ? '▶' : '◀' }}</button>
10
+ </div>
11
+ <nav class="mt-1">
4
12
  <router-link
13
+ v-if="showHome"
5
14
  to="/"
6
15
  class="sidebar-item text-xs"
7
16
  exact-active-class="sidebar-item-active"
8
17
  :data-testid="baseTestId ? `${baseTestId}-item-home` : undefined"
18
+ :title="collapsed ? '首页' : undefined"
9
19
  >
10
20
  <span class="w-5 text-center text-sm">🏠</span>
11
- <span class="ml-2">首页</span>
21
+ <span v-if="!collapsed" class="ml-2">首页</span>
12
22
  </router-link>
13
23
 
14
24
  <template v-for="group in menuGroups" :key="group.key">
15
- <div class="sidebar-section-title text-[10px]">{{ group.label }}</div>
25
+ <div v-if="!collapsed" class="sidebar-section-title text-[10px]">{{ group.label }}</div>
16
26
  <router-link
17
27
  v-for="item in group.items"
18
28
  :key="item.path"
@@ -20,9 +30,10 @@
20
30
  class="sidebar-item text-xs"
21
31
  active-class="sidebar-item-active"
22
32
  :data-testid="baseTestId ? `${baseTestId}-item-${item.path}` : undefined"
33
+ :title="collapsed ? item.label : undefined"
23
34
  >
24
35
  <span class="w-5 text-center text-sm">{{ item.icon }}</span>
25
- <span class="ml-2">{{ item.label }}</span>
36
+ <span v-if="!collapsed" class="ml-2">{{ item.label }}</span>
26
37
  </router-link>
27
38
  </template>
28
39
  </nav>
@@ -30,14 +41,18 @@
30
41
  </template>
31
42
 
32
43
  <script setup lang="ts">
33
- import { computed, useAttrs } from 'vue'
44
+ import { computed, ref, useAttrs } from 'vue'
34
45
  import type { MenuGroup } from '../types'
35
46
 
36
47
  const props = withDefaults(defineProps<{
37
48
  menuGroups: MenuGroup[]
38
49
  testId?: string
39
- }>(), {})
50
+ collapsible?: boolean
51
+ showHome?: boolean
52
+ }>(), { collapsible: false, showHome: true })
53
+
54
+ const collapsed = ref(false)
40
55
 
41
56
  const attrs = useAttrs()
42
57
  const baseTestId = computed<string | undefined>(() => props.testId ?? (attrs['data-testid'] as string | undefined))
43
- </script>
58
+ </script>
@@ -0,0 +1,230 @@
1
+ <template>
2
+ <div ref="rootRef" class="relative w-full" :data-testid="baseTestId">
3
+ <div class="flex items-center input w-full focus-within:border-accent-primary" :class="disabled ? 'opacity-60 cursor-not-allowed' : ''">
4
+ <input
5
+ ref="inputRef"
6
+ :value="modelValue"
7
+ :placeholder="placeholder"
8
+ :disabled="disabled"
9
+ class="flex-1 min-w-0 bg-transparent border-none outline-none text-input-text placeholder:text-input-placeholder"
10
+ :data-testid="baseTestId ? `${baseTestId}-input` : undefined"
11
+ type="text"
12
+ @input="onInput"
13
+ @focus="onFocus"
14
+ />
15
+ <button
16
+ v-if="clearable && modelValue"
17
+ type="button"
18
+ class="px-2 text-text-tertiary hover:text-text-primary shrink-0"
19
+ :data-testid="baseTestId ? `${baseTestId}-clear` : undefined"
20
+ @click.stop="clear"
21
+ tabindex="-1"
22
+ >&times;</button>
23
+ <!-- 获取模型按钮(loadBtn):手动触发 loadOptions,点击后加载并展开下拉 -->
24
+ <button
25
+ v-if="loadBtn && loadOptions"
26
+ type="button"
27
+ class="px-2.5 py-1 m-1 text-xs rounded border border-accent-primary/40 text-accent-primary hover:bg-accent-primary/10 whitespace-nowrap shrink-0"
28
+ :disabled="loading"
29
+ :data-testid="baseTestId ? `${baseTestId}-load` : undefined"
30
+ @click.stop="onLoadBtnClick"
31
+ tabindex="-1"
32
+ >{{ loading ? '获取中...' : loadBtnText }}</button>
33
+ <!-- 无 loadBtn 时提供展开按钮(配合外部 options 模式) -->
34
+ <button
35
+ v-if="!loadBtn && loading"
36
+ type="button"
37
+ class="px-2 text-text-tertiary shrink-0"
38
+ disabled
39
+ ></button>
40
+ <button
41
+ v-else-if="!loadBtn"
42
+ type="button"
43
+ class="px-2 text-text-tertiary hover:text-text-primary shrink-0"
44
+ :data-testid="baseTestId ? `${baseTestId}-toggle` : undefined"
45
+ @click.stop="toggle"
46
+ tabindex="-1"
47
+ >▾</button>
48
+ </div>
49
+
50
+ <!-- 下拉弹层:absolute 定位跟随输入框(与 SearchSelect 一致);父容器勿设 overflow-hidden -->
51
+ <div
52
+ v-if="open"
53
+ class="absolute top-full left-0 mt-1 w-full max-h-60 overflow-auto card shadow-lg z-20 py-1"
54
+ :data-testid="baseTestId ? `${baseTestId}-dropdown` : undefined"
55
+ >
56
+ <!-- 初始加载中 -->
57
+ <div v-if="initialLoading" class="px-4 py-2 text-sm text-text-tertiary">获取中...</div>
58
+ <!-- 加载失败 -->
59
+ <div v-else-if="loadError" class="px-4 py-2">
60
+ <span class="text-sm text-accent-danger">{{ loadError }}</span>
61
+ <button class="ml-2 text-xs text-accent-primary hover:text-accent-primary-hover" @click="retryLoad">重试</button>
62
+ </div>
63
+ <!-- 无选项 -->
64
+ <div v-else-if="filtered.length === 0" class="px-4 py-2 text-sm text-text-tertiary">无匹配项</div>
65
+ <!-- 选项列表 -->
66
+ <div
67
+ v-for="opt in filtered"
68
+ :key="opt.value"
69
+ class="px-4 py-2 text-sm text-text-primary hover:bg-bg-hover cursor-pointer"
70
+ :class="opt.value === modelValue ? 'bg-bg-secondary' : ''"
71
+ :data-testid="optionTestIdPrefix ? `${optionTestIdPrefix}-option-${opt.value}` : undefined"
72
+ @mousedown.prevent="select(opt)"
73
+ >
74
+ {{ opt.label }}
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </template>
79
+
80
+ <script setup lang="ts">
81
+ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
82
+ import { match } from 'pinyin-pro'
83
+ import type { SelectOption } from '../types'
84
+
85
+ const props = withDefaults(defineProps<{
86
+ modelValue: string
87
+ options?: SelectOption[]
88
+ loadOptions?: () => Promise<SelectOption[]>
89
+ placeholder?: string
90
+ disabled?: boolean
91
+ clearable?: boolean
92
+ testId?: string
93
+ loadBtn?: boolean
94
+ loadBtnText?: string
95
+ }>(), {
96
+ options: () => [],
97
+ placeholder: '请输入或选择',
98
+ disabled: false,
99
+ clearable: true,
100
+ loadBtn: false,
101
+ loadBtnText: '获取',
102
+ })
103
+
104
+ const emit = defineEmits<{
105
+ 'update:modelValue': [value: string]
106
+ }>()
107
+
108
+ const baseTestId = computed(() => props.testId)
109
+ const optionTestIdPrefix = baseTestId
110
+
111
+ const rootRef = ref<HTMLElement | null>(null)
112
+ const inputRef = ref<HTMLInputElement | null>(null)
113
+ const open = ref(false)
114
+ const loading = ref(false)
115
+ const initialLoading = ref(false)
116
+ const loadError = ref('')
117
+
118
+ const innerOptions = ref<SelectOption[]>([])
119
+
120
+ // 合并外部 options 与异步加载的选项
121
+ const mergedOptions = computed(() => {
122
+ // 如果提供了 loadOptions,优先使用异步加载的结果
123
+ if (innerOptions.value.length) return innerOptions.value
124
+ return props.options
125
+ })
126
+
127
+ const query = ref('')
128
+
129
+ const filtered = computed(() => {
130
+ const opts = mergedOptions.value
131
+ const q = query.value.trim()
132
+ if (!q) return opts
133
+ const ql = q.toLowerCase()
134
+ return opts.filter(o =>
135
+ o.label.toLowerCase().includes(ql) || match(o.label, q) !== null
136
+ )
137
+ })
138
+
139
+ async function loadAsync() {
140
+ if (!props.loadOptions) return
141
+ loadError.value = ''
142
+ initialLoading.value = true
143
+ loading.value = true
144
+ try {
145
+ const result = await props.loadOptions()
146
+ innerOptions.value = result
147
+ } catch (err: any) {
148
+ loadError.value = err?.message || '获取失败'
149
+ } finally {
150
+ initialLoading.value = false
151
+ loading.value = false
152
+ }
153
+ }
154
+
155
+ function retryLoad() {
156
+ loadAsync()
157
+ }
158
+
159
+ function onInput(e: Event) {
160
+ const value = (e.target as HTMLInputElement).value
161
+ emit('update:modelValue', value)
162
+ query.value = value
163
+ if (!open.value) open.value = true
164
+ }
165
+
166
+ function onFocus() {
167
+ if (props.disabled) return
168
+ // 有 loadBtn 时,展开聚焦不自动加载(需用户点击按钮)
169
+ if (!props.loadBtn && props.loadOptions && innerOptions.value.length === 0 && !loading.value) {
170
+ loadAsync()
171
+ }
172
+ open.value = true
173
+ }
174
+
175
+ function toggle() {
176
+ if (props.disabled) return
177
+ if (open.value) {
178
+ open.value = false
179
+ return
180
+ }
181
+ // 有 loadBtn 时不自动加载
182
+ if (!props.loadBtn && props.loadOptions && innerOptions.value.length === 0) {
183
+ loadAsync()
184
+ }
185
+ open.value = true
186
+ nextTick(() => inputRef.value?.focus())
187
+ }
188
+
189
+ async function onLoadBtnClick() {
190
+ if (!props.loadOptions) return
191
+ await loadAsync()
192
+ open.value = true
193
+ }
194
+
195
+ function select(opt: SelectOption) {
196
+ emit('update:modelValue', opt.value)
197
+ open.value = false
198
+ query.value = ''
199
+ }
200
+
201
+ function clear() {
202
+ emit('update:modelValue', '')
203
+ open.value = false
204
+ query.value = ''
205
+ }
206
+
207
+ // 下拉关闭(外部点击 / Escape)
208
+ function close() {
209
+ open.value = false
210
+ }
211
+
212
+ function handleOutside(e: MouseEvent) {
213
+ if (rootRef.value && !rootRef.value.contains(e.target as Node)) {
214
+ open.value = false
215
+ }
216
+ }
217
+
218
+ function onKeydown(e: KeyboardEvent) {
219
+ if (e.key === 'Escape') close()
220
+ }
221
+
222
+ onMounted(() => {
223
+ document.addEventListener('mousedown', handleOutside)
224
+ document.addEventListener('keydown', onKeydown)
225
+ })
226
+ onUnmounted(() => {
227
+ document.removeEventListener('mousedown', handleOutside)
228
+ document.removeEventListener('keydown', onKeydown)
229
+ })
230
+ </script>
package/index.ts CHANGED
@@ -13,6 +13,7 @@ export { default as FileUploader } from './components/FileUploader.vue'
13
13
  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
+ export { default as SuggestInput } from './components/SuggestInput.vue'
16
17
  export { default as TopNavbar } from './components/TopNavbar.vue'
17
18
  export { default as DateTimePicker } from './components/DateTimePicker.vue'
18
19
  export { default as Tree } from './components/Tree.vue'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aquiferre/ui-kit",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "index.ts",