@aquiferre/ui-kit 0.1.5 → 0.1.7

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.
@@ -10,10 +10,19 @@
10
10
  v-for="col in columns"
11
11
  :key="col.key"
12
12
  class="table-header-cell"
13
- :class="col.width"
13
+ :class="[col.width, col.sortable ? 'cursor-pointer select-none' : '']"
14
14
  :style="col.align ? `text-align:${col.align}` : undefined"
15
+ @click="col.sortable ? onSortClick(col) : undefined"
15
16
  >
16
- {{ col.title }}
17
+ <span class="inline-flex items-center gap-1">
18
+ {{ col.title }}
19
+ <span v-if="col.sortable" class="text-[10px] leading-none">
20
+ <span v-if="sort && sort.field === (col.sortField || col.key)" class="text-accent-primary">
21
+ {{ sort.order === 'asc' ? '▲' : '▼' }}
22
+ </span>
23
+ <span v-else class="text-text-tertiary">↕</span>
24
+ </span>
25
+ </span>
17
26
  </th>
18
27
  </tr>
19
28
  </thead>
@@ -67,19 +76,33 @@ const props = withDefaults(defineProps<{
67
76
  selectable?: boolean
68
77
  selected?: string[]
69
78
  rowKey?: string
79
+ sort?: { field: string; order: 'asc' | 'desc' } | null
70
80
  }>(), {
71
81
  loading: false,
72
82
  emptyText: '暂无数据',
73
83
  selectable: false,
74
84
  selected: () => [],
75
85
  rowKey: 'id',
86
+ sort: null,
76
87
  })
77
88
 
78
89
  const emit = defineEmits<{
79
90
  'update:selected': [ids: string[]]
80
91
  'row-click': [row: any]
92
+ 'sort-change': [payload: { field: string; order: 'asc' | 'desc' } | null]
81
93
  }>()
82
94
 
95
+ function onSortClick(col: Column) {
96
+ const field = col.sortField || col.key
97
+ if (!props.sort || props.sort.field !== field) {
98
+ emit('sort-change', { field, order: 'asc' })
99
+ } else if (props.sort.order === 'asc') {
100
+ emit('sort-change', { field, order: 'desc' })
101
+ } else {
102
+ emit('sort-change', null)
103
+ }
104
+ }
105
+
83
106
  const colspan = computed(() => props.columns.length + (props.selectable ? 1 : 0))
84
107
 
