@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.
- package/LICENSE +21 -0
- package/README.md +242 -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/index.d.ts +12 -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 +224 -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 +801 -0
- package/package.json +30 -6
- package/recipes/AdminLayout.vue +73 -0
- package/recipes/README.md +130 -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 +396 -0
- package/src/components/ConfigField.vue +401 -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 +143 -4
- package/src/styles/admin-primitives.css +23 -0
- 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/index.ts +81 -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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供管理端批量操作 toast / 汇总文案使用的纯辅助函数。
|
|
3
|
+
*
|
|
4
|
+
* 各产品在 i18n 键和成功文案上存在差异;kit 只负责判定
|
|
5
|
+
* 成功/失败基调,以及可选的 `{success}` / `{failed}` / `{count}` 填充。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type AdminBatchCounts = {
|
|
9
|
+
success: number
|
|
10
|
+
failed: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AdminBatchToast = {
|
|
14
|
+
message: string
|
|
15
|
+
type: 'success' | 'error'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hasBatchFailures(result: AdminBatchCounts): boolean {
|
|
19
|
+
return (result.failed ?? 0) > 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveBatchToastType(result: AdminBatchCounts): 'success' | 'error' {
|
|
23
|
+
return hasBatchFailures(result) ? 'error' : 'success'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 在模板中替换 `{success}`、`{failed}` 和 `{count}`(success 的别名)。
|
|
28
|
+
* 不依赖 vue-i18n — 产品可传入已翻译好的模板。
|
|
29
|
+
*/
|
|
30
|
+
export function formatBatchCountsMessage(
|
|
31
|
+
template: string,
|
|
32
|
+
result: AdminBatchCounts,
|
|
33
|
+
): string {
|
|
34
|
+
const success = String(result.success ?? 0)
|
|
35
|
+
const failed = String(result.failed ?? 0)
|
|
36
|
+
// 使用 split/join 使 TS lib 目标保持在 ES2021 以下(产品 vue-tsc 消费方)。
|
|
37
|
+
return template
|
|
38
|
+
.split('{success}')
|
|
39
|
+
.join(success)
|
|
40
|
+
.split('{failed}')
|
|
41
|
+
.join(failed)
|
|
42
|
+
.split('{count}')
|
|
43
|
+
.join(success)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 从混合/成功模板构建 toast 载荷。
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* buildBatchToast(res, {
|
|
51
|
+
* mixed: '完成:成功 {success},失败 {failed}',
|
|
52
|
+
* success: '已处理 {count} 条',
|
|
53
|
+
* })
|
|
54
|
+
*/
|
|
55
|
+
export function buildBatchToast(
|
|
56
|
+
result: AdminBatchCounts,
|
|
57
|
+
templates: {
|
|
58
|
+
/** 当存在任一失败项时 */
|
|
59
|
+
mixed: string
|
|
60
|
+
/** 当失败数 === 0 时 */
|
|
61
|
+
success: string
|
|
62
|
+
},
|
|
63
|
+
): AdminBatchToast {
|
|
64
|
+
const type = resolveBatchToastType(result)
|
|
65
|
+
const message = formatBatchCountsMessage(
|
|
66
|
+
type === 'error' ? templates.mixed : templates.success,
|
|
67
|
+
result,
|
|
68
|
+
)
|
|
69
|
+
return { message, type }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* cf-auth 管理端页面大量使用的默认中文混合汇总文案。
|
|
74
|
+
* 有产品 i18n 时优先使用;这是零依赖的回退方案。
|
|
75
|
+
*/
|
|
76
|
+
export function formatDefaultBatchMixedMessage(
|
|
77
|
+
result: AdminBatchCounts,
|
|
78
|
+
prefix = '完成',
|
|
79
|
+
): string {
|
|
80
|
+
return formatBatchCountsMessage(`${prefix}:成功 {success},失败 {failed}`, result)
|
|
81
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供叠加弹层(确认框 → 弹窗交接)共用的 body 滚动锁定。
|
|
3
|
+
* - 引用计数,保证嵌套层级不会让页面闪烁解锁
|
|
4
|
+
* - 用 padding-right 补偿滚动条宽度,避免布局跳动
|
|
5
|
+
*
|
|
6
|
+
* 仅修改 document.body 的内联样式;无 document 环境(SSR/测试)下为空操作。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let lockCount = 0
|
|
10
|
+
let previousOverflow = ''
|
|
11
|
+
let previousPaddingRight = ''
|
|
12
|
+
|
|
13
|
+
function scrollbarWidthPx(): number {
|
|
14
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return 0
|
|
15
|
+
return Math.max(0, window.innerWidth - document.documentElement.clientWidth)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 当前模块是否持有 body 滚动锁(测试 / 调试用)。 */
|
|
19
|
+
export function isBodyScrollLocked(): boolean {
|
|
20
|
+
return lockCount > 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 仅供测试使用的重置 — 请勿在生产路径中调用。 */
|
|
24
|
+
export function __resetBodyScrollLockForTests(): void {
|
|
25
|
+
lockCount = 0
|
|
26
|
+
previousOverflow = ''
|
|
27
|
+
previousPaddingRight = ''
|
|
28
|
+
if (typeof document !== 'undefined') {
|
|
29
|
+
document.body.style.overflow = ''
|
|
30
|
+
document.body.style.paddingRight = ''
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function lockBodyScroll(): void {
|
|
35
|
+
if (typeof document === 'undefined') return
|
|
36
|
+
lockCount += 1
|
|
37
|
+
if (lockCount !== 1) return
|
|
38
|
+
|
|
39
|
+
previousOverflow = document.body.style.overflow
|
|
40
|
+
previousPaddingRight = document.body.style.paddingRight
|
|
41
|
+
|
|
42
|
+
const gap = scrollbarWidthPx()
|
|
43
|
+
document.body.style.overflow = 'hidden'
|
|
44
|
+
if (gap > 0) {
|
|
45
|
+
const currentPad = Number.parseFloat(previousPaddingRight || '0') || 0
|
|
46
|
+
document.body.style.paddingRight = `${currentPad + gap}px`
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function unlockBodyScroll(): void {
|
|
51
|
+
if (typeof document === 'undefined') return
|
|
52
|
+
if (lockCount <= 0) {
|
|
53
|
+
lockCount = 0
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
lockCount -= 1
|
|
57
|
+
if (lockCount !== 0) return
|
|
58
|
+
|
|
59
|
+
document.body.style.overflow = previousOverflow
|
|
60
|
+
document.body.style.paddingRight = previousPaddingRight
|
|
61
|
+
previousOverflow = ''
|
|
62
|
+
previousPaddingRight = ''
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV 下载,针对电子表格客户端做了公式注入防护。
|
|
3
|
+
* 语义与后端服务端导出 CSV 的产品实现保持一致。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 转义单个单元格:加引号 + 内部双引号加倍;中和开头的公式字符。 */
|
|
7
|
+
export function safeCsvCell(value: unknown): string {
|
|
8
|
+
const text = String(value ?? '')
|
|
9
|
+
const escaped = text.replace(/"/g, '""')
|
|
10
|
+
// Excel/Sheets 将 = + - @(以及 tab/换行)视为公式起始字符。
|
|
11
|
+
return /^[=+\-@\t\n]/.test(escaped) ? `"\t${escaped}"` : `"${escaped}"`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 通过临时 object URL 下载 UTF-8 BOM 的 CSV。 */
|
|
15
|
+
export function downloadCsv(filename: string, rows: string[][]): void {
|
|
16
|
+
if (typeof document === 'undefined') return
|
|
17
|
+
const content = `\uFEFF${rows.map((row) => row.map(safeCsvCell).join(',')).join('\n')}`
|
|
18
|
+
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
|
|
19
|
+
const url = URL.createObjectURL(blob)
|
|
20
|
+
const anchor = document.createElement('a')
|
|
21
|
+
anchor.href = url
|
|
22
|
+
anchor.download = filename
|
|
23
|
+
anchor.click()
|
|
24
|
+
URL.revokeObjectURL(url)
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供管理表单使用的纯最小单位(分)辅助函数。
|
|
3
|
+
* 不依赖产品资金库 — 人民币风格 2 位小数主单位。
|
|
4
|
+
* 使用多币种的产品应保留 `@shared/money`,仅对 ConfigField 风格的整数分字段
|
|
5
|
+
* 重新导出或包装这些函数。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 将未知值强制转换为安全的整数分;否则返回 0。 */
|
|
9
|
+
export function normalizeCents(value: unknown): number {
|
|
10
|
+
const cents = Number(value)
|
|
11
|
+
return Number.isSafeInteger(cents) ? cents : 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 分 → 保留 2 位小数的主单位字符串(`123` → `"1.23"`)。 */
|
|
15
|
+
export function formatCents(cents: number | unknown): string {
|
|
16
|
+
if (!Number.isFinite(Number(cents))) return '0.00'
|
|
17
|
+
return (normalizeCents(cents) / 100).toFixed(2)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 主单位字符串 → 整数分。
|
|
22
|
+
* 空 / 非数字 → `null`,让调用方可以拒绝非法输入
|
|
23
|
+
* (避免意外把 `"50"` 元写成 50 分)。
|
|
24
|
+
*/
|
|
25
|
+
export function parseYuanToCents(yuan: string): number | null {
|
|
26
|
+
if (!yuan.trim()) return null
|
|
27
|
+
const num = Number.parseFloat(yuan)
|
|
28
|
+
if (!Number.isFinite(num)) return null
|
|
29
|
+
return Math.round(num * 100)
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供 CF 产品管理员共用的日期时间辅助函数。
|
|
3
|
+
* 纯函数 — 不依赖 Vue,不含领域标签。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function pad(num: number): string {
|
|
7
|
+
return String(num).padStart(2, '0')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ISO 字符串 / Date → 本地 `YYYY-MM-DD HH:mm:ss`;空值 → `-`。
|
|
12
|
+
* 接受 `Date` 对象,使 cookie/JSON 管理表格无需产品包装层(cf-auth)。
|
|
13
|
+
*/
|
|
14
|
+
export function formatDate(value?: string | Date | null): string {
|
|
15
|
+
if (value == null || value === '') return '-'
|
|
16
|
+
if (value instanceof Date) {
|
|
17
|
+
if (Number.isNaN(value.getTime())) return '-'
|
|
18
|
+
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
|
|
19
|
+
}
|
|
20
|
+
const date = new Date(value)
|
|
21
|
+
if (Number.isNaN(date.getTime())) return value
|
|
22
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 后端时间 → `datetime-local` 输入值(本地挂钟时间,不含秒) */
|
|
26
|
+
export function toDateTimeLocalValue(value?: string | null): string {
|
|
27
|
+
if (!value) return ''
|
|
28
|
+
const date = new Date(value)
|
|
29
|
+
if (Number.isNaN(date.getTime())) return value.slice(0, 16)
|
|
30
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `datetime-local` 值 → ISO 字符串(跨浏览器稳定) */
|
|
34
|
+
export function dateTimeLocalToIso(value?: string | null): string {
|
|
35
|
+
if (!value) return ''
|
|
36
|
+
const date = new Date(value)
|
|
37
|
+
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** {@link toDateTimeLocalValue} 的别名,用于编辑表单回填 */
|
|
41
|
+
export function isoToDateTimeLocal(iso?: string | null): string {
|
|
42
|
+
return toDateTimeLocalValue(iso)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 对长不透明字符串(如哈希后的客户端 IP)生成短指纹。
|
|
47
|
+
* 完整值保留用于详情/导出;列表展示缩写形式。
|
|
48
|
+
*/
|
|
49
|
+
export function formatIpFingerprint(value?: string | null, maxPlain = 18): string {
|
|
50
|
+
const text = value?.trim()
|
|
51
|
+
if (!text) return '-'
|
|
52
|
+
if (text.length <= maxPlain) return text
|
|
53
|
+
return `${text.slice(0, 10)}...${text.slice(-6)}`
|
|
54
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将未知的 catch 值规范化为管理端 toast / loading 展示用的字符串。
|
|
3
|
+
*
|
|
4
|
+
* 供 cf-shop / cf-lottery / cf-auth 管理端 UI 共用,它们反复编写:
|
|
5
|
+
* `err instanceof Error ? err.message : fallback`。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function toErrorMessage(error: unknown, fallback = ''): string {
|
|
9
|
+
if (error instanceof Error) {
|
|
10
|
+
const message = error.message.trim()
|
|
11
|
+
return message || fallback
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (typeof error === 'string') {
|
|
15
|
+
const message = error.trim()
|
|
16
|
+
return message || fallback
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (error != null && typeof error === 'object' && 'message' in error) {
|
|
20
|
+
const raw = (error as { message: unknown }).message
|
|
21
|
+
if (typeof raw === 'string') {
|
|
22
|
+
const message = raw.trim()
|
|
23
|
+
if (message) return message
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (fallback) return fallback
|
|
28
|
+
if (error == null) return ''
|
|
29
|
+
|
|
30
|
+
const asString = String(error).trim()
|
|
31
|
+
return asString || fallback
|
|
32
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export {
|
|
2
|
+
formatDate,
|
|
3
|
+
toDateTimeLocalValue,
|
|
4
|
+
dateTimeLocalToIso,
|
|
5
|
+
isoToDateTimeLocal,
|
|
6
|
+
formatIpFingerprint,
|
|
7
|
+
} from './datetime'
|
|
8
|
+
|
|
9
|
+
export { downloadCsv, safeCsvCell } from './csv-export'
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
lockBodyScroll,
|
|
13
|
+
unlockBodyScroll,
|
|
14
|
+
isBodyScrollLocked,
|
|
15
|
+
__resetBodyScrollLockForTests,
|
|
16
|
+
} from './body-scroll-lock'
|
|
17
|
+
|
|
18
|
+
export { normalizeCents, formatCents, parseYuanToCents } from './currency-cents'
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
createAdminUnauthorizedHandler,
|
|
22
|
+
decideAdminRouteAccess,
|
|
23
|
+
resolveAdminLoginRedirect,
|
|
24
|
+
type CreateAdminUnauthorizedHandlerOptions,
|
|
25
|
+
type AdminRouteAuthDecision,
|
|
26
|
+
type DecideAdminRouteAccessOptions,
|
|
27
|
+
type ResolveAdminLoginRedirectOptions,
|
|
28
|
+
} from './admin-navigation'
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
groupDefinitionsByName,
|
|
32
|
+
countSectionItems,
|
|
33
|
+
buildActiveGroupedDefinitions,
|
|
34
|
+
countAdvancedItems,
|
|
35
|
+
resolveSystemConfigSectionId,
|
|
36
|
+
getSystemConfigSection,
|
|
37
|
+
listedSystemConfigGroups,
|
|
38
|
+
auditSystemConfigSectionCoverage,
|
|
39
|
+
type SystemConfigDefinitionLike,
|
|
40
|
+
type SystemConfigSectionLike,
|
|
41
|
+
type SystemConfigGroupedBlock,
|
|
42
|
+
type BuildActiveGroupedOptions,
|
|
43
|
+
type SystemConfigCoverageAudit,
|
|
44
|
+
} from './system-config-engine'
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
normalizeOtpDigits,
|
|
48
|
+
isOtpComplete,
|
|
49
|
+
OTP_CODE_LENGTH,
|
|
50
|
+
} from './otp-input'
|
|
51
|
+
|
|
52
|
+
export { toErrorMessage } from './error-message'
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
hasBatchFailures,
|
|
56
|
+
resolveBatchToastType,
|
|
57
|
+
formatBatchCountsMessage,
|
|
58
|
+
buildBatchToast,
|
|
59
|
+
formatDefaultBatchMixedMessage,
|
|
60
|
+
type AdminBatchCounts,
|
|
61
|
+
type AdminBatchToast,
|
|
62
|
+
} from './batch-result'
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
DEFAULT_ADMIN_BATCH_LIMIT,
|
|
66
|
+
checkAdminBatchLimit,
|
|
67
|
+
type AdminBatchLimitOk,
|
|
68
|
+
type AdminBatchLimitExceeded,
|
|
69
|
+
type AdminBatchLimitCheck,
|
|
70
|
+
type CheckAdminBatchLimitOptions,
|
|
71
|
+
} from './batch-limit'
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
formatMetadata,
|
|
75
|
+
} from './format-metadata'
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
buildAdminOffsetParams,
|
|
79
|
+
applyAdminOffsetParams,
|
|
80
|
+
type AdminOffsetParams,
|
|
81
|
+
} from './admin-query'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP 输入规范化(纯函数)。
|
|
3
|
+
*
|
|
4
|
+
* - 只保留数字,最长 6 位
|
|
5
|
+
* - 便于 SMS/邮件自动填充后触发「满 6 位自动提交」
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const OTP_CODE_LENGTH = 6
|
|
9
|
+
|
|
10
|
+
/** 去掉非数字并截断到 OTP 长度 */
|
|
11
|
+
export function normalizeOtpDigits(raw: string, maxLen = OTP_CODE_LENGTH): string {
|
|
12
|
+
return String(raw || '').replace(/\D/g, '').slice(0, maxLen)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 是否已满位、可触发自动提交 */
|
|
16
|
+
export function isOtpComplete(digits: string, maxLen = OTP_CODE_LENGTH): boolean {
|
|
17
|
+
return digits.length === maxLen
|
|
18
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shop/lottery AdminSystemConfigView 共用的纯系统配置页面引擎。
|
|
3
|
+
*
|
|
4
|
+
* 产品负责:分区 id 联合、分组名列表、危险区 UI、API 加载/保存。
|
|
5
|
+
* 套件负责:分组、分区计数、高级筛选、tab 解析、覆盖率审计。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 引擎使用的最小定义结构(ConfigField 项满足此结构)。 */
|
|
9
|
+
export type SystemConfigDefinitionLike = {
|
|
10
|
+
key: string
|
|
11
|
+
group?: string
|
|
12
|
+
order?: number
|
|
13
|
+
difficulty?: 'basic' | 'advanced' | string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 最小分区结构 — 产品负责填充标签/描述。 */
|
|
17
|
+
export type SystemConfigSectionLike = {
|
|
18
|
+
id: string
|
|
19
|
+
groups: readonly string[]
|
|
20
|
+
includeDangerZone?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type SystemConfigGroupedBlock<T> = {
|
|
24
|
+
name: string
|
|
25
|
+
items: T[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 按 `group` 对定义分组(为空时使用回退名称),并按 `order` 对每组排序。
|
|
30
|
+
*/
|
|
31
|
+
export function groupDefinitionsByName<T extends SystemConfigDefinitionLike>(
|
|
32
|
+
definitions: readonly T[],
|
|
33
|
+
fallbackGroupName = '其他',
|
|
34
|
+
): Map<string, T[]> {
|
|
35
|
+
const map = new Map<string, T[]>()
|
|
36
|
+
for (const item of definitions) {
|
|
37
|
+
const name = item.group || fallbackGroupName
|
|
38
|
+
const bucket = map.get(name)
|
|
39
|
+
if (bucket) bucket.push(item)
|
|
40
|
+
else map.set(name, [item])
|
|
41
|
+
}
|
|
42
|
+
for (const items of map.values()) {
|
|
43
|
+
items.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
|
44
|
+
}
|
|
45
|
+
return map
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 按分区 id 统计定义项数量(所列分组长度之和)。
|
|
50
|
+
*/
|
|
51
|
+
export function countSectionItems(
|
|
52
|
+
groupsByName: Map<string, readonly unknown[]>,
|
|
53
|
+
sections: readonly SystemConfigSectionLike[],
|
|
54
|
+
): Record<string, number> {
|
|
55
|
+
const counts: Record<string, number> = {}
|
|
56
|
+
for (const section of sections) {
|
|
57
|
+
let n = 0
|
|
58
|
+
for (const group of section.groups) {
|
|
59
|
+
n += groupsByName.get(group)?.length ?? 0
|
|
60
|
+
}
|
|
61
|
+
counts[section.id] = n
|
|
62
|
+
}
|
|
63
|
+
return counts
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type BuildActiveGroupedOptions = {
|
|
67
|
+
/**
|
|
68
|
+
* 为 false 时,隐藏 `difficulty === 'advanced'` 的项(lottery 基础模式)。
|
|
69
|
+
* 默认 true = 显示全部(shop)。
|
|
70
|
+
*/
|
|
71
|
+
showAdvanced?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 为当前激活分区构建有序分组块;丢弃空分组。
|
|
76
|
+
*/
|
|
77
|
+
export function buildActiveGroupedDefinitions<T extends SystemConfigDefinitionLike>(
|
|
78
|
+
sectionGroups: readonly string[],
|
|
79
|
+
groupsByName: Map<string, readonly T[]>,
|
|
80
|
+
options: BuildActiveGroupedOptions = {},
|
|
81
|
+
): SystemConfigGroupedBlock<T>[] {
|
|
82
|
+
const showAdvanced = options.showAdvanced !== false
|
|
83
|
+
return sectionGroups
|
|
84
|
+
.map((name) => {
|
|
85
|
+
const raw = groupsByName.get(name) ?? []
|
|
86
|
+
const items = showAdvanced
|
|
87
|
+
? [...raw]
|
|
88
|
+
: raw.filter((item) => item.difficulty !== 'advanced')
|
|
89
|
+
return { name, items }
|
|
90
|
+
})
|
|
91
|
+
.filter((group) => group.items.length > 0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 统计给定分区分组中高级难度的项数。 */
|
|
95
|
+
export function countAdvancedItems<T extends SystemConfigDefinitionLike>(
|
|
96
|
+
sectionGroups: readonly string[],
|
|
97
|
+
groupsByName: Map<string, readonly T[]>,
|
|
98
|
+
): number {
|
|
99
|
+
let count = 0
|
|
100
|
+
for (const name of sectionGroups) {
|
|
101
|
+
const items = groupsByName.get(name) ?? []
|
|
102
|
+
count += items.filter((item) => item.difficulty === 'advanced').length
|
|
103
|
+
}
|
|
104
|
+
return count
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 将 `?tab=` 解析为已知分区 id;无效/为空 → defaultId(绝不凭空编造 id)。
|
|
109
|
+
*/
|
|
110
|
+
export function resolveSystemConfigSectionId<T extends string>(
|
|
111
|
+
raw: unknown,
|
|
112
|
+
validIds: readonly T[],
|
|
113
|
+
defaultId: T,
|
|
114
|
+
): T {
|
|
115
|
+
if (typeof raw !== 'string') return defaultId
|
|
116
|
+
const id = raw.trim()
|
|
117
|
+
if ((validIds as readonly string[]).includes(id)) return id as T
|
|
118
|
+
return defaultId
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getSystemConfigSection<T extends SystemConfigSectionLike>(
|
|
122
|
+
sections: readonly T[],
|
|
123
|
+
id: string,
|
|
124
|
+
): T {
|
|
125
|
+
const found = sections.find((s) => s.id === id)
|
|
126
|
+
return found ?? sections[0]!
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 按声明顺序展平 section.groups,并去重。 */
|
|
130
|
+
export function listedSystemConfigGroups(
|
|
131
|
+
sections: readonly SystemConfigSectionLike[],
|
|
132
|
+
): string[] {
|
|
133
|
+
const seen = new Set<string>()
|
|
134
|
+
const out: string[] = []
|
|
135
|
+
for (const section of sections) {
|
|
136
|
+
for (const group of section.groups) {
|
|
137
|
+
if (seen.has(group)) continue
|
|
138
|
+
seen.add(group)
|
|
139
|
+
out.push(group)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SystemConfigCoverageAudit = {
|
|
146
|
+
ok: boolean
|
|
147
|
+
unmappedGroups: string[]
|
|
148
|
+
unmappedKeys: string[]
|
|
149
|
+
duplicateGroups: string[]
|
|
150
|
+
extraGroupsInMap: string[]
|
|
151
|
+
sectionKeyCounts: Record<string, number>
|
|
152
|
+
totalKeys: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 校验分区映射 ↔ 定义分组覆盖情况(单元/契约测试)。
|
|
157
|
+
* `fallbackGroupName` 必须与 UI 对空 `group` 字段的分组方式一致。
|
|
158
|
+
*/
|
|
159
|
+
export function auditSystemConfigSectionCoverage(
|
|
160
|
+
definitions: readonly SystemConfigDefinitionLike[],
|
|
161
|
+
sections: readonly SystemConfigSectionLike[],
|
|
162
|
+
fallbackGroupName = '其他',
|
|
163
|
+
): SystemConfigCoverageAudit {
|
|
164
|
+
const defGroups = new Set<string>()
|
|
165
|
+
for (const d of definitions) {
|
|
166
|
+
defGroups.add(d.group || fallbackGroupName)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const groupToSection = new Map<string, string>()
|
|
170
|
+
const duplicateGroups: string[] = []
|
|
171
|
+
for (const section of sections) {
|
|
172
|
+
for (const group of section.groups) {
|
|
173
|
+
if (groupToSection.has(group)) {
|
|
174
|
+
duplicateGroups.push(group)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
groupToSection.set(group, section.id)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const unmappedGroups = [...defGroups].filter((g) => !groupToSection.has(g)).sort()
|
|
182
|
+
const extraGroupsInMap = [...groupToSection.keys()].filter((g) => !defGroups.has(g)).sort()
|
|
183
|
+
|
|
184
|
+
const unmappedKeys: string[] = []
|
|
185
|
+
const sectionKeyCounts: Record<string, number> = {}
|
|
186
|
+
for (const section of sections) {
|
|
187
|
+
sectionKeyCounts[section.id] = 0
|
|
188
|
+
}
|
|
189
|
+
for (const d of definitions) {
|
|
190
|
+
const g = d.group || fallbackGroupName
|
|
191
|
+
const sectionId = groupToSection.get(g)
|
|
192
|
+
if (!sectionId) {
|
|
193
|
+
unmappedKeys.push(d.key)
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
sectionKeyCounts[sectionId] = (sectionKeyCounts[sectionId] ?? 0) + 1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
ok:
|
|
201
|
+
unmappedGroups.length === 0
|
|
202
|
+
&& unmappedKeys.length === 0
|
|
203
|
+
&& duplicateGroups.length === 0
|
|
204
|
+
&& extraGroupsInMap.length === 0,
|
|
205
|
+
unmappedGroups,
|
|
206
|
+
unmappedKeys,
|
|
207
|
+
duplicateGroups,
|
|
208
|
+
extraGroupsInMap,
|
|
209
|
+
sectionKeyCounts,
|
|
210
|
+
totalKeys: definitions.length,
|
|
211
|
+
}
|
|
212
|
+
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @usethink/cf-admin — Vue 3 admin foundation for CF product family.
|
|
3
|
-
*
|
|
4
|
-
* Import composables from root or `@usethink/cf-admin/composables`.
|
|
5
|
-
* Import components from `@usethink/cf-admin/components`.
|
|
6
|
-
* Import styles: `import '@usethink/cf-admin/styles'`.
|
|
7
|
-
*/
|
|
8
|
-
export { useTablePagination, type UseTablePaginationOptions, type UseTablePaginationReturn, } from './composables/useTablePagination.ts';
|
|
9
|
-
export { useTableSelection, type UseTableSelectionOptions, } from './composables/useTableSelection.ts';
|
|
10
|
-
export { useConfirmDialog, type ConfirmOptionDef, type ConfirmAskOptions, type ConfirmResult, } from './composables/useConfirmDialog.ts';
|
|
11
|
-
export { useToast, MAX_TOASTS, type ToastType, type ToastItem, } from './composables/useToast.ts';
|
|
12
|
-
export { useAdminBatchOperation, type AdminBatchOperationResult, } from './composables/useAdminBatchOperation.ts';
|
|
13
|
-
export { writeClipboardText, copyText, } from './composables/useClipboard.ts';
|
|
14
|
-
export { default as AdminPagination } from './components/AdminPagination.vue';
|
|
15
|
-
export { default as AdminModal } from './components/AdminModal.vue';
|
|
16
|
-
export { default as ConfirmDialog } from './components/ConfirmDialog.vue';
|
|
17
|
-
export { default as ToastContainer } from './components/ToastContainer.vue';
|