@aquiferre/ui-kit 0.1.0 → 0.1.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.
@@ -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="panel-glass 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/50 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="panel-glass" :class="[width || 'max-w-lg', 'dialog']">
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 text-2xl leading-none" @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/50 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,111 @@
1
+ <template>
2
+ <div class="flex items-center justify-between px-6 py-2">
3
+ <div class="text-xs text-text-tertiary">
4
+ 共 {{ total }} 条,第 {{ startItem }} - {{ endItem }} 条
5
+ </div>
6
+ <div class="flex items-center space-x-1">
7
+ <button
8
+ :disabled="page <= 1"
9
+ class="btn-glass btn-sm disabled:opacity-50"
10
+ @click="changePage(page - 1)"
11
+ >
12
+ ← 上一页
13
+ </button>
14
+ <template v-for="p in visiblePages" :key="p">
15
+ <span v-if="p === '...'" class="btn-glass btn-sm pointer-events-none">…</span>
16
+ <button
17
+ v-else
18
+ :class="p === page ? 'pagination-btn-active btn-sm' : 'btn-glass btn-sm'"
19
+ @click="changePage(p as number)"
20
+ >
21
+ {{ p }}
22
+ </button>
23
+ </template>
24
+ <button
25
+ :disabled="page >= totalPages"
26
+ class="btn-glass btn-sm disabled:opacity-50"
27
+ @click="changePage(page + 1)"
28
+ >
29
+ 下一页 →
30
+ </button>
31
+ <select
32
+ :value="pageSize"
33
+ class="select ml-2"
34
+ @change="changePageSize(Number(($event.target as HTMLSelectElement).value))"
35
+ >
36
+ <option :value="10">10条/页</option>
37
+ <option :value="20">20条/页</option>
38
+ <option :value="50">50条/页</option>
39
+ </select>
40
+ </div>
41
+ </div>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ // @ts-nocheck
46
+ import { computed } from 'vue'
47
+
48
+ const props = defineProps<{
49
+ page: number
50
+ pageSize: number
51
+ total: number
52
+ }>()
53
+
54
+ const emit = defineEmits<{
55
+ 'update:page': [page: number]
56
+ 'update:pageSize': [size: number]
57
+ change: [page: number, pageSize: number]
58
+ }>()
59
+
60
+ const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
61
+
62
+ const startItem = computed(() => (props.page - 1) * props.pageSize + 1)
63
+ const endItem = computed(() => Math.min(props.page * props.pageSize, props.total))
64
+
65
+ /**
66
+ * 生成可见页码数组,当前页 ±2 范围内显示,其余用 '...' 省略
67
+ * 始终显示首页和末页
68
+ */
69
+ const visiblePages = computed(() => {
70
+ const total = totalPages.value
71
+ const current = props.page
72
+ const delta = 2
73
+ const pages: (number | string)[] = []
74
+
75
+ if (total <= 7) {
76
+ for (let i = 1; i <= total; i++) pages.push(i)
77
+ return pages
78
+ }
79
+
80
+ // Always show first page
81
+ pages.push(1)
82
+
83
+ const rangeStart = Math.max(2, current - delta)
84
+ const rangeEnd = Math.min(total - 1, current + delta)
85
+
86
+ if (rangeStart > 2) pages.push('...')
87
+
88
+ for (let i = rangeStart; i <= rangeEnd; i++) {
89
+ pages.push(i)
90
+ }
91
+
92
+ if (rangeEnd < total - 1) pages.push('...')
93
+
94
+ // Always show last page
95
+ pages.push(total)
96
+
97
+ return pages
98
+ })
99
+
100
+ function changePage(p: number) {
101
+ if (p < 1 || p > totalPages.value) return
102
+ emit('update:page', p)
103
+ emit('change', p, props.pageSize)
104
+ }
105
+
106
+ function changePageSize(size: number) {
107
+ emit('update:pageSize', size)
108
+ emit('update:page', 1)
109
+ emit('change', 1, size)
110
+ }
111
+ </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-accent-primary 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
+
14
+ <nav v-if="modules.length > 0" class="flex items-center gap-1 ml-4">
15
+ <router-link
16
+ v-for="mod in modules"
17
+ :key="mod.key"
18
+ :to="mod.href"
19
+ class="px-3 py-1.5 text-sm rounded-t-md transition-all duration-150 border-b-2"
20
+ :class="activeModule === mod.key
21
+ ? 'font-medium border-accent-primary'
22
+ : 'text-text-secondary hover:text-text-primary border-transparent'"
23
+ >
24
+ {{ mod.label }}
25
+ </router-link>
26
+ </nav>
27
+ </div>
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.3",
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",
package/types.ts CHANGED
@@ -23,6 +23,7 @@ export interface NavModule {
23
23
  href: string
24
24
  paths: string[]
25
25
  permissions: string[]
26
+ superAdminOnly?: boolean
26
27
  }
27
28
 
28
29
  export interface DropdownItem {