@usethink/cf-admin-fe 0.1.0 → 0.2.6

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -53
  3. package/dist/composables/createAdminFlagSession.d.ts +42 -0
  4. package/dist/composables/createAdminRequest.d.ts +95 -0
  5. package/dist/composables/createAdminTokenSession.d.ts +49 -0
  6. package/dist/composables/index.d.ts +7 -1
  7. package/dist/composables/useAdminListLoader.d.ts +45 -0
  8. package/dist/composables/useAdminSidebar.d.ts +17 -0
  9. package/dist/composables/useClipboard.d.ts +24 -2
  10. package/dist/composables/useConfirmDialog.d.ts +10 -4
  11. package/dist/composables/useNow.d.ts +3 -0
  12. package/dist/composables/useTablePagination.d.ts +6 -0
  13. package/dist/composables/useTableSelection.d.ts +2 -2
  14. package/dist/composables/useToast.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/system-config.d.ts +34 -0
  17. package/dist/utils/admin-navigation.d.ts +66 -0
  18. package/dist/utils/admin-query.d.ts +29 -0
  19. package/dist/utils/batch-limit.d.ts +38 -0
  20. package/dist/utils/batch-result.d.ts +41 -0
  21. package/dist/utils/body-scroll-lock.d.ts +13 -0
  22. package/dist/utils/csv-export.d.ts +8 -0
  23. package/dist/utils/currency-cents.d.ts +16 -0
  24. package/dist/utils/datetime.d.ts +20 -0
  25. package/dist/utils/error-message.d.ts +7 -0
  26. package/dist/utils/format-metadata.d.ts +5 -0
  27. package/dist/utils/index.d.ts +12 -0
  28. package/dist/utils/otp-input.d.ts +11 -0
  29. package/dist/utils/system-config-engine.d.ts +65 -0
  30. 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 +224 -0
  31. 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 +801 -0
  32. package/package.json +30 -6
  33. package/recipes/AdminLayout.vue +73 -0
  34. package/recipes/README.md +130 -0
  35. package/recipes/i18n-merge.snippet.ts +29 -0
  36. package/recipes/list-page.snippet.ts +141 -0
  37. package/recipes/main-styles.snippet.ts +13 -0
  38. package/recipes/reexports/components.ts +19 -0
  39. package/recipes/reexports/composables.ts +29 -0
  40. package/recipes/reexports/utils.ts +31 -0
  41. package/recipes/router-guard.snippet.ts +32 -0
  42. package/recipes/useAdminAuth.cookie.ts +29 -0
  43. package/recipes/useAdminAuth.shop.ts +29 -0
  44. package/recipes/useAdminRequest.cookie.ts +36 -0
  45. package/recipes/useAdminRequest.ts +37 -0
  46. package/src/components/AdminLoginShell.vue +31 -0
  47. package/src/components/AdminMetadataDetail.vue +121 -0
  48. package/src/components/AdminShell.vue +396 -0
  49. package/src/components/ConfigField.vue +401 -0
  50. package/src/components/ConfirmDialog.vue +67 -3
  51. package/src/components/index.ts +6 -0
  52. package/src/composables/createAdminFlagSession.ts +129 -0
  53. package/src/composables/createAdminRequest.ts +322 -0
  54. package/src/composables/createAdminTokenSession.ts +149 -0
  55. package/src/composables/index.ts +51 -0
  56. package/src/composables/useAdminBatchOperation.ts +4 -0
  57. package/src/composables/useAdminListLoader.ts +124 -0
  58. package/src/composables/useAdminSidebar.ts +68 -0
  59. package/src/composables/useClipboard.ts +41 -2
  60. package/src/composables/useConfirmDialog.ts +26 -4
  61. package/src/composables/useNow.ts +38 -0
  62. package/src/composables/useTablePagination.ts +6 -0
  63. package/src/composables/useTableSelection.ts +2 -2
  64. package/src/composables/useToast.ts +4 -1
  65. package/src/env.d.ts +2 -2
  66. package/src/i18n/index.ts +8 -0
  67. package/src/i18n/kit-messages.ts +297 -0
  68. package/src/index.ts +143 -4
  69. package/src/styles/admin-primitives.css +23 -0
  70. package/src/styles/index.css +1 -0
  71. package/src/styles/login-primitives.css +149 -0
  72. package/src/types/index.ts +7 -0
  73. package/src/types/system-config.ts +38 -0
  74. package/src/utils/admin-navigation.ts +138 -0
  75. package/src/utils/admin-query.ts +51 -0
  76. package/src/utils/batch-limit.ts +66 -0
  77. package/src/utils/batch-result.ts +81 -0
  78. package/src/utils/body-scroll-lock.ts +63 -0
  79. package/src/utils/csv-export.ts +25 -0
  80. package/src/utils/currency-cents.ts +30 -0
  81. package/src/utils/datetime.ts +54 -0
  82. package/src/utils/error-message.ts +32 -0
  83. package/src/utils/format-metadata.ts +11 -0
  84. package/src/utils/index.ts +81 -0
  85. package/src/utils/otp-input.ts +18 -0
  86. package/src/utils/system-config-engine.ts +212 -0
  87. package/dist/components/index.d.ts +0 -4
  88. package/dist/index.d.ts +0 -17
