@usethink/cf-admin-fe 0.1.0

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,271 @@
1
+ <template>
2
+ <nav class="admin-pagination" :aria-label="$t('pagination.listLabel')">
3
+ <div class="pagination-left">
4
+ <span class="pagination-info">{{ $t('pagination.total', { total }) }}</span>
5
+ </div>
6
+ <div class="pagination-right">
7
+ <!-- 每页条数选择器 -->
8
+ <label class="pagination-limit">
9
+ {{ $t('pagination.perPage') }}
10
+ <select
11
+ :value="limit"
12
+ class="limit-select"
13
+ :disabled="disabled"
14
+ @change="onLimitChange"
15
+ >
16
+ <option :value="10">10</option>
17
+ <option :value="20">20</option>
18
+ <option :value="50">50</option>
19
+ </select>
20
+ {{ $t('pagination.perPageUnit') }}
21
+ </label>
22
+
23
+ <!-- 上下翻页 -->
24
+ <div class="pagination-controls">
25
+ <button
26
+ type="button"
27
+ class="btn btn-ghost btn-xs"
28
+ :disabled="disabled || page <= 1"
29
+ :aria-label="$t('pagination.prevLabel')"
30
+ @click="$emit('prev')"
31
+ >
32
+ {{ $t('pagination.prev') }}
33
+ </button>
34
+ <span v-if="cursorMode" class="pagination-page" aria-live="polite">{{ $t('pagination.page', { page }) }}</span>
35
+ <span v-else class="pagination-page" aria-live="polite">{{ $t('pagination.pageOf', { page, total: totalPages }) }}</span>
36
+ <button
37
+ type="button"
38
+ class="btn btn-ghost btn-xs"
39
+ :disabled="disabled || (cursorMode ? !hasMore : page >= totalPages)"
40
+ :aria-label="$t('pagination.nextLabel')"
41
+ @click="$emit('next')"
42
+ >
43
+ {{ $t('pagination.next') }}
44
+ </button>
45
+ </div>
46
+
47
+ <!-- 页码跳转 -->
48
+ <div v-if="!cursorMode" class="pagination-jump">
49
+ {{ $t('pagination.jumpTo') }}
50
+ <input
51
+ type="number"
52
+ :min="1"
53
+ :max="totalPages"
54
+ :step="1"
55
+ inputmode="numeric"
56
+ class="jump-input"
57
+ :disabled="disabled"
58
+ :aria-label="$t('pagination.jumpLabel')"
59
+ v-model.number="jumpValue"
60
+ @keyup.enter="handleJump"
61
+ />
62
+ {{ $t('pagination.jumpPage') }}
63
+ <button type="button" class="btn btn-ghost btn-xs" @click="handleJump" :disabled="disabled || !jumpValue">{{ $t('pagination.jump') }}</button>
64
+ </div>
65
+ </div>
66
+ </nav>
67
+ </template>
68
+
69
+ <script setup lang="ts">
70
+ import { ref, watch } from 'vue'
71
+
72
+ const props = defineProps<{
73
+ page: number
74
+ total: number
75
+ totalPages: number
76
+ limit?: number
77
+ cursorMode?: boolean
78
+ hasMore?: boolean
79
+ disabled?: boolean
80
+ }>()
81
+
82
+ const emit = defineEmits<{
83
+ prev: []
84
+ next: []
85
+ jump: [page: number]
86
+ 'update:limit': [limit: number]
87
+ }>()
88
+
89
+ /** 跳转输入框的当前值,翻页后自动清空 */
90
+ const jumpValue = ref<number | undefined>(undefined)
91
+
92
+ function onLimitChange(e: Event) {
93
+ const val = Number((e.target as HTMLSelectElement).value)
94
+ if ([10, 20, 50].includes(val)) emit('update:limit', val)
95
+ }
96
+
97
+ function handleJump() {
98
+ const p = Number(jumpValue.value)
99
+ if (!Number.isFinite(p) || p <= 0) {
100
+ jumpValue.value = undefined
101
+ return
102
+ }
103
+ const clamped = Math.max(1, Math.min(Math.trunc(p), props.totalPages))
104
+ if (clamped !== props.page) {
105
+ emit('jump', clamped)
106
+ }
107
+ jumpValue.value = undefined
108
+ }
109
+
110
+ // 翻页后清空跳转输入
111
+ watch(() => props.page, () => {
112
+ jumpValue.value = undefined
113
+ })
114
+ </script>
115
+
116
+ <style scoped>
117
+ /*
118
+ * 分页条紧凑:全局 select 默认 min-height:34px,会把整行撑高。
119
+ * 本组件控件统一 --admin-control-height-sm(26px)+ 上下 padding 0。
120
+ */
121
+ .admin-pagination {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: space-between;
125
+ gap: var(--admin-inline-gap, 8px);
126
+ flex-wrap: wrap;
127
+ background: var(--tg-secondary-bg, #151b28);
128
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
129
+ border-radius: var(--r-md, 8px);
130
+ /* 行高由 26px 控件决定;条内边距只留 4px */
131
+ padding: 4px 10px;
132
+ margin: 0;
133
+ flex-shrink: 0;
134
+ min-height: 0;
135
+ line-height: 1.2;
136
+ }
137
+
138
+ .pagination-info {
139
+ font-size: 12px;
140
+ color: var(--tg-hint, #9aa4b2);
141
+ line-height: 1.2;
142
+ }
143
+
144
+ .pagination-right {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: var(--admin-inline-gap, 8px);
148
+ flex-wrap: wrap;
149
+ }
150
+
151
+ /* 每页条数 */
152
+ .pagination-limit {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 4px;
156
+ font-size: 12px;
157
+ color: var(--tg-hint, #9aa4b2);
158
+ white-space: nowrap;
159
+ line-height: 1.2;
160
+ }
161
+
162
+ /*
163
+ * 覆盖 base.css select { min-height: 34px; line-height: 32px }。
164
+ * 固定 height + 上下 padding:0 + line-height = height - border,与工具栏铁律相同、高度更矮。
165
+ */
166
+ .limit-select {
167
+ box-sizing: border-box;
168
+ height: var(--admin-control-height-sm, 26px);
169
+ min-height: var(--admin-control-height-sm, 26px);
170
+ max-height: var(--admin-control-height-sm, 26px);
171
+ padding: 0 22px 0 8px;
172
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
173
+ border-radius: var(--r-sm, 6px);
174
+ background-color: var(--tg-secondary-bg, #151b28);
175
+ color: var(--tg-text, #f0f2f5);
176
+ -webkit-text-fill-color: var(--tg-text, #f0f2f5);
177
+ font-size: 12px;
178
+ line-height: 24px;
179
+ outline: none;
180
+ cursor: pointer;
181
+ appearance: none;
182
+ -webkit-appearance: none;
183
+ color-scheme: dark;
184
+ vertical-align: middle;
185
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
186
+ background-repeat: no-repeat;
187
+ background-position: right 6px center;
188
+ background-size: 10px;
189
+ }
190
+
191
+ /* 翻页控制 */
192
+ .pagination-controls {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 6px;
196
+ }
197
+
198
+ .pagination-page {
199
+ font-size: 12px;
200
+ white-space: nowrap;
201
+ color: var(--tg-text, #f0f2f5);
202
+ line-height: 1.2;
203
+ }
204
+
205
+ /* 分页内按钮与 26px 控件对齐 */
206
+ .admin-pagination :deep(.btn-xs),
207
+ .admin-pagination :deep(.btn-sm) {
208
+ box-sizing: border-box;
209
+ height: var(--admin-control-height-sm, 26px);
210
+ min-height: var(--admin-control-height-sm, 26px);
211
+ padding: 0 8px;
212
+ font-size: 12px;
213
+ line-height: 24px;
214
+ border-radius: var(--r-sm, 6px);
215
+ }
216
+
217
+ /* 跳转输入 */
218
+ .pagination-jump {
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 4px;
222
+ font-size: 12px;
223
+ color: var(--tg-hint, #9aa4b2);
224
+ white-space: nowrap;
225
+ line-height: 1.2;
226
+ }
227
+
228
+ .jump-input {
229
+ box-sizing: border-box;
230
+ width: 40px;
231
+ height: var(--admin-control-height-sm, 26px);
232
+ min-height: var(--admin-control-height-sm, 26px);
233
+ padding: 0 4px;
234
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
235
+ border-radius: var(--r-sm, 6px);
236
+ background-color: var(--tg-secondary-bg, #151b28);
237
+ color: var(--tg-text, #f0f2f5);
238
+ font-size: 12px;
239
+ line-height: 24px;
240
+ text-align: center;
241
+ outline: none;
242
+ -moz-appearance: textfield;
243
+ }
244
+
245
+ .jump-input::-webkit-inner-spin-button,
246
+ .jump-input::-webkit-outer-spin-button {
247
+ -webkit-appearance: none;
248
+ margin: 0;
249
+ }
250
+
251
+ @media (max-width: 640px) {
252
+ .admin-pagination,
253
+ .pagination-right {
254
+ align-items: stretch;
255
+ }
256
+
257
+ .pagination-right {
258
+ width: 100%;
259
+ gap: 8px;
260
+ }
261
+
262
+ .pagination-controls {
263
+ justify-content: space-between;
264
+ flex: 1;
265
+ }
266
+
267
+ .pagination-jump {
268
+ display: none;
269
+ }
270
+ }
271
+ </style>
@@ -0,0 +1,160 @@
1
+ <template>
2
+ <AdminModal v-model="innerVisible" :title="$t('confirm.confirm')" max-width="460px" hide-actions>
3
+ <p class="confirm-message">{{ message }}</p>
4
+ <div v-if="options.length > 0" class="confirm-options" role="group" :aria-label="$t('confirm.delete')">
5
+ <label
6
+ v-for="opt in options"
7
+ :key="opt.key"
8
+ class="confirm-option"
9
+ >
10
+ <input
11
+ type="checkbox"
12
+ :checked="Boolean(optionValues[opt.key])"
13
+ :disabled="loading"
14
+ @change="onOptionChange(opt.key, ($event.target as HTMLInputElement).checked)"
15
+ />
16
+ <span class="confirm-option-body">
17
+ <span class="confirm-option-label">{{ opt.label }}</span>
18
+ <span v-if="opt.hint" class="confirm-option-hint">{{ opt.hint }}</span>
19
+ </span>
20
+ </label>
21
+ </div>
22
+ <template #actions>
23
+ <button type="button" class="btn btn-ghost" :disabled="loading" @click="handleClose">{{ $t('confirm.cancel') }}</button>
24
+ <button
25
+ class="btn"
26
+ type="button"
27
+ :class="danger ? 'btn-danger' : 'btn-primary'"
28
+ :disabled="loading"
29
+ @click="handleConfirm"
30
+ >
31
+ {{ $t('confirm.confirm') }}
32
+ </button>
33
+ </template>
34
+ </AdminModal>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import { ref, watch } from 'vue'
39
+ import AdminModal from './AdminModal.vue'
40
+ import type { ConfirmOptionDef } from '../composables/useConfirmDialog'
41
+
42
+ const props = withDefaults(defineProps<{
43
+ modelValue: boolean
44
+ message: string
45
+ loading?: boolean
46
+ danger?: boolean
47
+ /** 可选勾选项定义;无则退化为纯确认框 */
48
+ options?: ConfirmOptionDef[]
49
+ /** key → 是否勾选(由 useConfirmDialog 托管) */
50
+ optionValues?: Record<string, boolean>
51
+ }>(), {
52
+ loading: false,
53
+ danger: false,
54
+ options: () => [],
55
+ optionValues: () => ({}),
56
+ })
57
+
58
+ const emit = defineEmits<{
59
+ 'update:modelValue': [value: boolean]
60
+ confirm: []
61
+ 'update:option': [key: string, checked: boolean]
62
+ }>()
63
+
64
+ const innerVisible = ref(false)
65
+ watch(() => props.modelValue, (val) => {
66
+ innerVisible.value = val
67
+ }, { immediate: true })
68
+ watch(innerVisible, (val, wasVisible) => {
69
+ if (!val && wasVisible && props.modelValue) emit('update:modelValue', false)
70
+ })
71
+
72
+ function close() {
73
+ emit('update:modelValue', false)
74
+ }
75
+
76
+ function handleClose() {
77
+ close()
78
+ }
79
+
80
+ function handleConfirm() {
81
+ if (!innerVisible.value || props.loading) return
82
+ emit('confirm')
83
+ close()
84
+ }
85
+
86
+ function onOptionChange(key: string, checked: boolean) {
87
+ emit('update:option', key, checked)
88
+ }
89
+ </script>
90
+
91
+ <style scoped>
92
+ .confirm-message {
93
+ font-size: 14px;
94
+ line-height: 1.65;
95
+ /* 支持 askConfirm(`...\n\n...`) 多段说明;否则 HTML 会把换行压成空格 */
96
+ white-space: pre-line;
97
+ color: var(--tg-text, #f0f2f5);
98
+ }
99
+
100
+ /* 选项区:与弹窗同属暗色表面体系,用 elevated surface 分层,禁止浅色底板 */
101
+ .confirm-options {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 2px;
105
+ margin-top: 6px;
106
+ padding: 6px;
107
+ border-radius: var(--r-md, 8px);
108
+ background: var(--surface-2, rgba(255, 255, 255, 0.07));
109
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
110
+ }
111
+
112
+ .confirm-option {
113
+ display: flex;
114
+ align-items: flex-start;
115
+ gap: 10px;
116
+ cursor: pointer;
117
+ font-size: 13px;
118
+ line-height: 1.45;
119
+ color: var(--tg-text);
120
+ padding: 10px;
121
+ border-radius: var(--r-sm, 6px);
122
+ transition: background-color var(--duration-fast, 150ms) var(--ease-out, ease);
123
+ }
124
+
125
+ .confirm-option:hover {
126
+ background: var(--surface-hover, rgba(255, 255, 255, 0.1));
127
+ }
128
+
129
+ .confirm-option input {
130
+ margin-top: 2px;
131
+ flex-shrink: 0;
132
+ width: 14px;
133
+ height: 14px;
134
+ padding: 0;
135
+ border: none;
136
+ background: transparent;
137
+ accent-color: var(--admin-accent, #f59e0b);
138
+ }
139
+
140
+ .confirm-option-body {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 4px;
144
+ min-width: 0;
145
+ }
146
+
147
+ .confirm-option-label {
148
+ font-weight: 600;
149
+ font-size: 13px;
150
+ color: var(--tg-text);
151
+ letter-spacing: 0.01em;
152
+ }
153
+
154
+ .confirm-option-hint {
155
+ font-size: 12px;
156
+ color: var(--tg-hint);
157
+ line-height: 1.5;
158
+ opacity: 0.95;
159
+ }
160
+ </style>
@@ -0,0 +1,120 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div class="toast-container" role="status" aria-live="polite" aria-atomic="false">
4
+ <TransitionGroup name="toast">
5
+ <div
6
+ v-for="toast in toasts"
7
+ :key="toast.id"
8
+ class="toast-item"
9
+ :class="`toast-${toast.type}`"
10
+ >
11
+ <span class="toast-icon">{{ iconMap[toast.type] }}</span>
12
+ <span class="toast-message">{{ toast.message }}</span>
13
+ </div>
14
+ </TransitionGroup>
15
+ </div>
16
+ </Teleport>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { useToast } from '../composables/useToast'
21
+
22
+ const { toasts } = useToast()
23
+
24
+ const iconMap: Record<string, string> = {
25
+ success: '✓',
26
+ error: '✕',
27
+ info: 'ℹ',
28
+ }
29
+ </script>
30
+
31
+ <style scoped>
32
+ .toast-container {
33
+ position: fixed;
34
+ top: 20px;
35
+ left: 50%;
36
+ transform: translateX(-50%);
37
+ z-index: 2000;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 8px;
41
+ pointer-events: none;
42
+ width: min(420px, calc(100% - 32px));
43
+ }
44
+
45
+ .toast-item {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: 8px;
49
+ padding: 10px 16px;
50
+ border-radius: 10px;
51
+ background: #1f2937;
52
+ color: #fff;
53
+ font-size: 14px;
54
+ line-height: 1.4;
55
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
56
+ pointer-events: auto;
57
+ }
58
+
59
+ .toast-icon {
60
+ width: 20px;
61
+ height: 20px;
62
+ display: inline-flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ border-radius: 50%;
66
+ font-size: 12px;
67
+ font-weight: 700;
68
+ flex-shrink: 0;
69
+ }
70
+
71
+ .toast-success {
72
+ border: 0.5px solid rgba(34, 197, 94, 0.5);
73
+ }
74
+
75
+ .toast-success .toast-icon {
76
+ background: rgba(34, 197, 94, 0.18);
77
+ color: #22c55e;
78
+ }
79
+
80
+ .toast-error {
81
+ border: 0.5px solid rgba(239, 68, 68, 0.5);
82
+ }
83
+
84
+ .toast-error .toast-icon {
85
+ background: rgba(239, 68, 68, 0.18);
86
+ color: #ef4444;
87
+ }
88
+
89
+ .toast-info {
90
+ border: 0.5px solid rgba(59, 130, 246, 0.5);
91
+ }
92
+
93
+ .toast-info .toast-icon {
94
+ background: rgba(59, 130, 246, 0.18);
95
+ color: #60a5fa;
96
+ }
97
+
98
+ .toast-message {
99
+ word-break: break-word;
100
+ }
101
+
102
+ .toast-enter-active,
103
+ .toast-leave-active {
104
+ transition: all 0.25s ease;
105
+ }
106
+
107
+ .toast-enter-from {
108
+ opacity: 0;
109
+ transform: translateY(-8px) scale(0.98);
110
+ }
111
+
112
+ .toast-leave-to {
113
+ opacity: 0;
114
+ transform: translateY(-8px) scale(0.98);
115
+ }
116
+
117
+ .toast-move {
118
+ transition: transform 0.25s ease;
119
+ }
120
+ </style>
@@ -0,0 +1,4 @@
1
+ export { default as AdminPagination } from './AdminPagination.vue'
2
+ export { default as AdminModal } from './AdminModal.vue'
3
+ export { default as ConfirmDialog } from './ConfirmDialog.vue'
4
+ export { default as ToastContainer } from './ToastContainer.vue'
@@ -0,0 +1,34 @@
1
+ export {
2
+ useTablePagination,
3
+ type UseTablePaginationOptions,
4
+ type UseTablePaginationReturn,
5
+ } from './useTablePagination'
6
+
7
+ export {
8
+ useTableSelection,
9
+ type UseTableSelectionOptions,
10
+ } from './useTableSelection'
11
+
12
+ export {
13
+ useConfirmDialog,
14
+ type ConfirmOptionDef,
15
+ type ConfirmAskOptions,
16
+ type ConfirmResult,
17
+ } from './useConfirmDialog'
18
+
19
+ export {
20
+ useToast,
21
+ MAX_TOASTS,
22
+ type ToastType,
23
+ type ToastItem,
24
+ } from './useToast'
25
+
26
+ export {
27
+ useAdminBatchOperation,
28
+ type AdminBatchOperationResult,
29
+ } from './useAdminBatchOperation'
30
+
31
+ export {
32
+ writeClipboardText,
33
+ copyText,
34
+ } from './useClipboard'
@@ -0,0 +1,51 @@
1
+ import { ref } from 'vue'
2
+
3
+ export interface AdminBatchOperationResult<T> {
4
+ total: number
5
+ success: number
6
+ failed: number
7
+ failedItems: T[]
8
+ }
9
+
10
+ export function useAdminBatchOperation() {
11
+ const operating = ref(false)
12
+ const completed = ref(0)
13
+ const total = ref(0)
14
+
15
+ async function runSequential<T>(items: T[], action: (item: T) => Promise<void>): Promise<AdminBatchOperationResult<T> | null> {
16
+ if (operating.value) return null
17
+ operating.value = true
18
+ completed.value = 0
19
+ total.value = items.length
20
+ const result: AdminBatchOperationResult<T> = {
21
+ total: items.length,
22
+ success: 0,
23
+ failed: 0,
24
+ failedItems: [],
25
+ }
26
+
27
+ try {
28
+ for (const item of items) {
29
+ try {
30
+ await action(item)
31
+ result.success += 1
32
+ } catch {
33
+ result.failed += 1
34
+ result.failedItems.push(item)
35
+ } finally {
36
+ completed.value += 1
37
+ }
38
+ }
39
+ return result
40
+ } finally {
41
+ operating.value = false
42
+ }
43
+ }
44
+
45
+ return {
46
+ operating,
47
+ completed,
48
+ total,
49
+ runSequential,
50
+ }
51
+ }
@@ -0,0 +1,37 @@
1
+ /** 剪贴板工具:一键复制文本到剪贴板,带按钮视觉反馈 */
2
+
3
+ const FEEDBACK_MS = 1500
4
+
5
+ /** 复制文本,更新按钮文字反馈 */
6
+ export async function writeClipboardText(text: string): Promise<void> {
7
+ if (navigator.clipboard?.writeText) {
8
+ await navigator.clipboard.writeText(text)
9
+ return
10
+ }
11
+
12
+ const textarea = document.createElement('textarea')
13
+ textarea.value = text
14
+ textarea.setAttribute('readonly', '')
15
+ textarea.style.position = 'fixed'
16
+ textarea.style.opacity = '0'
17
+ document.body.appendChild(textarea)
18
+ textarea.select()
19
+ const copied = document.execCommand('copy')
20
+ textarea.remove()
21
+ if (!copied) throw new Error('Clipboard copy failed')
22
+ }
23
+
24
+ export function copyText(text: string, e: Event) {
25
+ writeClipboardText(text).then(() => {
26
+ feedback(e, '✅')
27
+ }).catch(() => {
28
+ feedback(e, '❌')
29
+ })
30
+ }
31
+
32
+ function feedback(e: Event, icon: string) {
33
+ const btn = e.currentTarget as HTMLButtonElement
34
+ const original = btn.textContent
35
+ btn.textContent = icon
36
+ setTimeout(() => { btn.textContent = original }, FEEDBACK_MS)
37
+ }