@aquiferre/ui-kit 0.1.0 → 0.1.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.
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <nav class="breadcrumb" aria-label="Breadcrumb">
3
+ <ol class="breadcrumb-list">
4
+ <li v-for="(item, index) in items" :key="index" class="breadcrumb-item">
5
+ <template v-if="index < items.length - 1">
6
+ <a v-if="item.path" :href="item.path" class="breadcrumb-link" @click.prevent="handleClick(item)">
7
+ {{ item.label }}
8
+ </a>
9
+ <span v-else class="breadcrumb-text">{{ item.label }}</span>
10
+ </template>
11
+ <span v-else class="breadcrumb-current" aria-current="page">{{ item.label }}</span>
12
+ <span v-if="index < items.length - 1" class="breadcrumb-separator">/</span>
13
+ </li>
14
+ </ol>
15
+ </nav>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { useRouter } from 'vue-router'
20
+
21
+ interface BreadcrumbItem {
22
+ label: string
23
+ path?: string
24
+ }
25
+
26
+ const props = defineProps<{
27
+ items: BreadcrumbItem[]
28
+ }>()
29
+
30
+ const router = useRouter()
31
+
32
+ function handleClick(item: BreadcrumbItem) {
33
+ if (item.path) {
34
+ router.push(item.path)
35
+ }
36
+ }
37
+ </script>
@@ -0,0 +1,68 @@
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="cancel" />
6
+ <div class="dialog max-w-sm">
7
+ <div class="px-6 py-4">
8
+ <h3 class="dialog-title">{{ title }}</h3>
9
+ <p v-if="content" class="text-text-secondary text-sm mt-2">{{ content }}</p>
10
+ </div>
11
+ <div class="dialog-actions px-6 py-4 border-t border-border-primary bg-bg-secondary rounded-b-2xl">
12
+ <button class="btn-secondary" @click="cancel">
13
+ {{ cancelText }}
14
+ </button>
15
+ <button :disabled="loading" :class="confirmClass" @click="confirm">
16
+ {{ loading ? loadingText : confirmText }}
17
+ </button>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </Transition>
22
+ </Teleport>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ withDefaults(defineProps<{
27
+ visible: boolean
28
+ title?: string
29
+ content?: string
30
+ confirmText?: string
31
+ cancelText?: string
32
+ loading?: boolean
33
+ loadingText?: string
34
+ confirmClass?: string
35
+ }>(), {
36
+ title: '确认删除',
37
+ confirmText: '确认删除',
38
+ cancelText: '取消',
39
+ loadingText: '删除中...',
40
+ confirmClass: 'btn-danger',
41
+ })
42
+
43
+ const emit = defineEmits<{
44
+ confirm: []
45
+ cancel: []
46
+ 'update:visible': [value: boolean]
47
+ }>()
48
+
49
+ function confirm() {
50
+ emit('confirm')
51
+ }
52
+
53
+ function cancel() {
54
+ emit('cancel')
55
+ emit('update:visible', false)
56
+ }
57
+ </script>
58
+
59
+ <style scoped>
60
+ .fade-enter-active,
61
+ .fade-leave-active {
62
+ transition: opacity 0.2s ease;
63
+ }
64
+ .fade-enter-from,
65
+ .fade-leave-to {
66
+ opacity: 0;
67
+ }
68
+ </style>
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <div class="table-container relative w-full">
3
+ <table class="table">
4
+ <thead class="table-header">
5
+ <tr>
6
+ <th
7
+ v-for="col in columns"
8
+ :key="col.key"
9
+ class="table-header-cell"
10
+ >
11
+ {{ col.title }}
12
+ </th>
13
+ </tr>
14
+ </thead>
15
+ <tbody class="table-body">
16
+ <tr v-if="!loading && data.length === 0">
17
+ <td :colspan="columns.length" class="table-empty">
18
+ {{ emptyText }}
19
+ </td>
20
+ </tr>
21
+ <tr v-for="(row, index) in data" :key="row.id || index" class="table-row">
22
+ <td v-for="col in columns" :key="col.key" class="table-cell">
23
+ <slot :name="col.key" :row="row" :value="row[col.key]">
24
+ {{ row[col.key] }}
25
+ </slot>
26
+ </td>
27
+ </tr>
28
+ </tbody>
29
+ </table>
30
+ <!-- Loading overlay -->
31
+ <div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-bg-elevated/60 backdrop-blur-[1px] z-10">
32
+ <div class="flex items-center space-x-2 text-text-secondary">
33
+ <div class="spinner"></div>
34
+ <span class="text-sm">加载中...</span>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ interface Column {
42
+ key: string
43
+ title: string
44
+ }
45
+
46
+ defineProps<{
47
+ columns: Column[]
48
+ data: any[]
49
+ loading?: boolean
50
+ emptyText?: string
51
+ }>()
52
+ </script>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <div>
3
+ <input
4
+ type="file"
5
+ multiple
6
+ :accept="acceptTypes"
7
+ @change="handleFileSelect"
8
+ class="hidden"
9
+ ref="fileInput"
10
+ />
11
+
12
+ <button @click="triggerFileInput" class="btn-primary btn-sm inline-flex items-center gap-1">
13
+ 📎 选择文件
14
+ </button>
15
+
16
+ <div v-if="uploadQueue.length > 0" class="mt-3 space-y-2">
17
+ <div v-for="item in uploadQueue" :key="item.id" class="bg-bg-secondary rounded-lg p-3">
18
+ <div class="flex justify-between text-sm mb-1">
19
+ <span class="text-text-primary font-medium">{{ item.name }}</span>
20
+ <span class="text-text-tertiary">{{ formatSize(item.size) }}</span>
21
+ </div>
22
+
23
+ <div class="h-2 bg-bg-tertiary rounded-full overflow-hidden">
24
+ <div
25
+ class="h-full rounded-full transition-all duration-300"
26
+ :class="item.status === 'error' ? 'bg-accent-danger' : 'bg-accent-primary'"
27
+ :style="{ width: item.progress + '%' }"
28
+ ></div>
29
+ </div>
30
+
31
+ <div class="text-xs mt-1">
32
+ <span v-if="item.status === 'uploading'" class="text-accent-primary">
33
+ 上传中 {{ item.progress }}%
34
+ </span>
35
+ <span v-else-if="item.status === 'success'" class="text-accent-success">✓ 完成</span>
36
+ <span v-else-if="item.status === 'error'" class="text-accent-danger">✗ {{ item.error }}</span>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { ref, reactive } from 'vue'
45
+
46
+ interface UploadItem {
47
+ id: string
48
+ name: string
49
+ size: number
50
+ progress: number
51
+ status: 'pending' | 'uploading' | 'success' | 'error'
52
+ error?: string
53
+ }
54
+
55
+ const props = withDefaults(defineProps<{
56
+ uploadFile: (file: File, onProgress: (percent: number) => void) => Promise<void>
57
+ acceptTypes?: string
58
+ }>(), {
59
+ acceptTypes: 'image/*,video/*'
60
+ })
61
+
62
+ const emit = defineEmits<{
63
+ uploaded: []
64
+ }>()
65
+
66
+ const fileInput = ref<HTMLInputElement | null>(null)
67
+ const uploadQueue = ref<UploadItem[]>([])
68
+
69
+ function triggerFileInput() {
70
+ fileInput.value?.click()
71
+ }
72
+
73
+ function formatSize(bytes: number): string {
74
+ if (bytes < 1024) return bytes + ' B'
75
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
76
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
77
+ return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
78
+ }
79
+
80
+ async function handleFileSelect(event: Event) {
81
+ const input = event.target as HTMLInputElement
82
+ const files = input.files
83
+ if (!files || files.length === 0) return
84
+
85
+ for (const file of Array.from(files)) {
86
+ const item = reactive<UploadItem>({
87
+ id: Math.random().toString(36).substring(2),
88
+ name: file.name,
89
+ size: file.size,
90
+ progress: 0,
91
+ status: 'uploading'
92
+ })
93
+
94
+ uploadQueue.value.push(item)
95
+
96
+ try {
97
+ await props.uploadFile(file, (percent) => {
98
+ item.progress = percent
99
+ })
100
+ item.status = 'success'
101
+ emit('uploaded')
102
+
103
+ setTimeout(() => {
104
+ const index = uploadQueue.value.findIndex(i => i.id === item.id)
105
+ if (index !== -1) {
106
+ uploadQueue.value.splice(index, 1)
107
+ }
108
+ }, 1500)
109
+ } catch (error: any) {
110
+ item.status = 'error'
111
+ item.error = error.message || '上传失败'
112
+ }
113
+ }
114
+
115
+ input.value = ''
116
+ }
117
+ </script>
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <select
3
+ :value="modelValue"
4
+ class="select"
5
+ @change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
6
+ >
7
+ <option v-if="showAll" value="">{{ placeholder || '全部' }}</option>
8
+ <option v-for="opt in options" :key="opt.value" :value="opt.value">
9
+ {{ opt.label }}
10
+ </option>
11
+ </select>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ withDefaults(defineProps<{
16
+ modelValue: string
17
+ options: Array<{ value: string; label: string }>
18
+ placeholder?: string
19
+ showAll?: boolean
20
+ }>(), {
21
+ showAll: true
22
+ })
23
+
24
+ defineEmits<{
25
+ 'update:modelValue': [value: string]
26
+ }>()
27
+ </script>
@@ -0,0 +1,69 @@
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="cancel" />
6
+ <div class="dialog" :class="width || 'max-w-lg'">
7
+ <div 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 class="text-text-tertiary hover:text-text-primary" @click="cancel">&times;</button>
10
+ </div>
11
+ <div class="px-6 py-4">
12
+ <slot />
13
+ </div>
14
+ <div class="dialog-actions px-6 py-4 border-t border-border-primary bg-bg-secondary rounded-b-2xl">
15
+ <button
16
+ class="btn-secondary"
17
+ @click="cancel"
18
+ >
19
+ 取消
20
+ </button>
21
+ <button
22
+ :disabled="loading"
23
+ class="btn-primary"
24
+ @click="confirm"
25
+ >
26
+ {{ loading ? '提交中...' : (confirmText || '确认') }}
27
+ </button>
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </Transition>
32
+ </Teleport>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ defineProps<{
37
+ visible: boolean
38
+ title: string
39
+ loading?: boolean
40
+ confirmText?: string
41
+ width?: string
42
+ }>()
43
+
44
+ const emit = defineEmits<{
45
+ confirm: []
46
+ cancel: []
47
+ 'update:visible': [value: boolean]
48
+ }>()
49
+
50
+ function confirm() {
51
+ emit('confirm')
52
+ }
53
+
54
+ function cancel() {
55
+ emit('cancel')
56
+ emit('update:visible', false)
57
+ }
58
+ </script>
59
+
60
+ <style scoped>
61
+ .fade-enter-active,
62
+ .fade-leave-active {
63
+ transition: opacity 0.2s ease;
64
+ }
65
+ .fade-enter-from,
66
+ .fade-leave-to {
67
+ opacity: 0;
68
+ }
69
+ </style>
@@ -0,0 +1,62 @@
1
+ <template>
2
+ <div class="flex items-center justify-between px-6 py-3 bg-bg-elevated border-t border-border-primary">
3
+ <div class="text-sm text-text-secondary">
4
+ 共 {{ total }} 条记录
5
+ </div>
6
+ <div class="flex items-center space-x-2">
7
+ <button
8
+ :disabled="page <= 1"
9
+ class="btn-secondary btn-sm disabled:opacity-50"
10
+ @click="changePage(page - 1)"
11
+ >
12
+ 上一页
13
+ </button>
14
+ <span class="text-sm text-text-primary">{{ page }} / {{ totalPages }}</span>
15
+ <button
16
+ :disabled="page >= totalPages"
17
+ class="btn-secondary btn-sm disabled:opacity-50"
18
+ @click="changePage(page + 1)"
19
+ >
20
+ 下一页
21
+ </button>
22
+ <select
23
+ :value="pageSize"
24
+ class="select text-sm py-1"
25
+ @change="changePageSize(Number(($event.target as HTMLSelectElement).value))"
26
+ >
27
+ <option :value="10">10条/页</option>
28
+ <option :value="20">20条/页</option>
29
+ <option :value="50">50条/页</option>
30
+ </select>
31
+ </div>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue'
37
+
38
+ const props = defineProps<{
39
+ page: number
40
+ pageSize: number
41
+ total: number
42
+ }>()
43
+
44
+ const emit = defineEmits<{
45
+ 'update:page': [page: number]
46
+ 'update:pageSize': [size: number]
47
+ change: [page: number, pageSize: number]
48
+ }>()
49
+
50
+ const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
51
+
52
+ function changePage(p: number) {
53
+ emit('update:page', p)
54
+ emit('change', p, props.pageSize)
55
+ }
56
+
57
+ function changePageSize(size: number) {
58
+ emit('update:pageSize', size)
59
+ emit('update:page', 1)
60
+ emit('change', 1, size)
61
+ }
62
+ </script>
@@ -0,0 +1,44 @@
1
+ <template>
2
+ <div class="relative">
3
+ <input
4
+ :value="modelValue"
5
+ type="text"
6
+ :placeholder="placeholder"
7
+ class="search-input w-full"
8
+ @input="onInput"
9
+ />
10
+ <button
11
+ v-if="modelValue"
12
+ class="absolute right-3 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-primary"
13
+ @click="clear"
14
+ >&times;</button>
15
+ </div>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ defineProps<{
20
+ modelValue: string
21
+ placeholder?: string
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ 'update:modelValue': [value: string]
26
+ search: [value: string]
27
+ }>()
28
+
29
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
30
+
31
+ function onInput(e: Event) {
32
+ const value = (e.target as HTMLInputElement).value
33
+ emit('update:modelValue', value)
34
+ if (debounceTimer) clearTimeout(debounceTimer)
35
+ debounceTimer = setTimeout(() => {
36
+ emit('search', value)
37
+ }, 300)
38
+ }
39
+
40
+ function clear() {
41
+ emit('update:modelValue', '')
42
+ emit('search', '')
43
+ }
44
+ </script>
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <aside class="sidebar" style="width: 200px;">
3
+ <nav class="mt-3">
4
+ <router-link
5
+ to="/"
6
+ class="sidebar-item text-xs"
7
+ exact-active-class="sidebar-item-active"
8
+ >
9
+ <span class="w-5 text-center text-sm">🏠</span>
10
+ <span class="ml-2">首页</span>
11
+ </router-link>
12
+
13
+ <template v-for="group in menuGroups" :key="group.key">
14
+ <div class="sidebar-section-title text-[10px]">{{ group.label }}</div>
15
+ <router-link
16
+ v-for="item in group.items"
17
+ :key="item.path"
18
+ :to="item.path"
19
+ class="sidebar-item text-xs"
20
+ active-class="sidebar-item-active"
21
+ >
22
+ <span class="w-5 text-center text-sm">{{ item.icon }}</span>
23
+ <span class="ml-2">{{ item.label }}</span>
24
+ </router-link>
25
+ </template>
26
+ </nav>
27
+ </aside>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import type { MenuGroup } from '../types'
32
+
33
+ withDefaults(defineProps<{
34
+ menuGroups: MenuGroup[]
35
+ }>(), {})
36
+ </script>
@@ -0,0 +1,32 @@
1
+ <template>
2
+ <span class="badge" :class="cls">
3
+ {{ label }}
4
+ </span>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { computed } from 'vue'
9
+
10
+ const props = withDefaults(defineProps<{
11
+ status: string | number
12
+ statusMap?: Record<string | number, { label: string; cls: string }>
13
+ }>(), {
14
+ statusMap: () => ({})
15
+ })
16
+
17
+ const defaultMap: Record<string, { label: string; cls: string }> = {
18
+ active: { label: '正常', cls: 'badge-success' },
19
+ locked: { label: '已锁定', cls: 'badge-danger' },
20
+ deleted: { label: '已删除', cls: 'badge-danger' },
21
+ '0': { label: '已停用', cls: 'badge-warning' },
22
+ '1': { label: '正常', cls: 'badge-success' },
23
+ }
24
+
25
+ const entry = computed(() => {
26
+ const key = String(props.status)
27
+ return props.statusMap[key] || props.statusMap[props.status] || defaultMap[key] || { label: key, cls: 'badge-danger' }
28
+ })
29
+
30
+ const label = computed(() => entry.value.label)
31
+ const cls = computed(() => entry.value.cls)
32
+ </script>
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div class="fixed top-4 right-4 z-50 space-y-2">
4
+ <TransitionGroup name="toast">
5
+ <div
6
+ v-for="toast in toasts"
7
+ :key="toast.id"
8
+ class="flex items-center p-4 rounded-lg shadow-lg min-w-[300px] border-l-4"
9
+ :class="typeClasses[toast.type]"
10
+ >
11
+ <span class="mr-2">{{ iconMap[toast.type] }}</span>
12
+ <span class="flex-1 text-text-primary">{{ toast.message }}</span>
13
+ <button @click="remove(toast.id)" class="ml-2 text-text-tertiary hover:text-text-primary">&times;</button>
14
+ </div>
15
+ </TransitionGroup>
16
+ </div>
17
+ </Teleport>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { useToast } from '../composables/useToast'
22
+ import type { ToastType } from '../types'
23
+
24
+ const { toasts, remove } = useToast()
25
+
26
+ const typeClasses: Record<ToastType, string> = {
27
+ success: 'bg-bg-elevated border-accent-success',
28
+ error: 'bg-bg-elevated border-accent-danger',
29
+ warning: 'bg-bg-elevated border-accent-warning',
30
+ info: 'bg-bg-elevated border-accent-info'
31
+ }
32
+
33
+ const iconMap: Record<ToastType, string> = {
34
+ success: '✅',
35
+ error: '❌',
36
+ warning: '⚠️',
37
+ info: 'ℹ️'
38
+ }
39
+ </script>
40
+
41
+ <style scoped>
42
+ .toast-enter-active,
43
+ .toast-leave-active {
44
+ transition: all 0.3s ease;
45
+ }
46
+ .toast-enter-from {
47
+ opacity: 0;
48
+ transform: translateX(100%);
49
+ }
50
+ .toast-leave-to {
51
+ opacity: 0;
52
+ transform: translateX(100%);
53
+ }
54
+ </style>
@@ -0,0 +1,159 @@
1
+ <template>
2
+ <header class="navbar">
3
+ <div class="flex items-center gap-3">
4
+ <slot name="brand">
5
+ <div class="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-primary to-accent-secondary flex items-center justify-center">
6
+ <svg class="w-5 h-5 text-text-inverse" viewBox="0 0 24 24" fill="currentColor">
7
+ <path d="M12 2L2 7l10 5 10-5-10-5z"/>
8
+ </svg>
9
+ </div>
10
+ <span class="navbar-brand">{{ title }}</span>
11
+ </slot>
12
+ <span v-if="tenantLabel" class="text-xs text-text-tertiary">| {{ tenantLabel }}</span>
13
+ </div>
14
+
15
+ <nav v-if="modules.length > 0" class="flex items-center gap-1">
16
+ <router-link
17
+ v-for="mod in modules"
18
+ :key="mod.key"
19
+ :to="mod.href"
20
+ class="px-3 py-1.5 text-sm rounded-t-md transition-all duration-150 border-b-2"
21
+ :class="activeModule === mod.key
22
+ ? 'text-accent-primary border-accent-primary font-medium'
23
+ : 'text-text-secondary hover:text-text-primary border-transparent'"
24
+ >
25
+ {{ mod.label }}
26
+ </router-link>
27
+ </nav>
28
+
29
+ <div class="navbar-user">
30
+ <!-- 主题切换 -->
31
+ <div class="relative" ref="themeDropdownRef">
32
+ <button
33
+ @click="showThemeDropdown = !showThemeDropdown"
34
+ :title="`当前主题: ${themeStore.currentThemeInfo?.label}`"
35
+ class="btn-icon text-text-secondary hover:text-text-primary"
36
+ >
37
+ {{ themeIcon }}
38
+ </button>
39
+
40
+ <Transition name="dropdown">
41
+ <div v-if="showThemeDropdown" class="absolute right-0 mt-2 w-40 card shadow-lg z-50 py-1">
42
+ <button
43
+ v-for="t in themeStore.themeList"
44
+ :key="t.mode"
45
+ class="w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-bg-tertiary"
46
+ :class="themeStore.theme === t.mode ? 'text-accent-primary' : 'text-text-secondary'"
47
+ @click="selectTheme(t.mode)"
48
+ >
49
+ <span>{{ t.icon }}</span>
50
+ <span>{{ t.label }}</span>
51
+ </button>
52
+ </div>
53
+ </Transition>
54
+ </div>
55
+
56
+ <!-- 用户头像下拉 -->
57
+ <div class="relative" ref="userDropdownRef">
58
+ <button
59
+ class="flex items-center gap-2 p-1 rounded-lg hover:bg-bg-tertiary transition-colors"
60
+ @click="showUserDropdown = !showUserDropdown"
61
+ >
62
+ <div class="navbar-avatar">{{ avatarInitial }}</div>
63
+ <svg class="w-3 h-3 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
64
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
65
+ </svg>
66
+ </button>
67
+
68
+ <Transition name="dropdown">
69
+ <div v-if="showUserDropdown" class="absolute right-0 mt-2 w-48 card shadow-lg z-50 py-1">
70
+ <template v-for="(item, idx) in userDropdownItems" :key="idx">
71
+ <!-- 用户信息区 -->
72
+ <div v-if="item.type === 'user-info'" class="px-4 py-3 border-b border-border-primary">
73
+ <div class="text-sm font-medium text-text-primary">{{ item.label }}</div>
74
+ <div v-if="item.subtitle" class="text-xs text-text-tertiary mt-1">{{ item.subtitle }}</div>
75
+ </div>
76
+ <!-- 分割线 -->
77
+ <div v-else-if="item.type === 'divider'" class="border-t border-border-primary my-1" />
78
+ <!-- 普通菜单项 -->
79
+ <button
80
+ v-else
81
+ class="w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-bg-tertiary"
82
+ :class="item.danger ? 'text-accent-danger' : 'text-text-secondary'"
83
+ @click="handleItemClick(item)"
84
+ >
85
+ <span v-if="item.icon">{{ item.icon }}</span>
86
+ <span>{{ item.label }}</span>
87
+ </button>
88
+ </template>
89
+ </div>
90
+ </Transition>
91
+ </div>
92
+ </div>
93
+ </header>
94
+ </template>
95
+
96
+ <script setup lang="ts">
97
+ import { computed, ref, onMounted, onUnmounted } from 'vue'
98
+ import { useThemeStore } from '@aquiferre/theme-kit'
99
+ import type { NavModule, DropdownItem } from '../types'
100
+
101
+ const props = withDefaults(defineProps<{
102
+ title?: string
103
+ avatarText?: string
104
+ tenantLabel?: string
105
+ modules?: NavModule[]
106
+ activeModule?: string
107
+ userDropdownItems?: DropdownItem[]
108
+ }>(), {
109
+ title: '医疗设备智能平台',
110
+ avatarText: 'U',
111
+ tenantLabel: '',
112
+ modules: () => [],
113
+ activeModule: '',
114
+ userDropdownItems: () => []
115
+ })
116
+
117
+ const themeStore = useThemeStore()
118
+ const showUserDropdown = ref(false)
119
+ const showThemeDropdown = ref(false)
120
+ const userDropdownRef = ref<HTMLElement | null>(null)
121
+ const themeDropdownRef = ref<HTMLElement | null>(null)
122
+
123
+ const themeIcon = computed(() => themeStore.currentThemeInfo?.icon || '🎨')
124
+ const avatarInitial = computed(() => props.avatarText.charAt(0).toUpperCase())
125
+
126
+ function selectTheme(mode: 'light' | 'dark' | 'neutral') {
127
+ themeStore.setTheme(mode)
128
+ showThemeDropdown.value = false
129
+ }
130
+
131
+ function handleItemClick(item: DropdownItem) {
132
+ if (item.onClick) item.onClick()
133
+ showUserDropdown.value = false
134
+ }
135
+
136
+ function handleClickOutside(event: MouseEvent) {
137
+ if (userDropdownRef.value && !userDropdownRef.value.contains(event.target as Node)) {
138
+ showUserDropdown.value = false
139
+ }
140
+ if (themeDropdownRef.value && !themeDropdownRef.value.contains(event.target as Node)) {
141
+ showThemeDropdown.value = false
142
+ }
143
+ }
144
+
145
+ onMounted(() => document.addEventListener('click', handleClickOutside))
146
+ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
147
+ </script>
148
+
149
+ <style scoped>
150
+ .dropdown-enter-active,
151
+ .dropdown-leave-active {
152
+ transition: all 0.15s ease;
153
+ }
154
+ .dropdown-enter-from,
155
+ .dropdown-leave-to {
156
+ opacity: 0;
157
+ transform: translateY(-4px);
158
+ }
159
+ </style>
@@ -0,0 +1,98 @@
1
+ <template>
2
+ <div class="user-picker">
3
+ <div class="mb-3">
4
+ <input
5
+ class="input w-full"
6
+ v-model="keyword"
7
+ placeholder="搜索用户..."
8
+ @input="handleSearch"
9
+ />
10
+ </div>
11
+
12
+ <div v-if="loading" class="text-center py-4 text-text-secondary">
13
+ <div class="spinner mx-auto mb-2"></div>
14
+ <p>加载中...</p>
15
+ </div>
16
+
17
+ <div v-else-if="users.length > 0" class="user-list space-y-2 max-h-64 overflow-y-auto">
18
+ <div
19
+ v-for="user in users"
20
+ :key="user.id"
21
+ class="user-item p-3 rounded-lg border-2 cursor-pointer transition-all"
22
+ :class="selectedUser?.id === user.id ? 'border-accent-primary bg-accent-primary/10' : 'border-bg-tertiary hover:border-accent-primary/50'"
23
+ @click="selectUser(user)"
24
+ >
25
+ <div class="flex items-center justify-between">
26
+ <div>
27
+ <p class="font-medium text-text-primary">{{ user.displayName || user.username }}</p>
28
+ <p class="text-xs text-text-tertiary">{{ user.email }}</p>
29
+ <p v-if="user.phone" class="text-xs text-text-tertiary">{{ user.phone }}</p>
30
+ </div>
31
+ <div v-if="selectedUser?.id === user.id" class="text-accent-primary">
32
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
33
+ <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
34
+ </svg>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <div v-else class="text-center py-4 text-text-tertiary">
41
+ <p>未找到符合条件的用户</p>
42
+ </div>
43
+ </div>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import { ref, onMounted } from 'vue'
48
+
49
+ interface UserBasicInfo {
50
+ id: string
51
+ username: string
52
+ displayName: string
53
+ email: string
54
+ phone: string
55
+ }
56
+
57
+ const props = defineProps<{
58
+ fetchUsers: (keyword?: string) => Promise<UserBasicInfo[]>
59
+ }>()
60
+
61
+ const emit = defineEmits<{
62
+ select: [user: UserBasicInfo]
63
+ }>()
64
+
65
+ const users = ref<UserBasicInfo[]>([])
66
+ const loading = ref(false)
67
+ const keyword = ref('')
68
+ const selectedUser = ref<UserBasicInfo | null>(null)
69
+
70
+ let searchTimeout: ReturnType<typeof setTimeout> | null = null
71
+
72
+ async function loadUsers() {
73
+ loading.value = true
74
+ try {
75
+ users.value = await props.fetchUsers(keyword.value || undefined)
76
+ } catch {
77
+ users.value = []
78
+ } finally {
79
+ loading.value = false
80
+ }
81
+ }
82
+
83
+ function handleSearch() {
84
+ if (searchTimeout) clearTimeout(searchTimeout)
85
+ searchTimeout = setTimeout(() => {
86
+ loadUsers()
87
+ }, 300)
88
+ }
89
+
90
+ function selectUser(user: UserBasicInfo) {
91
+ selectedUser.value = user
92
+ emit('select', user)
93
+ }
94
+
95
+ onMounted(() => {
96
+ loadUsers()
97
+ })
98
+ </script>
@@ -0,0 +1,32 @@
1
+ import { ref } from 'vue'
2
+ import type { ToastType } from '../types'
3
+
4
+ interface ToastItem {
5
+ id: number
6
+ type: ToastType
7
+ message: string
8
+ }
9
+
10
+ const toasts = ref<ToastItem[]>([])
11
+ let nextId = 0
12
+
13
+ function add(type: ToastType, message: string) {
14
+ const id = nextId++
15
+ toasts.value.push({ id, type, message })
16
+ setTimeout(() => remove(id), 3000)
17
+ }
18
+
19
+ function remove(id: number) {
20
+ toasts.value = toasts.value.filter(t => t.id !== id)
21
+ }
22
+
23
+ export function useToast() {
24
+ return {
25
+ toasts,
26
+ success: (msg: string) => add('success', msg),
27
+ error: (msg: string) => add('error', msg),
28
+ warning: (msg: string) => add('warning', msg),
29
+ info: (msg: string) => add('info', msg),
30
+ remove
31
+ }
32
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@aquiferre/ui-kit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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"],
10
+ "files": ["*.ts", "*.vue", "components/**/*.vue", "composables/**/*.ts"],
11
11
  "peerDependencies": {
12
12
  "vue": ">=3.0.0",
13
13
  "pinia": ">=2.0.0",