@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 管理端列表页标准骨架(recipe / 文档,非运行时模块)。
|
|
3
|
+
*
|
|
4
|
+
* 组合:
|
|
5
|
+
* - useAdminListLoader — loading / loadError / 竞态安全序号
|
|
6
|
+
* - useTablePagination — page / limit / total / offset(分页 state 的 offset 是 number)
|
|
7
|
+
* - useTableSelection — 多选(可选)
|
|
8
|
+
* - buildAdminOffsetParams / applyAdminOffsetParams — **仅 string** limit+offset(URLSearchParams)
|
|
9
|
+
* - toErrorMessage — catch / onError 统一文案
|
|
10
|
+
* - copyWithToast — 复制选中行 / ID / 链接(可选)
|
|
11
|
+
* - checkAdminBatchLimit + buildBatchToast / resolveBatchToastType — 批量(可选)
|
|
12
|
+
*
|
|
13
|
+
* 不要把领域列定义、筛选表单、业务 API 放进套件。
|
|
14
|
+
*
|
|
15
|
+
* ── 分页查询:两种契约,勿混用 ──
|
|
16
|
+
*
|
|
17
|
+
* A) Query string / URLSearchParams(本 snippet 主示例)
|
|
18
|
+
* buildAdminOffsetParams(page, limit) → { limit: string, offset: string }
|
|
19
|
+
* 参考:cf-lottery 平台审计 / 租户 / webhook / 兑换码
|
|
20
|
+
*
|
|
21
|
+
* B) 类型化 filter / JSON body:limit、offset 为 number
|
|
22
|
+
* 手写即可,例如:
|
|
23
|
+
* { limit: limit.value, offset: (page.value - 1) * limit.value }
|
|
24
|
+
* 参考:cf-shop AdminBalance / Recharges / Vouchers
|
|
25
|
+
* 禁止把 B 强行改成 A 只为「用上 helper」。number helper 观察期:≥2 产品同形再提案。
|
|
26
|
+
*
|
|
27
|
+
* 真实 API 签名(务必与 src/utils/admin-query.ts 一致):
|
|
28
|
+
* buildAdminOffsetParams(page: number, limit: number, options?)
|
|
29
|
+
* applyAdminOffsetParams(params: URLSearchParams, page: number, limit: number, options?)
|
|
30
|
+
*
|
|
31
|
+
* 批量上限:DEFAULT_ADMIN_BATCH_LIMIT = 20(auth 风格)。
|
|
32
|
+
* 后端若为 max(200),必须 checkAdminBatchLimit(ids, { limit: 200, unit: '…' })。
|
|
33
|
+
* 详见 docs/002 §5.8(cf-shop 接线对照)。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
37
|
+
import { ref, watch } from 'vue'
|
|
38
|
+
import {
|
|
39
|
+
useAdminListLoader,
|
|
40
|
+
useTablePagination,
|
|
41
|
+
useTableSelection,
|
|
42
|
+
copyWithToast,
|
|
43
|
+
} from '@usethink/cf-admin-fe/composables'
|
|
44
|
+
import {
|
|
45
|
+
toErrorMessage,
|
|
46
|
+
buildAdminOffsetParams,
|
|
47
|
+
applyAdminOffsetParams,
|
|
48
|
+
checkAdminBatchLimit,
|
|
49
|
+
buildBatchToast,
|
|
50
|
+
} from '@usethink/cf-admin-fe/utils'
|
|
51
|
+
|
|
52
|
+
// —— 伪代码:复制到产品 views/admin/XxxView.vue 后接领域 API ——
|
|
53
|
+
|
|
54
|
+
type Row = { id: string /* … */ }
|
|
55
|
+
|
|
56
|
+
const items = ref<Row[]>([])
|
|
57
|
+
const { page, limit, total, offset, setTotal } = useTablePagination()
|
|
58
|
+
const {
|
|
59
|
+
selectedIds,
|
|
60
|
+
clearSelection,
|
|
61
|
+
selectedCount,
|
|
62
|
+
} = useTableSelection(items, (row) => row.id)
|
|
63
|
+
const { loading, loadError, runLoad } = useAdminListLoader()
|
|
64
|
+
|
|
65
|
+
// 示例:产品侧 admin 请求(Bearer 或 cookie 由 useAdminRequest recipe 决定)
|
|
66
|
+
declare function listDomainItems(params: Record<string, string>): Promise<{ items: Row[]; total: number }>
|
|
67
|
+
declare function batchDisable(ids: string[]): Promise<{ success: number; failed: number }>
|
|
68
|
+
declare function showToast(message: string, type?: 'success' | 'error' | 'info'): void
|
|
69
|
+
declare function t(key: string, params?: Record<string, unknown>): string
|
|
70
|
+
|
|
71
|
+
async function loadData() {
|
|
72
|
+
await runLoad(
|
|
73
|
+
async (ctx) => {
|
|
74
|
+
// ── 形态 A:string query(本示例)──
|
|
75
|
+
// 位置参数:page(1-based)、limit(每页条数)→ 字符串 limit/offset
|
|
76
|
+
const params = buildAdminOffsetParams(page.value, limit.value)
|
|
77
|
+
// 若已有 URLSearchParams(含业务筛选),再写入 limit/offset:
|
|
78
|
+
// const qs = new URLSearchParams({ status: filter.status })
|
|
79
|
+
// applyAdminOffsetParams(qs, page.value, limit.value)
|
|
80
|
+
const res = await listDomainItems(params)
|
|
81
|
+
|
|
82
|
+
// ── 形态 B:number body / 类型化 filter(勿用 buildAdminOffsetParams)──
|
|
83
|
+
// const res = await listDomainItems({
|
|
84
|
+
// status: filter.status,
|
|
85
|
+
// limit: limit.value,
|
|
86
|
+
// offset: (page.value - 1) * limit.value,
|
|
87
|
+
// })
|
|
88
|
+
|
|
89
|
+
if (ctx.isStale()) return
|
|
90
|
+
items.value = res.items
|
|
91
|
+
setTotal(res.total)
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
onError: (err) => toErrorMessage(err, t('adminXxx.loadFailed')),
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
watch([page, limit], () => {
|
|
100
|
+
void loadData()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** 复制选中 ID / 业务字段:成功与失败文案走产品 i18n */
|
|
104
|
+
async function copySelected() {
|
|
105
|
+
if (selectedCount.value === 0) return
|
|
106
|
+
const idSet = new Set(selectedIds.value)
|
|
107
|
+
const rows = items.value.filter((r) => idSet.has(r.id))
|
|
108
|
+
await copyWithToast(rows.map((r) => r.id).join('\n'), {
|
|
109
|
+
successMessage: t('adminXxx.copySelectedSuccess', { count: rows.length }),
|
|
110
|
+
errorMessage: t('adminXxx.copySelectedFailed'),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runBatchDisable() {
|
|
115
|
+
const ids = [...selectedIds.value]
|
|
116
|
+
// 默认 limit=20(auth)。shop 类后端 max(200) 时改为:
|
|
117
|
+
// checkAdminBatchLimit(ids, { limit: 200, unit: t('adminXxx.unit') })
|
|
118
|
+
const limitCheck = checkAdminBatchLimit(ids, { unit: t('adminXxx.unit') })
|
|
119
|
+
if (!limitCheck.ok) {
|
|
120
|
+
showToast(limitCheck.message, 'error')
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const res = await batchDisable(ids)
|
|
125
|
+
const toast = buildBatchToast(res, {
|
|
126
|
+
mixed: t('adminXxx.batchMixed'), // 模板可含 {success}/{failed}/{count}
|
|
127
|
+
success: t('adminXxx.batchSuccess'),
|
|
128
|
+
})
|
|
129
|
+
showToast(toast.message, toast.type)
|
|
130
|
+
clearSelection()
|
|
131
|
+
await loadData()
|
|
132
|
+
} catch (e) {
|
|
133
|
+
showToast(toErrorMessage(e), 'error')
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
void offset
|
|
138
|
+
void applyAdminOffsetParams
|
|
139
|
+
void total
|
|
140
|
+
void loadError
|
|
141
|
+
void loading
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPE — 样式入口。
|
|
3
|
+
*
|
|
4
|
+
* 方案 A(完整工具包 token + 基础样式):
|
|
5
|
+
* import '@usethink/cf-admin-fe/styles'
|
|
6
|
+
*
|
|
7
|
+
* 方案 B(产品已有与商城共享的 base.css token):
|
|
8
|
+
* // assets/admin.css
|
|
9
|
+
* @import '@usethink/cf-admin-fe/styles/admin-primitives.css';
|
|
10
|
+
*
|
|
11
|
+
* 在应用根节点挂载一次:
|
|
12
|
+
* <ToastContainer />
|
|
13
|
+
*/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量组件重新导出 — 产品中每个组件一个文件:
|
|
3
|
+
*
|
|
4
|
+
* src/components/AdminPagination.ts
|
|
5
|
+
* export { default } from '@usethink/cf-admin-fe/components/AdminPagination.vue'
|
|
6
|
+
*
|
|
7
|
+
* 使用深 `.vue` 路径可避免把整个包根目录拉进 Vitest。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { default as AdminPagination } from '@usethink/cf-admin-fe/components/AdminPagination.vue'
|
|
11
|
+
export { default as AdminModal } from '@usethink/cf-admin-fe/components/AdminModal.vue'
|
|
12
|
+
export { default as ConfirmDialog } from '@usethink/cf-admin-fe/components/ConfirmDialog.vue'
|
|
13
|
+
export { default as ToastContainer } from '@usethink/cf-admin-fe/components/ToastContainer.vue'
|
|
14
|
+
export { default as ConfigField } from '@usethink/cf-admin-fe/components/ConfigField.vue'
|
|
15
|
+
export { default as AdminShell } from '@usethink/cf-admin-fe/components/AdminShell.vue'
|
|
16
|
+
export { default as AdminLoginShell } from '@usethink/cf-admin-fe/components/AdminLoginShell.vue'
|
|
17
|
+
export { default as AdminMetadataDetail } from '@usethink/cf-admin-fe/components/AdminMetadataDetail.vue'
|
|
18
|
+
export type { AdminShellMenuItem } from '@usethink/cf-admin-fe/components'
|
|
19
|
+
export type { AdminLogEntry } from '@usethink/cf-admin-fe/components'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 将每个导出复制到各自的产品轻量文件中,使现有的
|
|
3
|
+
* `@/composables/useX` 导入无需大规模改写视图即可继续工作。
|
|
4
|
+
*
|
|
5
|
+
* 示例:`src/composables/useTablePagination.ts`
|
|
6
|
+
* export { useTablePagination, type UseTablePaginationOptions, type UseTablePaginationReturn } from '@usethink/cf-admin-fe/composables'
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
useTablePagination,
|
|
11
|
+
type UseTablePaginationOptions,
|
|
12
|
+
type UseTablePaginationReturn,
|
|
13
|
+
useTableSelection,
|
|
14
|
+
type UseTableSelectionOptions,
|
|
15
|
+
useConfirmDialog,
|
|
16
|
+
type ConfirmOptionDef,
|
|
17
|
+
type ConfirmAskOptions,
|
|
18
|
+
type ConfirmResult,
|
|
19
|
+
useToast,
|
|
20
|
+
MAX_TOASTS,
|
|
21
|
+
type ToastType,
|
|
22
|
+
type ToastItem,
|
|
23
|
+
useAdminBatchOperation,
|
|
24
|
+
type AdminBatchOperationResult,
|
|
25
|
+
writeClipboardText,
|
|
26
|
+
copyText,
|
|
27
|
+
copyWithToast,
|
|
28
|
+
type CopyWithToastOptions,
|
|
29
|
+
} from '@usethink/cf-admin-fe/composables'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 产品侧再导出示例:`src/lib/csv-export.ts`、`body-scroll-lock.ts`、
|
|
3
|
+
* `composables/useFormat.ts`(日期 + 错误/批量助手等)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
formatDate,
|
|
8
|
+
toDateTimeLocalValue,
|
|
9
|
+
dateTimeLocalToIso,
|
|
10
|
+
isoToDateTimeLocal,
|
|
11
|
+
formatIpFingerprint,
|
|
12
|
+
downloadCsv,
|
|
13
|
+
safeCsvCell,
|
|
14
|
+
lockBodyScroll,
|
|
15
|
+
unlockBodyScroll,
|
|
16
|
+
isBodyScrollLocked,
|
|
17
|
+
normalizeCents,
|
|
18
|
+
formatCents,
|
|
19
|
+
parseYuanToCents,
|
|
20
|
+
createAdminUnauthorizedHandler,
|
|
21
|
+
decideAdminRouteAccess,
|
|
22
|
+
resolveAdminLoginRedirect,
|
|
23
|
+
toErrorMessage,
|
|
24
|
+
resolveBatchToastType,
|
|
25
|
+
buildBatchToast,
|
|
26
|
+
checkAdminBatchLimit,
|
|
27
|
+
DEFAULT_ADMIN_BATCH_LIMIT,
|
|
28
|
+
formatMetadata,
|
|
29
|
+
buildAdminOffsetParams,
|
|
30
|
+
applyAdminOffsetParams,
|
|
31
|
+
} from '@usethink/cf-admin-fe/utils'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPE — 将 `beforeEach` 主体粘贴到产品 `src/router/index.ts`。
|
|
3
|
+
* 抽奖产品:先运行工具包门禁,再运行产品 onboarding / 平台 / 仿冒登录。
|
|
4
|
+
*/
|
|
5
|
+
import type { Router } from 'vue-router'
|
|
6
|
+
import { decideAdminRouteAccess } from '@usethink/cf-admin-fe/utils'
|
|
7
|
+
// import { readAdminToken } from '@/composables/useAdminAuth'
|
|
8
|
+
|
|
9
|
+
export function installAdminRouteGuard(
|
|
10
|
+
router: Router,
|
|
11
|
+
readAdminToken: () => string,
|
|
12
|
+
): void {
|
|
13
|
+
router.beforeEach((to) => {
|
|
14
|
+
const adminToken = readAdminToken()
|
|
15
|
+
const decision = decideAdminRouteAccess({
|
|
16
|
+
hasToken: !!adminToken,
|
|
17
|
+
requiresAuth: to.meta.requiresAuth as boolean | undefined,
|
|
18
|
+
requiresGuest: to.meta.requiresGuest as boolean | undefined,
|
|
19
|
+
fullPath: to.fullPath,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
if (decision.type === 'login') {
|
|
23
|
+
return { name: 'AdminLogin', query: { redirect: decision.redirect }, replace: true }
|
|
24
|
+
}
|
|
25
|
+
if (decision.type === 'home') {
|
|
26
|
+
return { name: 'AdminDashboard', replace: true }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 产品专属钩子写在这里(抽奖 onboarding、平台角色等)
|
|
30
|
+
return true
|
|
31
|
+
})
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPE — cookie 管理员本地标志会话(cf-auth 风格)。
|
|
3
|
+
*
|
|
4
|
+
* 不是 Bearer token。HTTP 会话是 cookie;这里只是给路由守卫用的
|
|
5
|
+
* localStorage 标记。在模块作用域创建一次。
|
|
6
|
+
*
|
|
7
|
+
* PRODUCT:健康探测 /api/admin/health、登录、登出、允许列表 — 永远留在产品侧。
|
|
8
|
+
*/
|
|
9
|
+
import { createAdminFlagSession } from '@usethink/cf-admin-fe/composables'
|
|
10
|
+
|
|
11
|
+
export const adminFlagSession = createAdminFlagSession({
|
|
12
|
+
storageKey: 'cf_auth_admin_ok', // PRODUCT:产品方自定义 key
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const readAdminFlag = () => adminFlagSession.readFlag()
|
|
16
|
+
export const writeAdminFlag = (ok: boolean) => adminFlagSession.writeFlag(ok)
|
|
17
|
+
|
|
18
|
+
export function useAdminAuth() {
|
|
19
|
+
return {
|
|
20
|
+
isLoggedIn: adminFlagSession.isLoggedIn,
|
|
21
|
+
loggedIn: adminFlagSession.loggedIn,
|
|
22
|
+
markLoggedIn: adminFlagSession.markLoggedIn,
|
|
23
|
+
/** 为仍使用 clearToken 命名的产品 API 保留的别名 */
|
|
24
|
+
clearToken: adminFlagSession.clearSession,
|
|
25
|
+
clearSession: adminFlagSession.clearSession,
|
|
26
|
+
writeFlag: adminFlagSession.writeFlag,
|
|
27
|
+
readFlag: adminFlagSession.readFlag,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPE — shop 简易 Bearer 会话(单个 localStorage key + 8 小时 TTL)。
|
|
3
|
+
* 复制到 `src/composables/useAdminAuth.ts`,必要时调整 TOKEN_KEY。
|
|
4
|
+
*
|
|
5
|
+
* 抽奖多租户 / 仿冒登录:保留产品胶水代码;仅复用工具包中的
|
|
6
|
+
* `parseStoredAdminToken` + `DEFAULT_ADMIN_TOKEN_TTL_MS`。
|
|
7
|
+
*/
|
|
8
|
+
import { createAdminTokenSession } from '@usethink/cf-admin-fe/composables'
|
|
9
|
+
|
|
10
|
+
const TOKEN_KEY = 'admin_token'
|
|
11
|
+
|
|
12
|
+
const session = createAdminTokenSession({
|
|
13
|
+
storageKey: TOKEN_KEY,
|
|
14
|
+
ttlMs: 8 * 60 * 60 * 1000,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** 供 setup 之外的 Router 守卫使用 */
|
|
18
|
+
export function readAdminToken(): string {
|
|
19
|
+
return session.readToken()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAdminAuth() {
|
|
23
|
+
return {
|
|
24
|
+
token: session.token,
|
|
25
|
+
isLoggedIn: session.isLoggedIn,
|
|
26
|
+
setToken: session.setToken,
|
|
27
|
+
clearToken: session.clearToken,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPE — cookie 会话 adminRequest(cf-auth / Better Auth 风格)。
|
|
3
|
+
*
|
|
4
|
+
* 工具包一等路径(与 Bearer 对称):
|
|
5
|
+
* - createAdminFlagSession — 本地守卫标志(不是 HTTP cookie)
|
|
6
|
+
* - createAdminRequest({ credentials: 'include', onSuccess 修复, onUnauthorized 清除 })
|
|
7
|
+
*
|
|
8
|
+
* PRODUCT 仍负责:健康探测、登录/登出、OAuth、ADMIN_* 允许列表、领域路径。
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createAdminFlagSession,
|
|
12
|
+
createAdminRequest,
|
|
13
|
+
} from '@usethink/cf-admin-fe/composables'
|
|
14
|
+
import { createAdminUnauthorizedHandler } from '@usethink/cf-admin-fe/utils'
|
|
15
|
+
|
|
16
|
+
// PRODUCT: 本地管理员守卫标志的存储 key
|
|
17
|
+
export const adminFlagSession = createAdminFlagSession({
|
|
18
|
+
storageKey: 'cf_auth_admin_ok',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const handleUnauthorized = createAdminUnauthorizedHandler({
|
|
22
|
+
clearSession: () => adminFlagSession.clearSession(),
|
|
23
|
+
loginPath: '/admin/login',
|
|
24
|
+
defaultRedirectPath: '/admin',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const adminRequest = createAdminRequest({
|
|
28
|
+
credentials: 'include',
|
|
29
|
+
resultMode: 'ok-envelope',
|
|
30
|
+
errorField: 'message',
|
|
31
|
+
onUnauthorized: handleUnauthorized,
|
|
32
|
+
// 任何管理员调用成功后修复缺失的标志(会话 cookie 已有效)
|
|
33
|
+
onSuccess: () => {
|
|
34
|
+
if (!adminFlagSession.readFlag()) adminFlagSession.markLoggedIn()
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RECIPE — 可注入的管理员 fetch(抽奖风格 ok-envelope)。
|
|
3
|
+
*
|
|
4
|
+
* Shop 风格:使用 `resultMode: 'http-ok'`、`errorField: 'error'`,并在领域
|
|
5
|
+
* API 方法中逐次调用传入 `token`(参见 cf-shop `api/admin.ts`)。
|
|
6
|
+
*
|
|
7
|
+
* 复制到 `src/composables/useAdminRequest.ts`。
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
createAdminRequest,
|
|
11
|
+
type AdminRequestFn,
|
|
12
|
+
} from '@usethink/cf-admin-fe/composables'
|
|
13
|
+
import { createAdminUnauthorizedHandler } from '@usethink/cf-admin-fe/utils'
|
|
14
|
+
import { readAdminToken, useAdminAuth } from './useAdminAuth'
|
|
15
|
+
|
|
16
|
+
// 接入产品会话清理 — shop 通常只删除 admin_token;
|
|
17
|
+
// 抽奖产品可能调用完整业务会话清空。
|
|
18
|
+
function clearProductSession() {
|
|
19
|
+
try {
|
|
20
|
+
useAdminAuth().clearToken()
|
|
21
|
+
} catch {
|
|
22
|
+
/* 忽略 */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const handleUnauthorized = createAdminUnauthorizedHandler({
|
|
27
|
+
clearSession: clearProductSession,
|
|
28
|
+
loginPath: '/admin/login',
|
|
29
|
+
defaultRedirectPath: '/admin',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const adminRequest: AdminRequestFn = createAdminRequest({
|
|
33
|
+
getToken: () => readAdminToken(),
|
|
34
|
+
resultMode: 'ok-envelope',
|
|
35
|
+
errorField: 'message',
|
|
36
|
+
onUnauthorized: handleUnauthorized,
|
|
37
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* 仅提供管理端登录页的展示性外壳。
|
|
4
|
+
* 认证流程(token / 密码 / OTP / OAuth)由业务产品自行实现。
|
|
5
|
+
*/
|
|
6
|
+
defineProps<{
|
|
7
|
+
/** #title 插槽为空时的回退标题 */
|
|
8
|
+
title?: string
|
|
9
|
+
/** #subtitle 插槽为空时的回退副标题 */
|
|
10
|
+
subtitle?: string
|
|
11
|
+
}>()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="admin-login">
|
|
16
|
+
<div class="login-card">
|
|
17
|
+
<div v-if="$slots.title || $slots.subtitle || title || subtitle" class="login-header">
|
|
18
|
+
<slot name="title">
|
|
19
|
+
<h1 v-if="title" class="login-title">{{ title }}</h1>
|
|
20
|
+
</slot>
|
|
21
|
+
<slot name="subtitle">
|
|
22
|
+
<p v-if="subtitle" class="login-subtitle">{{ subtitle }}</p>
|
|
23
|
+
</slot>
|
|
24
|
+
</div>
|
|
25
|
+
<slot />
|
|
26
|
+
<div v-if="$slots.footer" class="login-footer">
|
|
27
|
+
<slot name="footer" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<AdminModal v-model="innerVisible" :title="$t('adminLogs.metadataTitle')" max-width="640px">
|
|
3
|
+
<div v-if="log" class="detail-box">
|
|
4
|
+
<div class="detail-section">
|
|
5
|
+
<div class="detail-title">{{ $t('adminLogs.basicInfo') }}</div>
|
|
6
|
+
<div class="detail-row">
|
|
7
|
+
<span class="detail-label">{{ $t('adminLogs.labelAction') }}</span>
|
|
8
|
+
<span class="detail-value">{{ log.action }}</span>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="detail-row">
|
|
11
|
+
<span class="detail-label">{{ $t('adminLogs.labelTargetType') }}</span>
|
|
12
|
+
<span class="detail-value">{{ log.targetType }}</span>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="detail-row">
|
|
15
|
+
<span class="detail-label">{{ $t('adminLogs.labelTargetId') }}</span>
|
|
16
|
+
<span class="detail-value">{{ log.targetId }}</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="detail-row">
|
|
19
|
+
<span class="detail-label">{{ $t('adminLogs.labelTime') }}</span>
|
|
20
|
+
<span class="detail-value">{{ formatDate(log.createdAt) }}</span>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="detail-row">
|
|
23
|
+
<span class="detail-label">{{ $t('adminLogs.labelIpFingerprint') }}</span>
|
|
24
|
+
<span class="detail-value">{{ log.ipHash || '-' }}</span>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div v-if="log.metadata && Object.keys(log.metadata).length > 0" class="detail-section">
|
|
28
|
+
<div class="detail-title">{{ $t('adminLogs.labelMetadata') }}</div>
|
|
29
|
+
<pre class="detail-json">{{ formatMetadata(log.metadata) }}</pre>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</AdminModal>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { ref, watch } from 'vue'
|
|
37
|
+
import AdminModal from './AdminModal.vue'
|
|
38
|
+
import { formatDate } from '../utils/datetime'
|
|
39
|
+
import { formatMetadata } from '../utils/format-metadata'
|
|
40
|
+
|
|
41
|
+
export interface AdminLogEntry {
|
|
42
|
+
action: string
|
|
43
|
+
targetType: string
|
|
44
|
+
targetId: string
|
|
45
|
+
createdAt: string
|
|
46
|
+
ipHash?: string | null
|
|
47
|
+
metadata?: Record<string, unknown> | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const props = withDefaults(defineProps<{
|
|
51
|
+
modelValue: boolean
|
|
52
|
+
log: AdminLogEntry | null
|
|
53
|
+
}>(), {
|
|
54
|
+
modelValue: false,
|
|
55
|
+
log: null,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const emit = defineEmits<{
|
|
59
|
+
'update:modelValue': [value: boolean]
|
|
60
|
+
}>()
|
|
61
|
+
|
|
62
|
+
const innerVisible = ref(false)
|
|
63
|
+
|
|
64
|
+
watch(() => props.modelValue, (val) => {
|
|
65
|
+
innerVisible.value = val
|
|
66
|
+
}, { immediate: true })
|
|
67
|
+
|
|
68
|
+
watch(innerVisible, (val, wasVisible) => {
|
|
69
|
+
if (!val && wasVisible && props.modelValue) {
|
|
70
|
+
emit('update:modelValue', false)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<style scoped>
|
|
76
|
+
.detail-box {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
gap: 16px;
|
|
80
|
+
}
|
|
81
|
+
.detail-section {
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-direction: column;
|
|
84
|
+
gap: 8px;
|
|
85
|
+
}
|
|
86
|
+
.detail-title {
|
|
87
|
+
font-size: 13px;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
color: var(--tg-hint, #9aa4b2);
|
|
90
|
+
text-transform: uppercase;
|
|
91
|
+
letter-spacing: 0.04em;
|
|
92
|
+
}
|
|
93
|
+
.detail-row {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: flex-start;
|
|
96
|
+
gap: 8px;
|
|
97
|
+
font-size: 13px;
|
|
98
|
+
line-height: 1.55;
|
|
99
|
+
}
|
|
100
|
+
.detail-label {
|
|
101
|
+
flex-shrink: 0;
|
|
102
|
+
width: 100px;
|
|
103
|
+
color: var(--tg-hint, #9aa4b2);
|
|
104
|
+
}
|
|
105
|
+
.detail-value {
|
|
106
|
+
color: var(--tg-text, #f0f2f5);
|
|
107
|
+
word-break: break-all;
|
|
108
|
+
}
|
|
109
|
+
.detail-json {
|
|
110
|
+
margin: 0;
|
|
111
|
+
padding: 10px 12px;
|
|
112
|
+
border-radius: var(--r-sm, 6px);
|
|
113
|
+
background: rgba(0, 0, 0, 0.2);
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
line-height: 1.55;
|
|
116
|
+
overflow-x: auto;
|
|
117
|
+
white-space: pre-wrap;
|
|
118
|
+
word-break: break-all;
|
|
119
|
+
color: var(--tg-text, #f0f2f5);
|
|
120
|
+
}
|
|
121
|
+
</style>
|