@usethink/cf-admin-fe 0.1.1 → 0.2.8
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/LICENSE +21 -0
- package/README.md +244 -53
- package/dist/composables/createAdminFlagSession.d.ts +42 -0
- package/dist/composables/createAdminRequest.d.ts +95 -0
- package/dist/composables/createAdminTokenSession.d.ts +49 -0
- package/dist/composables/index.d.ts +7 -1
- package/dist/composables/useAdminListLoader.d.ts +45 -0
- package/dist/composables/useAdminSidebar.d.ts +17 -0
- package/dist/composables/useClipboard.d.ts +24 -2
- package/dist/composables/useConfirmDialog.d.ts +10 -4
- package/dist/composables/useNow.d.ts +3 -0
- package/dist/composables/useTablePagination.d.ts +6 -0
- package/dist/composables/useTableSelection.d.ts +2 -2
- package/dist/composables/useToast.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/system-config.d.ts +34 -0
- package/dist/utils/admin-navigation.d.ts +66 -0
- package/dist/utils/admin-query.d.ts +29 -0
- package/dist/utils/batch-limit.d.ts +38 -0
- package/dist/utils/batch-result.d.ts +41 -0
- package/dist/utils/body-scroll-lock.d.ts +13 -0
- package/dist/utils/csv-export.d.ts +8 -0
- package/dist/utils/currency-cents.d.ts +16 -0
- package/dist/utils/datetime.d.ts +20 -0
- package/dist/utils/error-message.d.ts +7 -0
- package/dist/utils/format-metadata.d.ts +5 -0
- package/dist/utils/i18n.d.ts +14 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/otp-input.d.ts +11 -0
- package/dist/utils/system-config-engine.d.ts +65 -0
- package/docs/001_cf-admin-fe/346/217/220/345/217/226/345/267/256/350/267/235/345/210/206/346/236/220/346/212/245/345/221/212_2026-08-05.md +225 -0
- package/docs/002_cf-admin-fe/344/275/277/347/224/250/350/257/264/346/230/216/344/271/246/344/270/216/346/263/250/346/204/217/344/272/213/351/241/271.md +805 -0
- package/docs/003_cf-admin-fe/346/236/201/350/207/264/345/214/226/346/226/271/346/241/210/344/270/216/345/217/226/350/210/215_2026-08-05.md +332 -0
- package/package.json +35 -6
- package/recipes/AdminLayout.vue +73 -0
- package/recipes/README.md +131 -0
- package/recipes/i18n-merge.snippet.ts +29 -0
- package/recipes/list-page.snippet.ts +141 -0
- package/recipes/main-styles.snippet.ts +13 -0
- package/recipes/reexports/components.ts +19 -0
- package/recipes/reexports/composables.ts +29 -0
- package/recipes/reexports/utils.ts +31 -0
- package/recipes/router-guard.snippet.ts +32 -0
- package/recipes/useAdminAuth.cookie.ts +29 -0
- package/recipes/useAdminAuth.shop.ts +29 -0
- package/recipes/useAdminRequest.cookie.ts +36 -0
- package/recipes/useAdminRequest.ts +37 -0
- package/src/components/AdminLoginShell.vue +31 -0
- package/src/components/AdminMetadataDetail.vue +121 -0
- package/src/components/AdminShell.vue +384 -0
- package/src/components/ConfigField.vue +387 -0
- package/src/components/ConfirmDialog.vue +67 -3
- package/src/components/index.ts +6 -0
- package/src/composables/createAdminFlagSession.ts +129 -0
- package/src/composables/createAdminRequest.ts +322 -0
- package/src/composables/createAdminTokenSession.ts +149 -0
- package/src/composables/index.ts +51 -0
- package/src/composables/useAdminBatchOperation.ts +4 -0
- package/src/composables/useAdminListLoader.ts +124 -0
- package/src/composables/useAdminSidebar.ts +68 -0
- package/src/composables/useClipboard.ts +41 -2
- package/src/composables/useConfirmDialog.ts +26 -4
- package/src/composables/useNow.ts +38 -0
- package/src/composables/useTablePagination.ts +6 -0
- package/src/composables/useTableSelection.ts +2 -2
- package/src/composables/useToast.ts +4 -1
- package/src/env.d.ts +2 -2
- package/src/i18n/index.ts +8 -0
- package/src/i18n/kit-messages.ts +297 -0
- package/src/index.ts +147 -4
- package/src/styles/admin-primitives.css +28 -1
- package/src/styles/index.css +1 -0
- package/src/styles/login-primitives.css +149 -0
- package/src/types/index.ts +7 -0
- package/src/types/system-config.ts +38 -0
- package/src/utils/admin-navigation.ts +138 -0
- package/src/utils/admin-query.ts +51 -0
- package/src/utils/batch-limit.ts +66 -0
- package/src/utils/batch-result.ts +81 -0
- package/src/utils/body-scroll-lock.ts +63 -0
- package/src/utils/csv-export.ts +25 -0
- package/src/utils/currency-cents.ts +30 -0
- package/src/utils/datetime.ts +54 -0
- package/src/utils/error-message.ts +32 -0
- package/src/utils/format-metadata.ts +11 -0
- package/src/utils/i18n.ts +63 -0
- package/src/utils/index.ts +87 -0
- package/src/utils/otp-input.ts +18 -0
- package/src/utils/system-config-engine.ts +212 -0
- package/dist/components/index.d.ts +0 -4
- package/dist/index.d.ts +0 -17
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="config-card" :class="{ 'config-card-primary': primary }">
|
|
3
|
+
<label class="config-label">
|
|
4
|
+
<span>{{ localizedLabel }}</span>
|
|
5
|
+
<input
|
|
6
|
+
v-if="item.type === 'boolean'"
|
|
7
|
+
type="checkbox"
|
|
8
|
+
:checked="value === 'true'"
|
|
9
|
+
:disabled="status === 'saving'"
|
|
10
|
+
@change="emitChange(($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
|
11
|
+
/>
|
|
12
|
+
<!-- 多选枚举(逗号分隔存储) -->
|
|
13
|
+
<div v-else-if="item.enum?.length" class="config-enum-wrap">
|
|
14
|
+
<div class="config-enum-grid" role="group" :aria-label="localizedLabel">
|
|
15
|
+
<label
|
|
16
|
+
v-for="opt in item.enum"
|
|
17
|
+
:key="opt"
|
|
18
|
+
class="config-enum-item"
|
|
19
|
+
:class="{ checked: enumValues.includes(opt) }"
|
|
20
|
+
>
|
|
21
|
+
<input
|
|
22
|
+
type="checkbox"
|
|
23
|
+
:checked="enumValues.includes(opt)"
|
|
24
|
+
:disabled="status === 'saving'"
|
|
25
|
+
@change="toggleEnum(opt)"
|
|
26
|
+
/>
|
|
27
|
+
<span>{{ enumLabel(opt) }}</span>
|
|
28
|
+
</label>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<!-- integer + unit=cents:按“元”编辑,提交“分”字符串 -->
|
|
32
|
+
<input
|
|
33
|
+
v-else-if="isCentsMoney"
|
|
34
|
+
type="number"
|
|
35
|
+
inputmode="decimal"
|
|
36
|
+
step="0.01"
|
|
37
|
+
min="0"
|
|
38
|
+
:value="displayYuan"
|
|
39
|
+
:disabled="status === 'saving'"
|
|
40
|
+
@change="onYuanChange(($event.target as HTMLInputElement).value)"
|
|
41
|
+
/>
|
|
42
|
+
<input
|
|
43
|
+
v-else-if="item.type === 'integer'"
|
|
44
|
+
type="number"
|
|
45
|
+
step="1"
|
|
46
|
+
:value="value"
|
|
47
|
+
:disabled="status === 'saving'"
|
|
48
|
+
@change="emitChange(($event.target as HTMLInputElement).value)"
|
|
49
|
+
/>
|
|
50
|
+
<input
|
|
51
|
+
v-else-if="item.sensitive"
|
|
52
|
+
type="password"
|
|
53
|
+
value=""
|
|
54
|
+
:placeholder="item.configured ? tSafe('adminConfigField.override', '已配置,留空不修改') : tSafe('adminConfigField.notConfigured', '未配置')"
|
|
55
|
+
autocomplete="new-password"
|
|
56
|
+
:disabled="status === 'saving'"
|
|
57
|
+
@change="emitChange(($event.target as HTMLInputElement).value)"
|
|
58
|
+
/>
|
|
59
|
+
<textarea
|
|
60
|
+
v-else
|
|
61
|
+
:value="value"
|
|
62
|
+
:disabled="status === 'saving'"
|
|
63
|
+
@change="emitChange(($event.target as HTMLTextAreaElement).value)"
|
|
64
|
+
/>
|
|
65
|
+
</label>
|
|
66
|
+
<p v-if="localizedDescription" class="config-help">{{ localizedDescription }}</p>
|
|
67
|
+
<p v-if="localizedEffect" class="config-effect">{{ localizedEffect }}</p>
|
|
68
|
+
<p v-if="isCentsMoney" class="config-range">
|
|
69
|
+
{{ rangeLabel }} {{ tSafe('adminConfigField.yuanUnit', '元') }}
|
|
70
|
+
<span class="config-range-hint">{{ tSafe('adminConfigField.hint', '按元编辑,入库为分') }}</span>
|
|
71
|
+
</p>
|
|
72
|
+
<p v-else-if="item.type === 'integer'" class="config-range">
|
|
73
|
+
{{ rangeLabel }}
|
|
74
|
+
</p>
|
|
75
|
+
<p v-if="localError" class="config-local-error" role="alert">{{ localError }}</p>
|
|
76
|
+
<div class="config-actions">
|
|
77
|
+
<span v-if="statusText" class="config-status" :class="`config-status-${status}`">{{ statusText }}</span>
|
|
78
|
+
<button
|
|
79
|
+
class="btn btn-ghost btn-xs"
|
|
80
|
+
type="button"
|
|
81
|
+
:disabled="status === 'saving'"
|
|
82
|
+
@click="$emit('reset', item.key)"
|
|
83
|
+
>{{ tSafe('adminConfigField.reset', '重置') }}</button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<script setup lang="ts">
|
|
89
|
+
import { computed, ref, watch } from 'vue'
|
|
90
|
+
import type {
|
|
91
|
+
AdminSystemConfigFieldDefinition,
|
|
92
|
+
ConfigFieldStatus,
|
|
93
|
+
} from '../types/system-config'
|
|
94
|
+
import { formatCents, parseYuanToCents } from '../utils/currency-cents'
|
|
95
|
+
import { tKit } from '../utils/i18n'
|
|
96
|
+
|
|
97
|
+
const props = defineProps<{
|
|
98
|
+
item: AdminSystemConfigFieldDefinition
|
|
99
|
+
value: string
|
|
100
|
+
primary?: boolean
|
|
101
|
+
status?: ConfigFieldStatus
|
|
102
|
+
}>()
|
|
103
|
+
|
|
104
|
+
const emit = defineEmits<{
|
|
105
|
+
change: [key: string, value: string]
|
|
106
|
+
reset: [key: string]
|
|
107
|
+
}>()
|
|
108
|
+
|
|
109
|
+
const localError = ref('')
|
|
110
|
+
|
|
111
|
+
/** 可选 i18n:统一走 tKit(无 vue-i18n 时用中文 fallback) */
|
|
112
|
+
function tSafe(key: string, fallback: string, params?: Record<string, unknown>): string {
|
|
113
|
+
return tKit(key, fallback, params)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function translateConfigText(
|
|
117
|
+
suffix: 'label' | 'description' | 'effect',
|
|
118
|
+
fallback: string,
|
|
119
|
+
): string {
|
|
120
|
+
const key = `adminConfig.fields.${props.item.key}.${suffix}`
|
|
121
|
+
return tSafe(key, fallback)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const localizedLabel = computed(() => translateConfigText('label', props.item.label))
|
|
125
|
+
const localizedDescription = computed(() =>
|
|
126
|
+
translateConfigText('description', props.item.description || ''),
|
|
127
|
+
)
|
|
128
|
+
const localizedEffect = computed(() => translateConfigText('effect', props.item.effect || ''))
|
|
129
|
+
|
|
130
|
+
const isCentsMoney = computed(
|
|
131
|
+
() => props.item.type === 'integer' && props.item.unit === 'cents',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const displayYuan = computed(() => {
|
|
135
|
+
if (!isCentsMoney.value) return props.value
|
|
136
|
+
const cents = Number(props.value)
|
|
137
|
+
if (!Number.isSafeInteger(cents)) return props.value
|
|
138
|
+
return formatCents(cents)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const unlimited = computed(() => tSafe('adminConfigField.unlimited', '不限'))
|
|
142
|
+
|
|
143
|
+
const rangeLabel = computed(() => {
|
|
144
|
+
const min = props.item.min === undefined
|
|
145
|
+
? unlimited.value
|
|
146
|
+
: isCentsMoney.value
|
|
147
|
+
? formatCents(props.item.min)
|
|
148
|
+
: String(props.item.min)
|
|
149
|
+
const max = props.item.max === undefined
|
|
150
|
+
? unlimited.value
|
|
151
|
+
: isCentsMoney.value
|
|
152
|
+
? formatCents(props.item.max)
|
|
153
|
+
: String(props.item.max)
|
|
154
|
+
return tSafe('adminConfigField.range', `范围 ${min} ~ ${max}`, { min, max })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const enumValues = computed(() => {
|
|
158
|
+
if (!props.item.enum) return [] as string[]
|
|
159
|
+
return (props.value || '')
|
|
160
|
+
.split(/[,;\s\n]+/)
|
|
161
|
+
.map((s) => s.trim())
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
function enumLabel(opt: string): string {
|
|
166
|
+
return props.item.enumLabels?.[opt] || opt
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toggleEnum(opt: string) {
|
|
170
|
+
if (!props.item.enum?.length) return
|
|
171
|
+
const selected = new Set(enumValues.value)
|
|
172
|
+
if (selected.has(opt)) {
|
|
173
|
+
if (selected.size <= 1) {
|
|
174
|
+
localError.value = tSafe('adminConfigField.enumEmptyHint', '至少保留一项')
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
selected.delete(opt)
|
|
178
|
+
} else {
|
|
179
|
+
selected.add(opt)
|
|
180
|
+
}
|
|
181
|
+
localError.value = ''
|
|
182
|
+
const ordered = props.item.enum.filter((item) => selected.has(item))
|
|
183
|
+
emitChange(ordered.join(','))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function emitChange(value: string) {
|
|
187
|
+
localError.value = ''
|
|
188
|
+
emit('change', props.item.key, value)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function onYuanChange(raw: string) {
|
|
192
|
+
localError.value = ''
|
|
193
|
+
const trimmed = raw.trim()
|
|
194
|
+
if (!trimmed) {
|
|
195
|
+
localError.value = tSafe('adminConfigField.placeholder', '请输入有效金额')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const cents = parseYuanToCents(trimmed)
|
|
199
|
+
if (cents === null || cents < 0) {
|
|
200
|
+
localError.value = tSafe('adminConfigField.placeholder', '请输入有效金额')
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
if (props.item.min !== undefined && cents < props.item.min) {
|
|
204
|
+
localError.value = tSafe(
|
|
205
|
+
'adminConfigField.minError',
|
|
206
|
+
`不能小于 ${formatCents(props.item.min)}`,
|
|
207
|
+
{ min: formatCents(props.item.min) },
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if (props.item.max !== undefined && cents > props.item.max) {
|
|
212
|
+
localError.value = tSafe(
|
|
213
|
+
'adminConfigField.maxError',
|
|
214
|
+
`不能大于 ${formatCents(props.item.max)}`,
|
|
215
|
+
{ max: formatCents(props.item.max) },
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
emitChange(String(cents))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
watch(
|
|
223
|
+
() => props.value,
|
|
224
|
+
() => {
|
|
225
|
+
localError.value = ''
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const statusText = computed(() => {
|
|
230
|
+
if (props.status === 'saving') return tSafe('adminConfigField.saving', '保存中…')
|
|
231
|
+
if (props.status === 'saved') return tSafe('adminConfig.saved', '已保存')
|
|
232
|
+
if (props.status === 'error') return tSafe('adminConfig.saveFailed', '保存失败')
|
|
233
|
+
return ''
|
|
234
|
+
})
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
<style scoped>
|
|
238
|
+
.config-card {
|
|
239
|
+
background: var(--tg-secondary-bg, #151b28);
|
|
240
|
+
border-radius: var(--r-lg, 12px);
|
|
241
|
+
padding: 12px;
|
|
242
|
+
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
|
|
243
|
+
display: flex;
|
|
244
|
+
flex-direction: column;
|
|
245
|
+
gap: 8px;
|
|
246
|
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.config-card-primary {
|
|
250
|
+
border-color: var(--admin-accent-border, rgba(245, 158, 11, 0.38));
|
|
251
|
+
background: linear-gradient(
|
|
252
|
+
180deg,
|
|
253
|
+
var(--admin-accent-soft, rgba(245, 158, 11, 0.14)),
|
|
254
|
+
rgba(245, 158, 11, 0.04)
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.config-card:hover {
|
|
259
|
+
border-color: rgba(245, 158, 11, 0.55);
|
|
260
|
+
box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.3));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.config-label {
|
|
264
|
+
display: flex;
|
|
265
|
+
flex-direction: column;
|
|
266
|
+
gap: 4px;
|
|
267
|
+
font-size: 12px;
|
|
268
|
+
color: var(--tg-text, #f0f2f5);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.config-label span:first-child {
|
|
272
|
+
font-weight: 500;
|
|
273
|
+
font-size: 12px;
|
|
274
|
+
color: var(--tg-hint, #9aa4b2);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.config-help,
|
|
278
|
+
.config-effect,
|
|
279
|
+
.config-range {
|
|
280
|
+
margin: 0;
|
|
281
|
+
font-size: 12px;
|
|
282
|
+
line-height: 1.45;
|
|
283
|
+
color: var(--tg-hint, #9aa4b2);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.config-effect {
|
|
287
|
+
color: var(--tg-text, #f0f2f5);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.config-range-hint {
|
|
291
|
+
color: var(--admin-accent-text, #fbbf24);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.config-local-error {
|
|
295
|
+
margin: 0;
|
|
296
|
+
font-size: 12px;
|
|
297
|
+
color: #fca5a5;
|
|
298
|
+
line-height: 1.4;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.config-label input[type='number'],
|
|
302
|
+
.config-label input[type='password'],
|
|
303
|
+
.config-label textarea {
|
|
304
|
+
padding: 8px 10px;
|
|
305
|
+
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
|
|
306
|
+
border-radius: var(--r-md, 8px);
|
|
307
|
+
background: var(--surface, rgba(255, 255, 255, 0.04));
|
|
308
|
+
color: var(--tg-text, #f0f2f5);
|
|
309
|
+
font-size: 13px;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.config-label textarea {
|
|
313
|
+
min-height: 72px;
|
|
314
|
+
resize: vertical;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.config-label input[type='checkbox'] {
|
|
318
|
+
width: 14px;
|
|
319
|
+
height: 14px;
|
|
320
|
+
margin: 0;
|
|
321
|
+
padding: 0;
|
|
322
|
+
border: none;
|
|
323
|
+
background: transparent;
|
|
324
|
+
accent-color: var(--admin-accent, #f59e0b);
|
|
325
|
+
cursor: pointer;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.config-actions {
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
justify-content: space-between;
|
|
332
|
+
gap: 8px;
|
|
333
|
+
margin-top: auto;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.config-status {
|
|
337
|
+
font-size: 12px;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.config-status-saving {
|
|
341
|
+
color: var(--tg-hint, #9aa4b2);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.config-status-saved {
|
|
345
|
+
color: #6ee7b7;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.config-status-error {
|
|
349
|
+
color: #fca5a5;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.config-enum-wrap {
|
|
353
|
+
display: flex;
|
|
354
|
+
flex-direction: column;
|
|
355
|
+
gap: 6px;
|
|
356
|
+
width: 100%;
|
|
357
|
+
}
|
|
358
|
+
.config-enum-grid {
|
|
359
|
+
display: grid;
|
|
360
|
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
361
|
+
gap: 6px;
|
|
362
|
+
width: 100%;
|
|
363
|
+
}
|
|
364
|
+
.config-enum-item {
|
|
365
|
+
display: flex;
|
|
366
|
+
align-items: center;
|
|
367
|
+
gap: 6px;
|
|
368
|
+
padding: 6px 10px;
|
|
369
|
+
background: rgba(255, 255, 255, 0.04);
|
|
370
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
371
|
+
border-radius: 6px;
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
font-size: 13px;
|
|
374
|
+
transition: all 0.2s;
|
|
375
|
+
user-select: none;
|
|
376
|
+
}
|
|
377
|
+
.config-enum-item.checked {
|
|
378
|
+
background: rgba(245, 158, 11, 0.12);
|
|
379
|
+
border-color: rgba(245, 158, 11, 0.3);
|
|
380
|
+
}
|
|
381
|
+
.config-enum-item:hover {
|
|
382
|
+
border-color: rgba(255, 255, 255, 0.2);
|
|
383
|
+
}
|
|
384
|
+
.config-enum-item input {
|
|
385
|
+
flex-shrink: 0;
|
|
386
|
+
}
|
|
387
|
+
</style>
|
|
@@ -19,13 +19,30 @@
|
|
|
19
19
|
</span>
|
|
20
20
|
</label>
|
|
21
21
|
</div>
|
|
22
|
+
<div v-if="requirePhrase" class="confirm-phrase">
|
|
23
|
+
<label class="confirm-phrase-label" for="confirm-phrase-input">
|
|
24
|
+
{{ $t('confirm.requirePhrase', { phrase: requirePhrase }) }}
|
|
25
|
+
</label>
|
|
26
|
+
<input
|
|
27
|
+
id="confirm-phrase-input"
|
|
28
|
+
ref="phraseInputEl"
|
|
29
|
+
v-model="phraseInput"
|
|
30
|
+
type="text"
|
|
31
|
+
class="confirm-phrase-input"
|
|
32
|
+
autocomplete="off"
|
|
33
|
+
spellcheck="false"
|
|
34
|
+
:disabled="loading"
|
|
35
|
+
:placeholder="requirePhrase"
|
|
36
|
+
@keydown.enter.prevent="handleConfirm"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
22
39
|
<template #actions>
|
|
23
40
|
<button type="button" class="btn btn-ghost" :disabled="loading" @click="handleClose">{{ $t('confirm.cancel') }}</button>
|
|
24
41
|
<button
|
|
25
42
|
class="btn"
|
|
26
43
|
type="button"
|
|
27
44
|
:class="danger ? 'btn-danger' : 'btn-primary'"
|
|
28
|
-
:disabled="loading"
|
|
45
|
+
:disabled="loading || !phraseOk"
|
|
29
46
|
@click="handleConfirm"
|
|
30
47
|
>
|
|
31
48
|
{{ $t('confirm.confirm') }}
|
|
@@ -35,7 +52,7 @@
|
|
|
35
52
|
</template>
|
|
36
53
|
|
|
37
54
|
<script setup lang="ts">
|
|
38
|
-
import { ref, watch } from 'vue'
|
|
55
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
39
56
|
import AdminModal from './AdminModal.vue'
|
|
40
57
|
import type { ConfirmOptionDef } from '../composables/useConfirmDialog'
|
|
41
58
|
|
|
@@ -48,11 +65,14 @@ const props = withDefaults(defineProps<{
|
|
|
48
65
|
options?: ConfirmOptionDef[]
|
|
49
66
|
/** key → 是否勾选(由 useConfirmDialog 托管) */
|
|
50
67
|
optionValues?: Record<string, boolean>
|
|
68
|
+
/** 须完全匹配的确认短语;空则不要求键入 */
|
|
69
|
+
requirePhrase?: string | null
|
|
51
70
|
}>(), {
|
|
52
71
|
loading: false,
|
|
53
72
|
danger: false,
|
|
54
73
|
options: () => [],
|
|
55
74
|
optionValues: () => ({}),
|
|
75
|
+
requirePhrase: null,
|
|
56
76
|
})
|
|
57
77
|
|
|
58
78
|
const emit = defineEmits<{
|
|
@@ -62,8 +82,23 @@ const emit = defineEmits<{
|
|
|
62
82
|
}>()
|
|
63
83
|
|
|
64
84
|
const innerVisible = ref(false)
|
|
85
|
+
const phraseInput = ref('')
|
|
86
|
+
const phraseInputEl = ref<HTMLInputElement | null>(null)
|
|
87
|
+
|
|
88
|
+
const phraseOk = computed(() => {
|
|
89
|
+
const need = props.requirePhrase
|
|
90
|
+
if (!need) return true
|
|
91
|
+
return phraseInput.value === need
|
|
92
|
+
})
|
|
93
|
+
|
|
65
94
|
watch(() => props.modelValue, (val) => {
|
|
66
95
|
innerVisible.value = val
|
|
96
|
+
if (val) {
|
|
97
|
+
phraseInput.value = ''
|
|
98
|
+
if (props.requirePhrase) {
|
|
99
|
+
nextTick(() => phraseInputEl.value?.focus())
|
|
100
|
+
}
|
|
101
|
+
}
|
|
67
102
|
}, { immediate: true })
|
|
68
103
|
watch(innerVisible, (val, wasVisible) => {
|
|
69
104
|
if (!val && wasVisible && props.modelValue) emit('update:modelValue', false)
|
|
@@ -78,7 +113,7 @@ function handleClose() {
|
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
function handleConfirm() {
|
|
81
|
-
if (!innerVisible.value || props.loading) return
|
|
116
|
+
if (!innerVisible.value || props.loading || !phraseOk.value) return
|
|
82
117
|
emit('confirm')
|
|
83
118
|
close()
|
|
84
119
|
}
|
|
@@ -157,4 +192,33 @@ function onOptionChange(key: string, checked: boolean) {
|
|
|
157
192
|
line-height: 1.5;
|
|
158
193
|
opacity: 0.95;
|
|
159
194
|
}
|
|
195
|
+
|
|
196
|
+
/* 确认短语输入 */
|
|
197
|
+
.confirm-phrase {
|
|
198
|
+
margin-top: 14px;
|
|
199
|
+
display: flex;
|
|
200
|
+
flex-direction: column;
|
|
201
|
+
gap: 8px;
|
|
202
|
+
}
|
|
203
|
+
.confirm-phrase-label {
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
color: var(--tg-hint, #9aa4b2);
|
|
206
|
+
line-height: 1.45;
|
|
207
|
+
}
|
|
208
|
+
.confirm-phrase-input {
|
|
209
|
+
box-sizing: border-box;
|
|
210
|
+
height: 36px;
|
|
211
|
+
padding: 0 10px;
|
|
212
|
+
border-radius: var(--r-md, 8px);
|
|
213
|
+
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
|
|
214
|
+
background: var(--tg-secondary-bg, #151b28);
|
|
215
|
+
color: var(--tg-text, #f0f2f5);
|
|
216
|
+
font-size: 14px;
|
|
217
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
218
|
+
letter-spacing: 0.04em;
|
|
219
|
+
}
|
|
220
|
+
.confirm-phrase-input:focus {
|
|
221
|
+
outline: 2px solid rgba(239, 68, 68, 0.45);
|
|
222
|
+
outline-offset: 1px;
|
|
223
|
+
}
|
|
160
224
|
</style>
|
package/src/components/index.ts
CHANGED
|
@@ -2,3 +2,9 @@ export { default as AdminPagination } from './AdminPagination.vue'
|
|
|
2
2
|
export { default as AdminModal } from './AdminModal.vue'
|
|
3
3
|
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
|
4
4
|
export { default as ToastContainer } from './ToastContainer.vue'
|
|
5
|
+
export { default as ConfigField } from './ConfigField.vue'
|
|
6
|
+
export { default as AdminShell } from './AdminShell.vue'
|
|
7
|
+
export { default as AdminLoginShell } from './AdminLoginShell.vue'
|
|
8
|
+
export { default as AdminMetadataDetail } from './AdminMetadataDetail.vue'
|
|
9
|
+
export type { AdminShellMenuItem } from './AdminShell.vue'
|
|
10
|
+
export type { AdminLogEntry } from './AdminMetadataDetail.vue'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie 会话产品用的本地布尔管理端守卫 flag。
|
|
3
|
+
*
|
|
4
|
+
* 真正的会话在 HTTP cookie(Better Auth 等);此处只存 localStorage 快路径标记,
|
|
5
|
+
* 供路由守卫使用——与 Bearer 产品的 {@link createAdminTokenSession} 对称。
|
|
6
|
+
*
|
|
7
|
+
* 产品负责:健康探测、登录/登出、OAuth/PKCE、ADMIN_* 白名单。
|
|
8
|
+
* 套件负责:单 key 读写 + 一个 Vue ref 句柄(在模块作用域创建一次)。
|
|
9
|
+
*/
|
|
10
|
+
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ADMIN_FLAG_TRUTHY = '1'
|
|
13
|
+
|
|
14
|
+
export type CreateAdminFlagSessionOptions = {
|
|
15
|
+
/** localStorage 键名(如 `cf_auth_admin_ok`) */
|
|
16
|
+
storageKey: string
|
|
17
|
+
/** 已登录时写入的值;默认 `'1'` */
|
|
18
|
+
truthyValue?: string
|
|
19
|
+
/** 覆盖存储实现(测试用);默认在可用时用 `localStorage` */
|
|
20
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveStorage(
|
|
24
|
+
explicit?: CreateAdminFlagSessionOptions['storage'],
|
|
25
|
+
): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null {
|
|
26
|
+
if (explicit !== undefined) return explicit
|
|
27
|
+
try {
|
|
28
|
+
if (typeof localStorage === 'undefined') return null
|
|
29
|
+
return localStorage
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readAdminFlagFromStorage(
|
|
36
|
+
storageKey: string,
|
|
37
|
+
storage?: CreateAdminFlagSessionOptions['storage'],
|
|
38
|
+
truthyValue: string = DEFAULT_ADMIN_FLAG_TRUTHY,
|
|
39
|
+
): boolean {
|
|
40
|
+
const store = resolveStorage(storage)
|
|
41
|
+
if (!store) return false
|
|
42
|
+
try {
|
|
43
|
+
return store.getItem(storageKey) === truthyValue
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function writeAdminFlagToStorage(
|
|
50
|
+
storageKey: string,
|
|
51
|
+
ok: boolean,
|
|
52
|
+
options?: {
|
|
53
|
+
storage?: CreateAdminFlagSessionOptions['storage']
|
|
54
|
+
truthyValue?: string
|
|
55
|
+
},
|
|
56
|
+
): void {
|
|
57
|
+
const store = resolveStorage(options?.storage)
|
|
58
|
+
if (!store) return
|
|
59
|
+
const truthy = options?.truthyValue ?? DEFAULT_ADMIN_FLAG_TRUTHY
|
|
60
|
+
try {
|
|
61
|
+
if (ok) store.setItem(storageKey, truthy)
|
|
62
|
+
else store.removeItem(storageKey)
|
|
63
|
+
} catch {
|
|
64
|
+
/* 隐私模式 / 配额不足 */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type AdminFlagSession = {
|
|
69
|
+
/** 与 storage flag 同步的响应式镜像 */
|
|
70
|
+
loggedIn: Ref<boolean>
|
|
71
|
+
isLoggedIn: ComputedRef<boolean>
|
|
72
|
+
/** 读 storage(作为 session.readFlag 调用时也可刷新 ref) */
|
|
73
|
+
readFlag: () => boolean
|
|
74
|
+
/** 唯一写入入口:true 写入 truthy,false 删除键 */
|
|
75
|
+
writeFlag: (ok: boolean) => void
|
|
76
|
+
markLoggedIn: () => void
|
|
77
|
+
clearSession: () => void
|
|
78
|
+
storageKey: string
|
|
79
|
+
truthyValue: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Cookie 管理端本地守卫 flag 的 Vue 会话句柄。
|
|
84
|
+
* 在产品模块作用域 **创建一次**(与 createAdminTokenSession 同模式)。
|
|
85
|
+
*/
|
|
86
|
+
export function createAdminFlagSession(
|
|
87
|
+
options: CreateAdminFlagSessionOptions,
|
|
88
|
+
): AdminFlagSession {
|
|
89
|
+
const storageKey = options.storageKey
|
|
90
|
+
const truthyValue = options.truthyValue ?? DEFAULT_ADMIN_FLAG_TRUTHY
|
|
91
|
+
// 未传 `storage` 时每次调用重新解析,便于测试在模块加载后再 stub localStorage
|
|
92
|
+
// (Node 默认没有 localStorage)。
|
|
93
|
+
const explicitStorage = options.storage
|
|
94
|
+
|
|
95
|
+
function store() {
|
|
96
|
+
return resolveStorage(explicitStorage)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readFlag(): boolean {
|
|
100
|
+
return readAdminFlagFromStorage(storageKey, store(), truthyValue)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const loggedIn = ref(readFlag())
|
|
104
|
+
const isLoggedIn = computed(() => loggedIn.value)
|
|
105
|
+
|
|
106
|
+
function writeFlag(ok: boolean): void {
|
|
107
|
+
writeAdminFlagToStorage(storageKey, ok, { storage: store(), truthyValue })
|
|
108
|
+
loggedIn.value = ok
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function markLoggedIn(): void {
|
|
112
|
+
writeFlag(true)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clearSession(): void {
|
|
116
|
+
writeFlag(false)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
loggedIn,
|
|
121
|
+
isLoggedIn,
|
|
122
|
+
readFlag,
|
|
123
|
+
writeFlag,
|
|
124
|
+
markLoggedIn,
|
|
125
|
+
clearSession,
|
|
126
|
+
storageKey,
|
|
127
|
+
truthyValue,
|
|
128
|
+
}
|
|
129
|
+
}
|