@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.
- package/components/Breadcrumb.vue +37 -0
- package/components/ConfirmDialog.vue +68 -0
- package/components/DataTable.vue +52 -0
- package/components/FileUploader.vue +117 -0
- package/components/FilterDropdown.vue +27 -0
- package/components/FormDialog.vue +69 -0
- package/components/Pagination.vue +62 -0
- package/components/SearchInput.vue +44 -0
- package/components/SideMenu.vue +36 -0
- package/components/StatusTag.vue +32 -0
- package/components/Toast.vue +54 -0
- package/components/TopNavbar.vue +159 -0
- package/components/UserPicker.vue +98 -0
- package/composables/useToast.ts +32 -0
- package/package.json +2 -2
|
@@ -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">×</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
|
+
>×</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">×</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.
|
|
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",
|