@@ -0,0 +1,401 @@
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, getCurrentInstance, 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
+
96
+ const props = defineProps<{
97
+ item: AdminSystemConfigFieldDefinition
98
+ value: string
99
+ primary?: boolean
100
+ status?: ConfigFieldStatus
101
+ }>()
102
+
103
+ const emit = defineEmits<{
104
+ change: [key: string, value: string]
105
+ reset: [key: string]
106
+ }>()
107
+
108
+ const localError = ref('')
109
+
110
+ /**
111
+ * 可选 i18n:宿主可注入 vue-i18n 全局 `$t` / `$te`。
112
+ * 切勿把后端自由文本作为 defaultMessage 传入(裸 `@` / `{}` 会导致解析失败)。
113
+ */
114
+ function tSafe(key: string, fallback: string, params?: Record<string, unknown>): string {
115
+ const proxy = getCurrentInstance()?.proxy as
116
+ | { $t?: (k: string, p?: Record<string, unknown>) => string; $te?: (k: string) => boolean }
117
+ | undefined
118
+ try {
119
+ if (proxy?.$te && !proxy.$te(key)) return fallback
120
+ if (typeof proxy?.$t === 'function') {
121
+ const out = params ? proxy.$t(key, params) : proxy.$t(key)
122
+ if (typeof out === 'string' && out !== key) return out
123
+ }
124
+ } catch {
125
+ /* 无 i18n */
126
+ }
127
+ return fallback
128
+ }
129
+
130
+ function translateConfigText(
131
+ suffix: 'label' | 'description' | 'effect',
132
+ fallback: string,
133
+ ): string {
134
+ const key = `adminConfig.fields.${props.item.key}.${suffix}`
135
+ return tSafe(key, fallback)
136
+ }
137
+
138
+ const localizedLabel = computed(() => translateConfigText('label', props.item.label))
139
+ const localizedDescription = computed(() =>
140
+ translateConfigText('description', props.item.description || ''),
141
+ )
142
+ const localizedEffect = computed(() => translateConfigText('effect', props.item.effect || ''))
143
+
144
+ const isCentsMoney = computed(
145
+ () => props.item.type === 'integer' && props.item.unit === 'cents',
146
+ )
147
+
148
+ const displayYuan = computed(() => {
149
+ if (!isCentsMoney.value) return props.value
150
+ const cents = Number(props.value)
151
+ if (!Number.isSafeInteger(cents)) return props.value
152
+ return formatCents(cents)
153
+ })
154
+
155
+ const unlimited = computed(() => tSafe('adminConfigField.unlimited', '不限'))
156
+
157
+ const rangeLabel = computed(() => {
158
+ const min = props.item.min === undefined
159
+ ? unlimited.value
160
+ : isCentsMoney.value
161
+ ? formatCents(props.item.min)
162
+ : String(props.item.min)
163
+ const max = props.item.max === undefined
164
+ ? unlimited.value
165
+ : isCentsMoney.value
166
+ ? formatCents(props.item.max)
167
+ : String(props.item.max)
168
+ return tSafe('adminConfigField.range', `范围 ${min} ~ ${max}`, { min, max })
169
+ })
170
+
171
+ const enumValues = computed(() => {
172
+ if (!props.item.enum) return [] as string[]
173
+ return (props.value || '')
174
+ .split(/[,;\s\n]+/)
175
+ .map((s) => s.trim())
176
+ .filter(Boolean)
177
+ })
178
+
179
+ function enumLabel(opt: string): string {
180
+ return props.item.enumLabels?.[opt] || opt
181
+ }
182
+
183
+ function toggleEnum(opt: string) {
184
+ if (!props.item.enum?.length) return
185
+ const selected = new Set(enumValues.value)
186
+ if (selected.has(opt)) {
187
+ if (selected.size <= 1) {
188
+ localError.value = tSafe('adminConfigField.enumEmptyHint', '至少保留一项')
189
+ return
190
+ }
191
+ selected.delete(opt)
192
+ } else {
193
+ selected.add(opt)
194
+ }
195
+ localError.value = ''
196
+ const ordered = props.item.enum.filter((item) => selected.has(item))
197
+ emitChange(ordered.join(','))
198
+ }
199
+
200
+ function emitChange(value: string) {
201
+ localError.value = ''
202
+ emit('change', props.item.key, value)
203
+ }
204
+
205
+ function onYuanChange(raw: string) {
206
+ localError.value = ''
207
+ const trimmed = raw.trim()
208
+ if (!trimmed) {
209
+ localError.value = tSafe('adminConfigField.placeholder', '请输入有效金额')
210
+ return
211
+ }
212
+ const cents = parseYuanToCents(trimmed)
213
+ if (cents === null || cents < 0) {
214
+ localError.value = tSafe('adminConfigField.placeholder', '请输入有效金额')
215
+ return
216
+ }
217
+ if (props.item.min !== undefined && cents < props.item.min) {
218
+ localError.value = tSafe(
219
+ 'adminConfigField.minError',
220
+ `不能小于 ${formatCents(props.item.min)}`,
221
+ { min: formatCents(props.item.min) },
222
+ )
223
+ return
224
+ }
225
+ if (props.item.max !== undefined && cents > props.item.max) {
226
+ localError.value = tSafe(
227
+ 'adminConfigField.maxError',
228
+ `不能大于 ${formatCents(props.item.max)}`,
229
+ { max: formatCents(props.item.max) },
230
+ )
231
+ return
232
+ }
233
+ emitChange(String(cents))
234
+ }
235
+
236
+ watch(
237
+ () => props.value,
238
+ () => {
239
+ localError.value = ''
240
+ },
241
+ )
242
+
243
+ const statusText = computed(() => {
244
+ if (props.status === 'saving') return tSafe('adminConfigField.saving', '保存中…')
245
+ if (props.status === 'saved') return tSafe('adminConfig.saved', '已保存')
246
+ if (props.status === 'error') return tSafe('adminConfig.saveFailed', '保存失败')
247
+ return ''
248
+ })
249
+ </script>
250
+
251
+ <style scoped>
252
+ .config-card {
253
+ background: var(--tg-secondary-bg, #151b28);
254
+ border-radius: var(--r-lg, 12px);
255
+ padding: 12px;
256
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 8px;
260
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
261
+ }
262
+
263
+ .config-card-primary {
264
+ border-color: var(--admin-accent-border, rgba(245, 158, 11, 0.38));
265
+ background: linear-gradient(
266
+ 180deg,
267
+ var(--admin-accent-soft, rgba(245, 158, 11, 0.14)),
268
+ rgba(245, 158, 11, 0.04)
269
+ );
270
+ }
271
+
272
+ .config-card:hover {
273
+ border-color: rgba(245, 158, 11, 0.55);
274
+ box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.3));
275
+ }
276
+
277
+ .config-label {
278
+ display: flex;
279
+ flex-direction: column;
280
+ gap: 4px;
281
+ font-size: 12px;
282
+ color: var(--tg-text, #f0f2f5);
283
+ }
284
+
285
+ .config-label span:first-child {
286
+ font-weight: 500;
287
+ font-size: 12px;
288
+ color: var(--tg-hint, #9aa4b2);
289
+ }
290
+
291
+ .config-help,
292
+ .config-effect,
293
+ .config-range {
294
+ margin: 0;
295
+ font-size: 12px;
296
+ line-height: 1.45;
297
+ color: var(--tg-hint, #9aa4b2);
298
+ }
299
+
300
+ .config-effect {
301
+ color: var(--tg-text, #f0f2f5);
302
+ }
303
+
304
+ .config-range-hint {
305
+ color: var(--admin-accent-text, #fbbf24);
306
+ }
307
+
308
+ .config-local-error {
309
+ margin: 0;
310
+ font-size: 12px;
311
+ color: #fca5a5;
312
+ line-height: 1.4;
313
+ }
314
+
315
+ .config-label input[type='number'],
316
+ .config-label input[type='password'],
317
+ .config-label textarea {
318
+ padding: 8px 10px;
319
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
320
+ border-radius: var(--r-md, 8px);
321
+ background: var(--surface, rgba(255, 255, 255, 0.04));
322
+ color: var(--tg-text, #f0f2f5);
323
+ font-size: 13px;
324
+ }
325
+
326
+ .config-label textarea {
327
+ min-height: 72px;
328
+ resize: vertical;
329
+ }
330
+
331
+ .config-label input[type='checkbox'] {
332
+ width: 14px;
333
+ height: 14px;
334
+ margin: 0;
335
+ padding: 0;
336
+ border: none;
337
+ background: transparent;
338
+ accent-color: var(--admin-accent, #f59e0b);
339
+ cursor: pointer;
340
+ }
341
+
342
+ .config-actions {
343
+ display: flex;
344
+ align-items: center;
345
+ justify-content: space-between;
346
+ gap: 8px;
347
+ margin-top: auto;
348
+ }
349
+
350
+ .config-status {
351
+ font-size: 12px;
352
+ }
353
+
354
+ .config-status-saving {
355
+ color: var(--tg-hint, #9aa4b2);
356
+ }
357
+
358
+ .config-status-saved {
359
+ color: #6ee7b7;
360
+ }
361
+
362
+ .config-status-error {
363
+ color: #fca5a5;
364
+ }
365
+
366
+ .config-enum-wrap {
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 6px;
370
+ width: 100%;
371
+ }
372
+ .config-enum-grid {
373
+ display: grid;
374
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
375
+ gap: 6px;
376
+ width: 100%;
377
+ }
378
+ .config-enum-item {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 6px;
382
+ padding: 6px 10px;
383
+ background: rgba(255, 255, 255, 0.04);
384
+ border: 1px solid rgba(255, 255, 255, 0.1);
385
+ border-radius: 6px;
386
+ cursor: pointer;
387
+ font-size: 13px;
388
+ transition: all 0.2s;
389
+ user-select: none;
390
+ }
391
+ .config-enum-item.checked {
392
+ background: rgba(245, 158, 11, 0.12);
393
+ border-color: rgba(245, 158, 11, 0.3);
394
+ }
395
+ .config-enum-item:hover {
396
+ border-color: rgba(255, 255, 255, 0.2);
397
+ }
398
+ .config-enum-item input {
399
+ flex-shrink: 0;
400
+ }
401
+ </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>
@@ -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
+ }