85
108
  const selectedSet = computed(() => new Set(props.selected))
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <Transition name="fade">
4
+ <div v-if="visible" class="dialog-overlay">
5
+ <div class="fixed inset-0 bg-bg-overlay" @click="onOverlayClick" />
6
+ <div class="panel-glass dialog" :class="[sizeClass, customClass]">
7
+ <div v-if="title" class="flex items-center justify-between px-6 py-4 border-b border-border-primary">
8
+ <h3 class="text-lg font-medium text-text-primary">{{ title }}</h3>
9
+ <button
10
+ v-if="closable"
11
+ class="text-text-tertiary hover:text-text-primary text-2xl leading-none"
12
+ @click="close"
13
+ >&times;</button>
14
+ </div>
15
+ <div class="px-6 py-4">
16
+ <slot />
17
+ </div>
18
+ <div v-if="$slots.footer" class="px-6 py-4 border-t border-border-primary bg-bg-secondary/50 rounded-b-2xl">
19
+ <slot name="footer" />
20
+ </div>
21
+ </div>
22
+ </div>
23
+ </Transition>
24
+ </Teleport>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { computed } from 'vue'
29
+
30
+ const props = withDefaults(defineProps<{
31
+ visible: boolean
32
+ title?: string
33
+ size?: 'sm' | 'md' | 'lg' | 'xl'
34
+ closable?: boolean
35
+ maskClosable?: boolean
36
+ customClass?: string
37
+ }>(), {
38
+ closable: true,
39
+ maskClosable: true,
40
+ size: 'md',
41
+ })
42
+
43
+ const emit = defineEmits<{
44
+ 'update:visible': [value: boolean]
45
+ close: []
46
+ }>()
47
+
48
+ const sizeClass = computed(() => {
49
+ const map: Record<string, string> = {
50
+ sm: 'max-w-sm',
51
+ md: 'max-w-lg',
52
+ lg: 'max-w-2xl',
53
+ xl: 'max-w-4xl',
54
+ }
55
+ return map[props.size] || 'max-w-lg'
56
+ })
57
+
58
+ function close() {
59
+ emit('update:visible', false)
60
+ emit('close')
61
+ }
62
+
63
+ function onOverlayClick() {
64
+ if (props.maskClosable) {
65
+ close()
66
+ }
67
+ }
68
+ </script>
69
+
70
+ <style scoped>
71
+ .fade-enter-active,
72
+ .fade-leave-active {
73
+ transition: opacity 0.2s ease;
74
+ }
75
+ .fade-enter-from,
76
+ .fade-leave-to {
77
+ opacity: 0;
78
+ }
79
+ </style>
@@ -0,0 +1,142 @@
1
+ <template>
2
+ <div ref="rootRef" class="relative w-full">
3
+ <div
4
+ class="input flex items-center w-full focus-within:border-accent-primary"
5
+ :class="disabled ? 'opacity-60 cursor-not-allowed' : ''"
6
+ >
7
+ <input
8
+ ref="inputRef"
9
+ :value="inputValue"
10
+ :placeholder="displayLabel || placeholder"
11
+ :disabled="disabled"
12
+ class="flex-1 min-w-0 bg-transparent border-none outline-none text-input-text placeholder:text-input-placeholder"
13
+ @focus="onFocus"
14
+ @input="onInput"
15
+ />
16
+ <button
17
+ v-if="clearable && modelValue"
18
+ type="button"
19
+ class="px-2 text-text-tertiary hover:text-text-primary"
20
+ @click.stop="clear"
21
+ tabindex="-1"
22
+ >✕</button>
23
+ <button
24
+ type="button"
25
+ class="px-2 text-text-tertiary hover:text-text-primary"
26
+ @click.stop="toggle"
27
+ tabindex="-1"
28
+ >▾</button>
29
+ </div>
30
+
31
+ <div
32
+ v-if="open"
33
+ class="absolute top-full left-0 mt-1 w-full max-h-60 overflow-auto card shadow-lg z-20 py-1"
34
+ >
35
+ <div v-if="filtered.length === 0" class="px-4 py-2 text-sm text-text-tertiary">无匹配项</div>
36
+ <div
37
+ v-for="opt in filtered"
38
+ :key="opt.value"
39
+ class="px-4 py-2 text-sm text-text-primary hover:bg-bg-hover cursor-pointer"
40
+ :class="opt.value === modelValue ? 'bg-bg-secondary' : ''"
41
+ @mousedown.prevent="select(opt)"
42
+ >
43
+ {{ opt.label }}
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
51
+ import { match } from 'pinyin-pro'
52
+ import type { SelectOption } from '../types'
53
+
54
+ const props = withDefaults(defineProps<{
55
+ modelValue: string
56
+ options: SelectOption[]
57
+ placeholder?: string
58
+ clearable?: boolean
59
+ disabled?: boolean
60
+ }>(), {
61
+ placeholder: '请选择',
62
+ clearable: true,
63
+ disabled: false,
64
+ })
65
+
66
+ const emit = defineEmits<{
67
+ 'update:modelValue': [value: string]
68
+ 'change': [value: string]
69
+ }>()
70
+
71
+ const rootRef = ref<HTMLElement | null>(null)
72
+ const inputRef = ref<HTMLInputElement | null>(null)
73
+ const open = ref(false)
74
+ const query = ref('')
75
+
76
+ const displayLabel = computed(() => {
77
+ const opt = props.options.find(o => o.value === props.modelValue)
78
+ return opt ? opt.label : ''
79
+ })
80
+
81
+ const inputValue = computed(() => (open.value ? query.value : displayLabel.value))
82
+
83
+ const filtered = computed(() => {
84
+ const q = query.value.trim()
85
+ if (!q) return props.options
86
+ const ql = q.toLowerCase()
87
+ return props.options.filter(o =>
88
+ o.label.toLowerCase().includes(ql) || match(o.label, q) !== null
89
+ )
90
+ })
91
+
92
+ function onInput(e: Event) {
93
+ query.value = (e.target as HTMLInputElement).value
94
+ open.value = true
95
+ }
96
+
97
+ function onFocus() {
98
+ open.value = true
99
+ query.value = ''
100
+ }
101
+
102
+ function select(opt: SelectOption) {
103
+ emit('update:modelValue', opt.value)
104
+ emit('change', opt.value)
105
+ query.value = ''
106
+ open.value = false
107
+ }
108
+
109
+ function clear() {
110
+ emit('update:modelValue', '')
111
+ emit('change', '')
112
+ query.value = ''
113
+ open.value = false
114
+ }
115
+
116
+ function toggle() {
117
+ if (props.disabled) return
118
+ open.value = !open.value
119
+ if (open.value) {
120
+ query.value = ''
121
+ inputRef.value?.focus()
122
+ }
123
+ }
124
+
125
+ function handleOutside(e: MouseEvent) {
126
+ if (rootRef.value && !rootRef.value.contains(e.target as Node)) {
127
+ open.value = false
128
+ }
129
+ }
130
+
131
+ onMounted(() => {
132
+ document.addEventListener('mousedown', handleOutside)
133
+ })
134
+ onUnmounted(() => {
135
+ document.removeEventListener('mousedown', handleOutside)
136
+ })
137
+
138
+ // 选项变化时(如异步加载完成),若已选中则无需调整;仅保证关闭态显示正确
139
+ watch(() => props.options, () => {
140
+ if (!open.value) query.value = ''
141
+ })
142
+ </script>
@@ -1,4 +1,4 @@
1
- import { ref } from 'vue'
1
+ import { ref, type Ref } from 'vue'
2
2
  import type { ToastType } from '../types'
