@usethink/cf-admin-fe 0.1.1 → 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,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
+ }
@@ -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'
@@ -1,3 +1,7 @@
1
+ /**
2
+ * 管理端逐条串行批处理:进度 ref + 成功/失败汇总。
3
+ * 服务端一次返回 `{ success, failed }` 的接口请改用 `buildBatchToast` / `resolveBatchToastType`。
4
+ */
1
5
  import { ref } from 'vue'
2
6
 
3
7
  export interface AdminBatchOperationResult<T> {
@@ -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
+ }