@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,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 产品管理端 HTTP 客户端工厂。
|
|
3
|
+
*
|
|
4
|
+
* CF 各产品差异点:
|
|
5
|
+
* - 认证:Bearer JWT(shop/lottery)vs cookie 会话(cf-auth)
|
|
6
|
+
* - 成功判定:仅 `res.ok` vs `res.ok && data.ok`
|
|
7
|
+
* - 错误字段:`error` vs `message`
|
|
8
|
+
* - 401 副作用:清 token / 整会话清空 / 跳转登录
|
|
9
|
+
* - Token 来源:闭包 `getToken` vs 每次调用传 `token`(shop 的 api/admin.ts)
|
|
10
|
+
*
|
|
11
|
+
* 业务 API 方法仍在产品侧;此处只统一 fetch、请求头与 401 处理。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type AdminRequestResultMode = 'http-ok' | 'ok-envelope'
|
|
15
|
+
|
|
16
|
+
/** 结构化错误,便于产品映射到 AdminApiError,无需再解析 message 字符串。 */
|
|
17
|
+
export class AdminRequestError extends Error {
|
|
18
|
+
readonly status: number
|
|
19
|
+
readonly code?: string
|
|
20
|
+
readonly data: Record<string, unknown>
|
|
21
|
+
readonly response?: Response
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
message: string,
|
|
25
|
+
status: number,
|
|
26
|
+
options?: {
|
|
27
|
+
code?: string
|
|
28
|
+
data?: Record<string, unknown>
|
|
29
|
+
response?: Response
|
|
30
|
+
},
|
|
31
|
+
) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'AdminRequestError'
|
|
34
|
+
this.status = status
|
|
35
|
+
this.code = options?.code
|
|
36
|
+
this.data = options?.data ?? {}
|
|
37
|
+
this.response = options?.response
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CreateAdminRequestOptions = {
|
|
42
|
+
/** 可选 API 前缀(如 `''` 或 origin)。path 仍可为绝对地址。 */
|
|
43
|
+
baseUrl?: string
|
|
44
|
+
/** 返回当前 Bearer token;省略/空 → 不带 Authorization */
|
|
45
|
+
getToken?: () => string | null | undefined
|
|
46
|
+
/** fetch credentials;cookie 管理端用 `include` */
|
|
47
|
+
credentials?: RequestCredentials
|
|
48
|
+
/**
|
|
49
|
+
* - `http-ok`:`res.ok` 即成功(cf-shop admin.ts 风格;body 可无 `ok`)
|
|
50
|
+
* - `ok-envelope`:`res.ok && data.ok`(lottery / 多数列表接口)
|
|
51
|
+
*/
|
|
52
|
+
resultMode?: AdminRequestResultMode
|
|
53
|
+
/** 构造 Error 时优先取 `error`(shop)或 `message`(lottery) */
|
|
54
|
+
errorField?: 'error' | 'message' | 'auto'
|
|
55
|
+
/** HTTP 401 且抛错前调用;产品负责清会话并跳转登录 */
|
|
56
|
+
onUnauthorized?: (ctx: {
|
|
57
|
+
path: string
|
|
58
|
+
data: Record<string, unknown>
|
|
59
|
+
response: Response
|
|
60
|
+
}) => void
|
|
61
|
+
/**
|
|
62
|
+
* 成功响应被接受后调用(http-ok / ok-envelope)。
|
|
63
|
+
* Cookie 管理端用来「治愈」缺失的本地守卫 flag,无需每个调用点包一层(cf-auth 模式)。
|
|
64
|
+
* Bearer 产品通常省略。
|
|
65
|
+
*/
|
|
66
|
+
onSuccess?: (ctx: {
|
|
67
|
+
path: string
|
|
68
|
+
data: Record<string, unknown>
|
|
69
|
+
response: Response
|
|
70
|
+
}) => void
|
|
71
|
+
/**
|
|
72
|
+
* 为 false 时:401 仍抛错,但跳过 onUnauthorized
|
|
73
|
+
* (登录探测 token 时不得清掉已有会话)。
|
|
74
|
+
*/
|
|
75
|
+
redirectOnUnauthorized?: boolean
|
|
76
|
+
/** 默认请求头,会被单次调用的 headers 覆盖合并 */
|
|
77
|
+
defaultHeaders?: HeadersInit
|
|
78
|
+
/**
|
|
79
|
+
* 跳过跳转时(token 探测)401 文案覆盖。
|
|
80
|
+
* Shop 使用固定「管理令牌无效」与 code INVALID_ADMIN_TOKEN。
|
|
81
|
+
*/
|
|
82
|
+
unauthorizedProbeMessage?: string
|
|
83
|
+
unauthorizedProbeCode?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type AdminRequestCallOptions = RequestInit & {
|
|
87
|
+
/** 查询参数;undefined / '' 会跳过 */
|
|
88
|
+
params?: Record<string, string | number | boolean | Array<string | number> | undefined | null>
|
|
89
|
+
/** 覆盖工厂对本调用的 redirectOnUnauthorized 默认 */
|
|
90
|
+
redirectOnUnauthorized?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* 单次调用的 Bearer token(shop 每个域 API 方法都会传入)。
|
|
93
|
+
* 已设置时(含空字符串)覆盖工厂 `getToken`。
|
|
94
|
+
*/
|
|
95
|
+
token?: string | null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type AdminRequestFn = <T = unknown>(
|
|
99
|
+
path: string,
|
|
100
|
+
options?: AdminRequestCallOptions,
|
|
101
|
+
) => Promise<T>
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 非 JSON 管理端请求(blob 模板 / 导出下载)。
|
|
105
|
+
* 不解析成功 body;只检查 `res.ok` 并处理 401。
|
|
106
|
+
*/
|
|
107
|
+
export type AdminRequestRawFn = (
|
|
108
|
+
path: string,
|
|
109
|
+
options?: AdminRequestCallOptions,
|
|
110
|
+
) => Promise<Response>
|
|
111
|
+
|
|
112
|
+
function joinUrl(baseUrl: string, path: string): string {
|
|
113
|
+
if (!baseUrl) return path
|
|
114
|
+
if (/^https?:\/\//i.test(path)) return path
|
|
115
|
+
const base = baseUrl.replace(/\/+$/, '')
|
|
116
|
+
const rel = path.startsWith('/') ? path : `/${path}`
|
|
117
|
+
return `${base}${rel}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function appendParams(
|
|
121
|
+
url: string,
|
|
122
|
+
params?: AdminRequestCallOptions['params'],
|
|
123
|
+
): string {
|
|
124
|
+
if (!params) return url
|
|
125
|
+
const query = new URLSearchParams()
|
|
126
|
+
for (const [key, value] of Object.entries(params)) {
|
|
127
|
+
if (value === undefined || value === null || value === '') continue
|
|
128
|
+
query.set(key, Array.isArray(value) ? value.join(',') : String(value))
|
|
129
|
+
}
|
|
130
|
+
const qs = query.toString()
|
|
131
|
+
if (!qs) return url
|
|
132
|
+
return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pickErrorMessage(
|
|
136
|
+
data: Record<string, unknown>,
|
|
137
|
+
status: number,
|
|
138
|
+
errorField: CreateAdminRequestOptions['errorField'],
|
|
139
|
+
): string {
|
|
140
|
+
const err = typeof data.error === 'string' ? data.error : ''
|
|
141
|
+
const msg = typeof data.message === 'string' ? data.message : ''
|
|
142
|
+
if (errorField === 'error') return err || msg || `请求失败(${status})`
|
|
143
|
+
if (errorField === 'message') return msg || err || `请求失败(${status})`
|
|
144
|
+
return msg || err || `请求失败(${status})`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pickCode(data: Record<string, unknown>): string | undefined {
|
|
148
|
+
return typeof data.code === 'string' ? data.code : undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSuccess(
|
|
152
|
+
res: Response,
|
|
153
|
+
data: Record<string, unknown>,
|
|
154
|
+
mode: AdminRequestResultMode,
|
|
155
|
+
): boolean {
|
|
156
|
+
if (!res.ok) return false
|
|
157
|
+
if (mode === 'http-ok') return true
|
|
158
|
+
// ok-envelope(lottery / 多数 CF 管理端 API):要求显式 ok:true
|
|
159
|
+
return data.ok === true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 构建可复用的 `adminRequest(path, init)`。
|
|
164
|
+
*/
|
|
165
|
+
export function createAdminRequest(factory: CreateAdminRequestOptions = {}): AdminRequestFn {
|
|
166
|
+
const baseUrl = factory.baseUrl ?? ''
|
|
167
|
+
const resultMode: AdminRequestResultMode = factory.resultMode ?? 'ok-envelope'
|
|
168
|
+
const errorField = factory.errorField ?? 'auto'
|
|
169
|
+
const defaultRedirect = factory.redirectOnUnauthorized !== false
|
|
170
|
+
|
|
171
|
+
return async function adminRequest<T = unknown>(
|
|
172
|
+
path: string,
|
|
173
|
+
options: AdminRequestCallOptions = {},
|
|
174
|
+
): Promise<T> {
|
|
175
|
+
const {
|
|
176
|
+
params,
|
|
177
|
+
redirectOnUnauthorized = defaultRedirect,
|
|
178
|
+
headers: callHeaders,
|
|
179
|
+
token: callToken,
|
|
180
|
+
...fetchOptions
|
|
181
|
+
} = options
|
|
182
|
+
|
|
183
|
+
const url = appendParams(joinUrl(baseUrl, path), params)
|
|
184
|
+
const headers = new Headers(factory.defaultHeaders)
|
|
185
|
+
if (callHeaders) {
|
|
186
|
+
const extra = new Headers(callHeaders)
|
|
187
|
+
extra.forEach((value, key) => headers.set(key, value))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const token =
|
|
191
|
+
callToken !== undefined ? callToken : factory.getToken?.()
|
|
192
|
+
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|
193
|
+
|
|
194
|
+
// 默认 JSON;FormData 须保留浏览器 multipart boundary。
|
|
195
|
+
if (!(fetchOptions.body instanceof FormData) && !headers.has('Content-Type')) {
|
|
196
|
+
headers.set('Content-Type', 'application/json')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const res = await fetch(url, {
|
|
200
|
+
...fetchOptions,
|
|
201
|
+
headers,
|
|
202
|
+
credentials: factory.credentials ?? fetchOptions.credentials,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
|
206
|
+
|
|
207
|
+
if (res.status === 401) {
|
|
208
|
+
if (redirectOnUnauthorized && factory.onUnauthorized) {
|
|
209
|
+
factory.onUnauthorized({ path, data, response: res })
|
|
210
|
+
}
|
|
211
|
+
const probe = !redirectOnUnauthorized
|
|
212
|
+
const message = probe
|
|
213
|
+
? factory.unauthorizedProbeMessage ||
|
|
214
|
+
pickErrorMessage(data, 401, errorField) ||
|
|
215
|
+
'登录已过期,请重新登录'
|
|
216
|
+
: pickErrorMessage(data, 401, errorField) || '登录已过期,请重新登录'
|
|
217
|
+
const code = probe
|
|
218
|
+
? factory.unauthorizedProbeCode || pickCode(data) || 'UNAUTHORIZED'
|
|
219
|
+
: pickCode(data) || 'UNAUTHORIZED'
|
|
220
|
+
throw new AdminRequestError(message, 401, { code, data, response: res })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!isSuccess(res, data, resultMode)) {
|
|
224
|
+
throw new AdminRequestError(pickErrorMessage(data, res.status, errorField), res.status, {
|
|
225
|
+
code: pickCode(data) || 'ADMIN_ERROR',
|
|
226
|
+
data,
|
|
227
|
+
response: res,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
factory.onSuccess?.({ path, data, response: res })
|
|
233
|
+
} catch {
|
|
234
|
+
/* 产品侧 heal 不得打断已成功的载荷 */
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return data as T
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 构建 raw `adminRequestRaw(path, init)`,用于二进制 / 非 JSON 响应。
|
|
243
|
+
* 与 {@link createAdminRequest} 共享 token、params、headers 与 401 行为。
|
|
244
|
+
*/
|
|
245
|
+
export function createAdminRequestRaw(
|
|
246
|
+
factory: CreateAdminRequestOptions = {},
|
|
247
|
+
): AdminRequestRawFn {
|
|
248
|
+
const baseUrl = factory.baseUrl ?? ''
|
|
249
|
+
const errorField = factory.errorField ?? 'auto'
|
|
250
|
+
const defaultRedirect = factory.redirectOnUnauthorized !== false
|
|
251
|
+
|
|
252
|
+
return async function adminRequestRaw(
|
|
253
|
+
path: string,
|
|
254
|
+
options: AdminRequestCallOptions = {},
|
|
255
|
+
): Promise<Response> {
|
|
256
|
+
const {
|
|
257
|
+
params,
|
|
258
|
+
redirectOnUnauthorized = defaultRedirect,
|
|
259
|
+
headers: callHeaders,
|
|
260
|
+
token: callToken,
|
|
261
|
+
...fetchOptions
|
|
262
|
+
} = options
|
|
263
|
+
|
|
264
|
+
const url = appendParams(joinUrl(baseUrl, path), params)
|
|
265
|
+
const headers = new Headers(factory.defaultHeaders)
|
|
266
|
+
if (callHeaders) {
|
|
267
|
+
const extra = new Headers(callHeaders)
|
|
268
|
+
extra.forEach((value, key) => headers.set(key, value))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const token = callToken !== undefined ? callToken : factory.getToken?.()
|
|
272
|
+
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|
273
|
+
|
|
274
|
+
// raw 下载不强加 JSON Content-Type。
|
|
275
|
+
const res = await fetch(url, {
|
|
276
|
+
...fetchOptions,
|
|
277
|
+
headers,
|
|
278
|
+
credentials: factory.credentials ?? fetchOptions.credentials,
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (res.ok) {
|
|
282
|
+
// 与 createAdminRequest 对齐:raw 下载也可触发 cookie flag 治愈 / 产品钩子。
|
|
283
|
+
try {
|
|
284
|
+
factory.onSuccess?.({ path, data: {}, response: res })
|
|
285
|
+
} catch {
|
|
286
|
+
/* 产品侧 heal 不得打断已成功的下载 */
|
|
287
|
+
}
|
|
288
|
+
return res
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const data = (await res
|
|
292
|
+
.clone()
|
|
293
|
+
.json()
|
|
294
|
+
.catch(() => ({}))) as Record<string, unknown>
|
|
295
|
+
|
|
296
|
+
if (res.status === 401) {
|
|
297
|
+
if (redirectOnUnauthorized && factory.onUnauthorized) {
|
|
298
|
+
factory.onUnauthorized({ path, data, response: res })
|
|
299
|
+
}
|
|
300
|
+
const probe = !redirectOnUnauthorized
|
|
301
|
+
const message = probe
|
|
302
|
+
? factory.unauthorizedProbeMessage ||
|
|
303
|
+
pickErrorMessage(data, 401, errorField) ||
|
|
304
|
+
'登录已过期,请重新登录'
|
|
305
|
+
: pickErrorMessage(data, 401, errorField) || '登录已过期,请重新登录'
|
|
306
|
+
const code = probe
|
|
307
|
+
? factory.unauthorizedProbeCode || pickCode(data) || 'UNAUTHORIZED'
|
|
308
|
+
: pickCode(data) || 'UNAUTHORIZED'
|
|
309
|
+
throw new AdminRequestError(message, 401, { code, data, response: res })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
throw new AdminRequestError(
|
|
313
|
+
pickErrorMessage(data, res.status, errorField) || `管理端请求失败:${res.status}`,
|
|
314
|
+
res.status,
|
|
315
|
+
{
|
|
316
|
+
code: pickCode(data) || 'ADMIN_ERROR',
|
|
317
|
+
data,
|
|
318
|
+
response: res,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通用管理端 Bearer token 会话(localStorage + TTL)。
|
|
3
|
+
*
|
|
4
|
+
* 产品在其上叠加产品专属 key / 伪装 / 平台角色。
|
|
5
|
+
* Cookie 会话产品改用 {@link createAdminFlagSession}(无 Bearer)。
|
|
6
|
+
*/
|
|
7
|
+
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
|
8
|
+
|
|
9
|
+
export type StoredAdminToken = { token: string; expiry: number }
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_ADMIN_TOKEN_TTL_MS = 8 * 60 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
export type CreateAdminTokenSessionOptions = {
|
|
14
|
+
/** localStorage 键名(如 `admin_token`) */
|
|
15
|
+
storageKey: string
|
|
16
|
+
/** 会话有效期;默认 8 小时,对齐常见 verify-jwt 会话 JWT */
|
|
17
|
+
ttlMs?: number
|
|
18
|
+
/** 覆盖存储实现(测试用);默认在可用时用 `localStorage` */
|
|
19
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveStorage(
|
|
23
|
+
explicit?: CreateAdminTokenSessionOptions['storage'],
|
|
24
|
+
): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null {
|
|
25
|
+
if (explicit !== undefined) return explicit
|
|
26
|
+
try {
|
|
27
|
+
if (typeof localStorage === 'undefined') return null
|
|
28
|
+
return localStorage
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 解析已存储的 token 记录。
|
|
36
|
+
* - 含 token+expiry 的对象:遵守 TTL;过期则清除键
|
|
37
|
+
* - 旧版裸字符串:保留一次迁移(不自动清除)
|
|
38
|
+
*/
|
|
39
|
+
export function parseStoredAdminToken(
|
|
40
|
+
raw: string | null,
|
|
41
|
+
options?: { storageKey?: string; storage?: CreateAdminTokenSessionOptions['storage'] },
|
|
42
|
+
): string {
|
|
43
|
+
if (!raw) return ''
|
|
44
|
+
const storage = resolveStorage(options?.storage)
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw) as StoredAdminToken
|
|
47
|
+
if (typeof parsed.token === 'string' && typeof parsed.expiry === 'number') {
|
|
48
|
+
if (Date.now() < parsed.expiry) return parsed.token
|
|
49
|
+
if (options?.storageKey && storage) {
|
|
50
|
+
try {
|
|
51
|
+
storage.removeItem(options.storageKey)
|
|
52
|
+
} catch {
|
|
53
|
+
/* 忽略清除失败 */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
return ''
|
|
59
|
+
} catch {
|
|
60
|
+
return raw
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readAdminTokenFromStorage(
|
|
65
|
+
storageKey: string,
|
|
66
|
+
storage?: CreateAdminTokenSessionOptions['storage'],
|
|
67
|
+
): string {
|
|
68
|
+
const store = resolveStorage(storage)
|
|
69
|
+
if (!store) return ''
|
|
70
|
+
try {
|
|
71
|
+
return parseStoredAdminToken(store.getItem(storageKey), { storageKey, storage: store })
|
|
72
|
+
} catch {
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type AdminTokenSession = {
|
|
78
|
+
token: Ref<string>
|
|
79
|
+
isLoggedIn: ComputedRef<boolean>
|
|
80
|
+
setToken: (value: string) => void
|
|
81
|
+
clearToken: () => void
|
|
82
|
+
readToken: () => string
|
|
83
|
+
storageKey: string
|
|
84
|
+
ttlMs: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 单个管理端 token 键的 Vue 会话句柄。
|
|
89
|
+
* 在 setup 外的路由守卫中调用 `readToken`。
|
|
90
|
+
*/
|
|
91
|
+
export function createAdminTokenSession(
|
|
92
|
+
options: CreateAdminTokenSessionOptions,
|
|
93
|
+
): AdminTokenSession {
|
|
94
|
+
const storageKey = options.storageKey
|
|
95
|
+
const ttlMs = options.ttlMs ?? DEFAULT_ADMIN_TOKEN_TTL_MS
|
|
96
|
+
const storage = resolveStorage(options.storage)
|
|
97
|
+
|
|
98
|
+
function readToken(): string {
|
|
99
|
+
return readAdminTokenFromStorage(storageKey, storage)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const token = ref<string>(readToken())
|
|
103
|
+
const isLoggedIn = computed(() => !!token.value)
|
|
104
|
+
|
|
105
|
+
function setToken(value: string) {
|
|
106
|
+
const stored: StoredAdminToken = { token: value, expiry: Date.now() + ttlMs }
|
|
107
|
+
token.value = value
|
|
108
|
+
if (!storage) return
|
|
109
|
+
try {
|
|
110
|
+
storage.setItem(storageKey, JSON.stringify(stored))
|
|
111
|
+
} catch {
|
|
112
|
+
/* 隐私模式 */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearToken() {
|
|
117
|
+
token.value = ''
|
|
118
|
+
if (!storage) return
|
|
119
|
+
try {
|
|
120
|
+
storage.removeItem(storageKey)
|
|
121
|
+
} catch {
|
|
122
|
+
/* ignore */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
token,
|
|
128
|
+
isLoggedIn,
|
|
129
|
+
setToken,
|
|
130
|
+
clearToken,
|
|
131
|
+
readToken,
|
|
132
|
+
storageKey,
|
|
133
|
+
ttlMs,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 便捷组合式:默认键 `admin_token`、8 小时 TTL(cf-shop 形态)。
|
|
139
|
+
* 多租户 / 伪装登录产品应直接调用 {@link createAdminTokenSession}。
|
|
140
|
+
*/
|
|
141
|
+
export function useAdminTokenSession(
|
|
142
|
+
options?: Partial<CreateAdminTokenSessionOptions>,
|
|
143
|
+
): AdminTokenSession {
|
|
144
|
+
return createAdminTokenSession({
|
|
145
|
+
storageKey: options?.storageKey ?? 'admin_token',
|
|
146
|
+
ttlMs: options?.ttlMs,
|
|
147
|
+
storage: options?.storage,
|
|
148
|
+
})
|
|
149
|
+
}
|
package/src/composables/index.ts
CHANGED
|
@@ -31,4 +31,55 @@ export {
|
|
|
31
31
|
export {
|
|
32
32
|
writeClipboardText,
|
|
33
33
|
copyText,
|
|
34
|
+
copyWithToast,
|
|
35
|
+
type CopyWithToastOptions,
|
|
34
36
|
} from './useClipboard'
|
|
37
|
+
|
|
38
|
+
export {
|
|
39
|
+
createAdminTokenSession,
|
|
40
|
+
useAdminTokenSession,
|
|
41
|
+
parseStoredAdminToken,
|
|
42
|
+
readAdminTokenFromStorage,
|
|
43
|
+
DEFAULT_ADMIN_TOKEN_TTL_MS,
|
|
44
|
+
type StoredAdminToken,
|
|
45
|
+
type CreateAdminTokenSessionOptions,
|
|
46
|
+
type AdminTokenSession,
|
|
47
|
+
} from './createAdminTokenSession'
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
createAdminFlagSession,
|
|
51
|
+
readAdminFlagFromStorage,
|
|
52
|
+
writeAdminFlagToStorage,
|
|
53
|
+
DEFAULT_ADMIN_FLAG_TRUTHY,
|
|
54
|
+
type CreateAdminFlagSessionOptions,
|
|
55
|
+
type AdminFlagSession,
|
|
56
|
+
} from './createAdminFlagSession'
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
createAdminRequest,
|
|
60
|
+
createAdminRequestRaw,
|
|
61
|
+
AdminRequestError,
|
|
62
|
+
type CreateAdminRequestOptions,
|
|
63
|
+
type AdminRequestCallOptions,
|
|
64
|
+
type AdminRequestFn,
|
|
65
|
+
type AdminRequestRawFn,
|
|
66
|
+
type AdminRequestResultMode,
|
|
67
|
+
} from './createAdminRequest'
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
useAdminSidebar,
|
|
71
|
+
type UseAdminSidebarOptions,
|
|
72
|
+
type UseAdminSidebarReturn,
|
|
73
|
+
} from './useAdminSidebar'
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
useAdminListLoader,
|
|
77
|
+
type UseAdminListLoaderOptions,
|
|
78
|
+
type UseAdminListLoaderReturn,
|
|
79
|
+
type AdminListLoadContext,
|
|
80
|
+
type AdminListRunLoadOptions,
|
|
81
|
+
} from './useAdminListLoader'
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
useNow,
|
|
85
|
+
} from './useNow'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台列表通用的加载生命周期:loading / loadError / 竞态安全的序号。
|
|
3
|
+
* 业务请求、列定义、筛选逻辑保留在产品页面中。
|
|
4
|
+
*/
|
|
5
|
+
import { ref, type Ref } from 'vue'
|
|
6
|
+
|
|
7
|
+
export type UseAdminListLoaderOptions = {
|
|
8
|
+
/** 初始加载状态(默认 false)。 */
|
|
9
|
+
initialLoading?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AdminListLoadContext = {
|
|
13
|
+
/** 本次尝试的单调递增序号。 */
|
|
14
|
+
sequence: number
|
|
15
|
+
/** 若有更新的加载已开始(或调用了 invalidate),返回 true。 */
|
|
16
|
+
isStale: () => boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type AdminListRunLoadOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* 将抛出的错误映射为面向用户的 loadError 字符串。
|
|
22
|
+
* 默认:Error.message 或 String(err)。
|
|
23
|
+
*/
|
|
24
|
+
onError?: (err: unknown) => string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type UseAdminListLoaderReturn = {
|
|
28
|
+
loading: Ref<boolean>
|
|
29
|
+
loadError: Ref<string>
|
|
30
|
+
/** 递增序号、置 loading 为 true、清空 loadError;返回本次尝试的序号。 */
|
|
31
|
+
beginLoad: () => number
|
|
32
|
+
isStale: (sequence: number) => boolean
|
|
33
|
+
/** 仅当序号仍为最新时清除 loading。 */
|
|
34
|
+
finishLoad: (sequence: number) => void
|
|
35
|
+
/**
|
|
36
|
+
* 若序号仍为最新则记录错误信息。
|
|
37
|
+
* @returns 错误是否已生效(未被判定为过期)
|
|
38
|
+
*/
|
|
39
|
+
failLoad: (sequence: number, message: string) => boolean
|
|
40
|
+
/**
|
|
41
|
+
* 完整且竞态安全的加载流程:begin → work → catch → finish。
|
|
42
|
+
* work 内部在 await 之后应通过 `ctx.isStale()` 提前返回。
|
|
43
|
+
* @returns 若尝试未抛错且 try 结束时未过期,返回 true
|
|
44
|
+
*/
|
|
45
|
+
runLoad: (
|
|
46
|
+
work: (ctx: AdminListLoadContext) => void | Promise<void>,
|
|
47
|
+
options?: AdminListRunLoadOptions,
|
|
48
|
+
) => Promise<boolean>
|
|
49
|
+
/** 使进行中的加载失效(例如组件卸载时)。 */
|
|
50
|
+
invalidate: () => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultErrorMessage(err: unknown): string {
|
|
54
|
+
if (err instanceof Error && err.message) return err.message
|
|
55
|
+
if (typeof err === 'string' && err) return err
|
|
56
|
+
return '加载失败'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useAdminListLoader(
|
|
60
|
+
options: UseAdminListLoaderOptions = {},
|
|
61
|
+
): UseAdminListLoaderReturn {
|
|
62
|
+
const loading = ref(options.initialLoading ?? false)
|
|
63
|
+
const loadError = ref('')
|
|
64
|
+
let loadSequence = 0
|
|
65
|
+
|
|
66
|
+
function beginLoad(): number {
|
|
67
|
+
const sequence = ++loadSequence
|
|
68
|
+
loading.value = true
|
|
69
|
+
loadError.value = ''
|
|
70
|
+
return sequence
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isStale(sequence: number): boolean {
|
|
74
|
+
return sequence !== loadSequence
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function finishLoad(sequence: number): void {
|
|
78
|
+
if (sequence === loadSequence) loading.value = false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function failLoad(sequence: number, message: string): boolean {
|
|
82
|
+
if (sequence !== loadSequence) return false
|
|
83
|
+
loadError.value = message
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runLoad(
|
|
88
|
+
work: (ctx: AdminListLoadContext) => void | Promise<void>,
|
|
89
|
+
runOptions: AdminListRunLoadOptions = {},
|
|
90
|
+
): Promise<boolean> {
|
|
91
|
+
const sequence = beginLoad()
|
|
92
|
+
const ctx: AdminListLoadContext = {
|
|
93
|
+
sequence,
|
|
94
|
+
isStale: () => isStale(sequence),
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await work(ctx)
|
|
98
|
+
if (isStale(sequence)) return false
|
|
99
|
+
return true
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (isStale(sequence)) return false
|
|
102
|
+
loadError.value = runOptions.onError?.(err) ?? defaultErrorMessage(err)
|
|
103
|
+
return false
|
|
104
|
+
} finally {
|
|
105
|
+
finishLoad(sequence)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function invalidate(): void {
|
|
110
|
+
loadSequence += 1
|
|
111
|
+
loading.value = false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
loading,
|
|
116
|
+
loadError,
|
|
117
|
+
beginLoad,
|
|
118
|
+
isStale,
|
|
119
|
+
finishLoad,
|
|
120
|
+
failLoad,
|
|
121
|
+
runLoad,
|
|
122
|
+
invalidate,
|
|
123
|
+
}
|
|
124
|
+
}
|