3
3
 
4
4
  interface ToastItem {
@@ -7,20 +7,38 @@ interface ToastItem {
7
7
  message: string
8
8
  }
9
9
 
10
- const toasts = ref<ToastItem[]>([])
11
- let nextId = 0
10
+ // 在全局对象上存储 toast 状态,确保模块重复加载时也能共享状态
11
+ function getGlobalToasts(): Ref<ToastItem[]> {
12
+ const w = window as any
13
+ if (!w.__TOAST_STATE__) {
14
+ w.__TOAST_STATE__ = ref<ToastItem[]>([])
15
+ }
16
+ return w.__TOAST_STATE__
17
+ }
18
+
19
+ function getGlobalNextId(): { value: number } {
20
+ const w = window as any
21
+ if (!w.__TOAST_NEXT_ID__) {
22
+ w.__TOAST_NEXT_ID__ = { value: 0 }
23
+ }
24
+ return w.__TOAST_NEXT_ID__
25
+ }
12
26
 
13
27
  function add(type: ToastType, message: string) {
14
- const id = nextId++
28
+ const toasts = getGlobalToasts()
29
+ const nextId = getGlobalNextId()
30
+ const id = nextId.value++
15
31
  toasts.value.push({ id, type, message })
16
32
  setTimeout(() => remove(id), 3000)
17
33
  }
18
34
 
19
35
  function remove(id: number) {
36
+ const toasts = getGlobalToasts()
20
37
  toasts.value = toasts.value.filter(t => t.id !== id)
21
38
  }
22
39
 
23
40
  export function useToast() {
41
+ const toasts = getGlobalToasts()
24
42
  return {
25
43
  toasts,
26
44
  success: (msg: string) => add('success', msg),
package/index.ts CHANGED
@@ -6,11 +6,13 @@ export { default as SearchInput } from './components/SearchInput.vue'
6
6
  export { default as FilterDropdown } from './components/FilterDropdown.vue'
7
7
  export { default as FormDialog } from './components/FormDialog.vue'
8
8
  export { default as ConfirmDialog } from './components/ConfirmDialog.vue'
9
+ export { default as Modal } from './components/Modal.vue'
9
10
  export { default as Toast } from './components/Toast.vue'
10
11
  export { default as StatusTag } from './components/StatusTag.vue'
11
12
  export { default as FileUploader } from './components/FileUploader.vue'
12
13
  export { default as UserPicker } from './components/UserPicker.vue'
14
+ export { default as SearchSelect } from './components/SearchSelect.vue'
13
15
  export { default as SideMenu } from './components/SideMenu.vue'
14
16
  export { default as TopNavbar } from './components/TopNavbar.vue'
15
17
  export { useToast } from './composables/useToast'
16
- export type { Column, MenuItem, MenuGroup, NavModule, DropdownItem, ToastType } from './types'
18
+ export type { Column, SelectOption, MenuItem, MenuGroup, NavModule, DropdownItem, ToastType } from './types'
package/package.json CHANGED
@@ -1,17 +1,25 @@
1
1
  {
2
2
  "name": "@aquiferre/ui-kit",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "exports": {
8
8
  ".": "./index.ts"
9
9
  },
10
- "files": ["*.ts", "*.vue", "components/**/*.vue", "composables/**/*.ts"],
10
+ "files": [
11
+ "*.ts",
12
+ "*.vue",
13
+ "components/**/*.vue",
14
+ "composables/**/*.ts"
15
+ ],
16
+ "dependencies": {
17
+ "pinyin-pro": "^3.28.1"
18
+ },
11
19
  "peerDependencies": {
12
- "vue": ">=3.0.0",
20
+ "@aquiferre/theme-kit": ">=0.1.0",
13
21
  "pinia": ">=2.0.0",
14
- "@aquiferre/theme-kit": ">=0.1.0"
22
+ "vue": ">=3.0.0"
15
23
  },
16
24
  "publishConfig": {
17
25
  "access": "public"
package/types.ts CHANGED
@@ -3,6 +3,13 @@ export interface Column {
3
3
  title: string
4
4
  width?: string
5
5
  align?: 'left' | 'center' | 'right'
6
+ sortable?: boolean
7
+ sortField?: string
8
+ }
9
+
10
+ export interface SelectOption {
11
+ value: string
12
+ label: string
6
13
  }
7
14
 
8
15
  export interface MenuItem {