@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.
- 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 +111 -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
- package/types.ts +1 -0
|
@@ -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">×</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
|
+
>×</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-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.
|
|
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",
|