@usethink/cf-admin-fe 0.2.6 → 0.2.9

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  面向 Cloudflare 产品家族(`cf-shop`、`cf-lottery`、`cf-auth` 以及未来的 CF 管理端应用)的 Vue 3 **管理端基础套件(admin foundation)**。
4
4
 
5
- **状态:** **0.2.6** — 基础套件 + 双会话模型 + 错误/批处理助手 + 日志元数据/`offset` 查询 + **`copyWithToast`** + 列表 recipe。完整用法见 **[`docs/002_cf-admin-fe使用说明书与注意事项.md`](./docs/002_cf-admin-fe使用说明书与注意事项.md)**;起步清单见 `recipes/README.md`。
5
+ **状态:** **0.2.8** — P0 三件套:`resolveAdminT` / `tKit`、AdminShell/ConfigField/MetadataDetail 契约测、`size-report`。完整用法见 **[`docs/002_cf-admin-fe使用说明书与注意事项.md`](./docs/002_cf-admin-fe使用说明书与注意事项.md)**;起步清单见 `recipes/README.md`;路线图见 **[`docs/003_cf-admin-fe极致化方案与取舍_2026-08-05.md`](./docs/003_cf-admin-fe极致化方案与取舍_2026-08-05.md)**。
6
6
 
7
7
  **核心目标:** 最大化共享 **管理端前端基础套件**,让 CF 产品快速搭建 — **不**通过克隆业务页面实现,也 **不** 强迫每个产品套用 shop 的 Bearer 模式。
8
8
 
@@ -40,6 +40,22 @@
40
40
  - 坏:只有 cf-lottery 需要 → 留在产品内。
41
41
  - 坏:为了“一致性”而包装第三方日期选择器 → 在产品里用轻量专用库。
42
42
 
43
+ ### 与 `@usethink/cf-core` 的边界(正交分工,禁止越界)
44
+
45
+ 本套件与 cf-core 是**互补而非重叠**的两个包,职责分工固定:
46
+
47
+ | 域 | 归属包 | 示例 |
48
+ |---|---|---|
49
+ | 管理端前端(Vue 组件/composables/utils/i18n/styles) | **本包** | AdminShell、createAdminRequest、useTableSelection |
50
+ | Worker/API 基础设施(后端优先 + 前后端可用的纯 TS) | **cf-core** | http、crypto、rate-limit、JWT、features/email |
51
+ | storefront 端共享 composable | **cf-core** | features/telegram-miniapp(前台,非管理端) |
52
+
53
+ **硬性纪律(`npm run verify:boundaries` 强制执行)**:
54
+ - **禁止 `import` 自 `@usethink/cf-core`**:本包保持零运行时依赖(仅 peer `vue`),不被后端内核污染。
55
+ - **`dependencies` 必须为空**:任何新增运行时依赖都是越界(可选 peer 依赖需先在 docs/003 裁决)。
56
+ - **禁止 `import 'node:'` / 后端逻辑**:本包是浏览器/Vue 端,Node 专有 API 不得进入。
57
+ - 边界规则以本表为准,与 cf-core README 的 A′ 边界段互为单向引用,不复制全文(避免双份规则漂移)。
58
+
43
59
  ## 安装
44
60
 
45
61
  ```bash
@@ -263,11 +279,14 @@ import '@usethink/cf-admin-fe/styles/login-primitives.css' // .admin-login / .l
263
279
 
264
280
  ```bash
265
281
  npm install
282
+ npm run pre-release # type-check → test → size-report:check + 消费方 bump 提示(不 publish)
283
+ # 或分步:
266
284
  npm test
267
285
  npm run type-check
268
- npm run clean # 清空 dist/,避免过期声明混入
269
- npm run build # clean + 仅生成 composables/utils/types 的 .d.ts
270
- npm pack # 触发 prepack→build;入口仍以 src/ 为准
286
+ npm run size-report # CSS/源码规模;:check 相对 baseline 门禁
287
+ npm run clean # 清空 dist/,避免过期声明混入
288
+ npm run build # clean + 仅生成 composables/utils/types 的 .d.ts
289
+ npm pack # 触发 prepack→build;入口仍以 src/ 为准
271
290
  ```
272
291
 
273
292
  **封包注意:**
@@ -287,6 +306,9 @@ npm pack # 触发 prepack→build;入口仍以 src/ 为准
287
306
  - **0.2.5** — `createAdminFlagSession`、request `onSuccess` / credentials、cookie recipes(cf-auth 验证)
288
307
  - **0.2.6** — `toErrorMessage`、批量 toast/数量上限;`formatMetadata` + `AdminMetadataDetail`;`buildAdminOffsetParams` / `applyAdminOffsetParams`;`copyWithToast`;`recipes/list-page.snippet.ts`;封包 clean/prepack/LICENSE;完整说明书 `docs/002_…`
289
308
  - **0.2.6 文档收口(无 API 变更)** — README/recipes/001/002 对齐:string vs number offset、batch `limit` 须跟后端 max、shop §5.8 接线对照、MetadataDetail「新页优先」
309
+ - **0.2.7** — `.table-wrap` 去掉 `scrollbar-gutter: stable`,消除无溢出时表格右侧缺口
310
+ - **0.2.8** — `resolveAdminT` / `tKit`;AdminShell / ConfigField / AdminMetadataDetail 契约测;`npm run size-report`(CSS/源码规模 + runtime dep 门禁)
311
+ - **0.2.8 中端(无 API)** — `npm run pre-release`;001 R5 number offset 审计(维持不增 helper,推荐 `offset.value`);recipes「最小可跑通」清单
290
312
 
291
313
  ## 许可证
292
314
 
@@ -0,0 +1,14 @@
1
+ export type AdminI18nProxy = {
2
+ $t?: (key: string, params?: Record<string, unknown>) => unknown;
3
+ $te?: (key: string) => boolean;
4
+ } | null | undefined;
5
+ /**
6
+ * 纯函数:可在单测中直接注入 proxy,不依赖 setup。
7
+ */
8
+ export declare function resolveAdminT(proxy: AdminI18nProxy, key: string, fallback: string, params?: Record<string, unknown>): string;
9
+ /**
10
+ * 从当前组件实例的 appContext.globalProperties 读取 `$t` / `$te`。
11
+ * 使用 globalProperties 而非 `proxy.$t`,避免未安装 vue-i18n 时访问实例代理触发 Vue warn。
12
+ * 勿在模块顶层调用。
13
+ */
14
+ export declare function tKit(key: string, fallback: string, params?: Record<string, unknown>): string;
@@ -1,6 +1,7 @@
1
1
  export { formatDate, toDateTimeLocalValue, dateTimeLocalToIso, isoToDateTimeLocal, formatIpFingerprint, } from './datetime';
2
2
  export { downloadCsv, safeCsvCell } from './csv-export';
3
3
  export { lockBodyScroll, unlockBodyScroll, isBodyScrollLocked, __resetBodyScrollLockForTests, } from './body-scroll-lock';
4
+ export { collectModalFocusable, handleModalTabTrap, blockOverlayTabKeydown, } from './modal-focus-trap';
4
5
  export { normalizeCents, formatCents, parseYuanToCents } from './currency-cents';
5
6
  export { createAdminUnauthorizedHandler, decideAdminRouteAccess, resolveAdminLoginRedirect, type CreateAdminUnauthorizedHandlerOptions, type AdminRouteAuthDecision, type DecideAdminRouteAccessOptions, type ResolveAdminLoginRedirectOptions, } from './admin-navigation';
6
7
  export { groupDefinitionsByName, countSectionItems, buildActiveGroupedDefinitions, countAdvancedItems, resolveSystemConfigSectionId, getSystemConfigSection, listedSystemConfigGroups, auditSystemConfigSectionCoverage, type SystemConfigDefinitionLike, type SystemConfigSectionLike, type SystemConfigGroupedBlock, type BuildActiveGroupedOptions, type SystemConfigCoverageAudit, } from './system-config-engine';
@@ -10,3 +11,4 @@ export { hasBatchFailures, resolveBatchToastType, formatBatchCountsMessage, buil
10
11
  export { DEFAULT_ADMIN_BATCH_LIMIT, checkAdminBatchLimit, type AdminBatchLimitOk, type AdminBatchLimitExceeded, type AdminBatchLimitCheck, type CheckAdminBatchLimitOptions, } from './batch-limit';
11
12
  export { formatMetadata, } from './format-metadata';
12
13
  export { buildAdminOffsetParams, applyAdminOffsetParams, type AdminOffsetParams, } from './admin-query';
14
+ export { resolveAdminT, tKit, type AdminI18nProxy, } from './i18n';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 弹窗焦点陷阱纯函数(docs/326 · 与 cf-lottery modal-focus-trap 对齐)
3
+ *
4
+ * 与 body-scroll-lock 分工:scroll lock 由 AdminModal 统一;各弹窗只负责 Tab 循环 + 初始 focus。
5
+ */
6
+ /** 收集弹窗内可聚焦元素(顺序与 DOM 一致) */
7
+ export declare function collectModalFocusable(root: HTMLElement): HTMLElement[];
8
+ /** 弹窗根节点内 Tab 循环 */
9
+ export declare function handleModalTabTrap(e: KeyboardEvent, root: HTMLElement | null): void;
10
+ /** 遮罩层:禁止 Tab 把焦点漏到背景页 */
11
+ export declare function blockOverlayTabKeydown(e: KeyboardEvent): void;
@@ -1,10 +1,10 @@
1
1
  # cf-admin-fe 提取差距分析报告(结案版)
2
2
 
3
3
  **原日期:** 2026-08-05
4
- **结案修订:** 2026-08-05(对照仓库实况;含 shop §5.8 接线与文档收口)
5
- **项目:** `@usethink/cf-admin-fe`(**v0.2.6** — foundation + cookie/flag + 错误/批处理助手)
4
+ **结案修订:** 2026-08-05(对照仓库实况;含 shop §5.8 接线、0.2.8 P0、R5 number offset 审计笔记)
5
+ **项目:** `@usethink/cf-admin-fe`(**v0.2.8** — P0 契约测 / tKit / size-report;foundation 自 0.2.6)
6
6
  **源项目:** cf-shop / cf-lottery / cf-auth frontends
7
- **状态:** foundation **0.2.6 封版维护态**(无阻塞 API 缺口;三端 type-check/build/测试已对齐);**cf-auth 角色 = 检验 cookie 路径,不是 shop 克隆目标**;文档与源码注释以**中文**为准
7
+ **状态:** foundation **维护态**(无阻塞 API 缺口);**cf-auth 角色 = 检验 cookie 路径,不是 shop 克隆目标**;文档与源码注释以**中文**为准
8
8
 
9
9
  > 本文原为差距清单。实施后多项已落地,**勿再把全文当待办路线图**。
10
10
  > 历史错误 claim(如 kit 无 `requirePhrase`、`./components` 未导出、adminLogs「60+ 完全一致」)已纠正。
@@ -177,12 +177,30 @@
177
177
 
178
178
  | 候选 | 现状 | 触发条件 |
179
179
  |---|---|---|
180
- | `buildAdminOffsetNumbers`(number limit/offset) | shop 手写 1 行公式足够 | ≥2 产品出现同形 number filter 且复制粘贴成税 |
180
+ | `buildAdminOffsetNumbers`(number limit/offset) | **2026-08-05 审计:维持不增 API**(见下方笔记) | 仅当 ≥2 产品出现**无法**用 `useTablePagination().offset` 表达、且同形复制成税时再评估 |
181
181
  | `copyWithToast` silent / success-fallback 选项 | 开启/创建链等产品语义分支 | ≥2 处同一套「失败仍业务成功」稳定复制 |
182
182
  | cursor 分页 helper | 字段命名因产品而异 | ≥2 产品同一 cursor/`hasMore` 约定 |
183
183
  | batch 默认 limit「产品 profile」工厂 | 显式 `{ limit: N }` 已够清晰 | 三端都厌倦每次写 limit **且** N 集合稳定 |
184
184
 
185
- **维护文档真相源:** 接线细节以 [`002` §5.8](./002_cf-admin-fe使用说明书与注意事项.md) 为准;本文只记决策与结案,**勿把 R5 当立即开工的 backlog**。
185
+ #### R5 审计笔记 — number offset(2026-08-05,对照源码,非记忆)
186
+
187
+ | 产品 | number body / filter 形态 | string query(`buildAdminOffsetParams`) |
188
+ |------|---------------------------|------------------------------------------|
189
+ | **cf-shop** | `AdminRechargesView` / `AdminVouchersView` / `AdminBalanceView`(账户+流水)手写 `offset: (page - 1) * limit`(**4 处**);**无** `buildAdminOffsetParams` | 无(1-based 页码 → string query 列表几乎无) |
190
+ | **cf-lottery** | `AdminLotteryView` 抽奖列表 **1 处** 手写同一公式 | 平台审计 / 租户 / webhook / 兑换码 **4 页** 已用 string helper |
191
+ | **cf-auth** | `AdminUsers` / `AdminClients` / `AdminBlocklist` 等:`limit: limit.value, offset: offset.value`(**直接用** `useTablePagination` 的 `offset` ComputedRef) | 无(本审计未检出 string helper 列表) |
192
+
193
+ **结论(钉死):**
194
+
195
+ 1. **不新增** `buildAdminOffsetNumbers` / 对称 number helper。
196
+ 2. 摩擦不是「缺工厂」,而是写法不统一:
197
+ - 推荐 number body:`{ limit: limit.value, offset: offset.value }`(pagination 已算好);
198
+ - 手写 `(page - 1) * limit` 与 `offset.value` **等价**,1 行不算提取税。
199
+ 3. string 与 number **契约不同**(URLSearchParams vs 类型化 filter),继续用现有 string helper;禁止为「对齐」互转。
200
+ 4. 触发条件收紧:只有出现 **无法** 用现有 `offset` 表达的同形 number 拼装(例如额外字段包装、多 offset 轴)且跨产品复制,才重开评估。
201
+
202
+ **维护文档真相源:** 接线细节以 [`002` §5.8](./002_cf-admin-fe使用说明书与注意事项.md) 为准;本文只记决策与结案,**勿把 R5 当立即开工的 backlog**。
203
+ **后续取舍与近端路线(含 P0 三件套、脚手架/文档站冻结条件):** 见 [`003` 极致化方案与取舍](./003_cf-admin-fe极致化方案与取舍_2026-08-05.md)。
186
204
 
187
205
  ---
188
206
 
@@ -1,9 +1,9 @@
1
1
  # @usethink/cf-admin-fe 使用说明书与注意事项
2
2
 
3
- > **版本:** 0.2.6(以 `package.json` 为准)
3
+ > **版本:** 0.2.8(以 `package.json` 为准)
4
4
  > **读者:** 新建 CF 管理端前端、或把既有 admin 接到本套件的开发者
5
5
  > **原则:** 只写仓库里真实存在的 API 与约束;不发明业务能力。
6
- > **配套:** 根 [`README.md`](../README.md)(API 面速查)· [`recipes/README.md`](../recipes/README.md)(复制清单)· [`docs/001_…差距分析`](./001_cf-admin-fe提取差距分析报告_2026-08-05.md)(提取决策背景)
6
+ > **配套:** 根 [`README.md`](../README.md)(API 面速查)· [`recipes/README.md`](../recipes/README.md)(复制清单)· [`docs/001_…差距分析`](./001_cf-admin-fe提取差距分析报告_2026-08-05.md)(提取决策背景)· [`docs/003_…极致化取舍`](./003_cf-admin-fe极致化方案与取舍_2026-08-05.md)(路线图与做/不做裁决,非 API 说明书)
7
7
 
8
8
  ---
9
9
 
@@ -283,16 +283,18 @@ applyAdminOffsetParams(
283
283
 
284
284
  非法页码 → 1;非法 limit → `fallbackLimit` 或 20。
285
285
 
286
- **number body 列表(shop 常见)手写即可:**
286
+ **number body 列表(shop / 部分 lottery 抽奖列表)— 推荐用 pagination 自带 offset:**
287
287
 
288
288
  ```ts
289
- await listApi({
290
- ...filters,
291
- limit: limit.value,
292
- offset: (page.value - 1) * limit.value, // number
293
- })
289
+ const { page, limit, offset, setTotal } = useTablePagination()
290
+ // 推荐(与手写公式等价,少一处算术):
291
+ await listApi({ limit: limit.value, offset: offset.value })
292
+ // 等价手写:
293
+ // await listApi({ limit: limit.value, offset: (page.value - 1) * limit.value })
294
294
  ```
295
295
 
296
+ 历史页面常见手写 `(page-1)*limit` 亦可,**不必**为对齐而改。**不要**为此新增 kit number helper(001 R5 审计)。
297
+
296
298
  **`runLoad` 推荐形态(string query):**
297
299
 
298
300
  ```ts
@@ -661,7 +663,7 @@ Vite 需 `dedupe: ['vue']`,避免套件与应用两份 Vue(peer 类型/运
661
663
  | 复制 | 多数路径 `copyWithToast`;lottery 开启/创建等有意 silent/success-fallback | 见 §5.2 |
662
664
  | CSV | `downloadCsv` / `safeCsvCell` reexport | |
663
665
  | 系统配置 | `ConfigField` + 产品 `system-config-sections` | 字段文案在产品 i18n |
664
- | **offset 查询** | **多为 number body**:`limit`/`offset` 数字手写在 `AdminBalance` / `AdminRecharges` / `AdminVouchers` filter 类型 | **不要**强行换成 `buildAdminOffsetParams`(它产出 **string**,给 URLSearchParams |
666
+ | **offset 查询** | **多为 number body**:`limit`/`offset` 数字;推荐 `offset: offset.value`(`useTablePagination` 已算好),或手写 `(page-1)*limit`(与 offset 等价) | **不要**强行换成 `buildAdminOffsetParams`(它产出 **string**,给 URLSearchParams);R5 审计结论见 001 |
665
667
  | **string limit/offset** | shop 几乎无 1-based 页码 → string query 列表 | **对照面在 cf-lottery**:平台审计 / 租户 / webhook / 兑换码已用 `buildAdminOffsetParams` |
666
668
  | **批量上限** | 服务端 batch:`Orders` 批量删、`Cards` 状态/删、`EmailLogs` 删、`Logs` 删 → `checkAdminBatchLimit(..., { limit: 200 })` | 后端 `z.array(...).max(200)`;**禁止**默认 20 误伤产品 |
667
669
  | 批量 UX | 逐条 `runSequential` + `resolveBatchToastType`;服务端汇总路径直接 toast | Coupons/Products 等逐条路径可按需再加 limit(产品策略) |
@@ -671,7 +673,7 @@ Vite 需 `dedupe: ['vue']`,避免套件与应用两份 Vue(peer 类型/运
671
673
 
672
674
  1. 升 `@usethink/cf-admin-fe` 后跑产品 `vue-tsc` + 相关 contract 测试。
673
675
  2. 新增 **URLSearchParams + limit/offset 字符串** 列表 → 用 `buildAdminOffsetParams(page, limit)` 或 `applyAdminOffsetParams`。
674
- 3. 新增 **number** filter offset 列表 → 继续手写 `(page-1)*limit` 数字;若 ≥2 产品同形再考虑 kit number helper。
676
+ 3. 新增 **number** filter offset 列表 → 优先 `offset: offset.value`(或等价手写 `(page-1)*limit`);**不**新增 kit number helper(001 R5 审计 2026-08-05:维持不增 API)。
675
677
  4. 新增服务端 `max(N)` 批量 API → `checkAdminBatchLimit(ids, { limit: N, unit })`,N 跟后端一致。
676
678
  5. 不要为了「对齐文档示例」把 number body 改成 string query。
677
679
 
@@ -688,7 +690,7 @@ Vite 需 `dedupe: ['vue']`,避免套件与应用两份 Vue(peer 类型/运
688
690
  - [ ] 路由:`decideAdminRouteAccess` + 登录页 `resolveAdminLoginRedirect`
689
691
  - [ ] `AdminShell` 布局 + 产品菜单
690
692
  - [ ] 登录 UI 包在 `AdminLoginShell`
691
- - [ ] 第一页列表:`useAdminListLoader` + `useTablePagination` + **按契约选分页**:string query → `buildAdminOffsetParams`;number body → 手写数字 offset(见 §5.8)
693
+ - [ ] 第一页列表:`useAdminListLoader` + `useTablePagination` + **按契约选分页**:string query → `buildAdminOffsetParams`;number body → `offset.value` 或等价手写(见 §5.8 / 001 R5
692
694
  - [ ] 多选页:`useTableSelection` + `checkAdminBatchLimit`(**`limit` 对齐后端 max**,默认 20 仅 auth 风格)+ batch toast
693
695
  - [ ] 复制:`copyWithToast`;特殊降级才 `writeClipboardText`
694
696
  - [ ] 错误:`toErrorMessage`
@@ -746,7 +748,7 @@ cf-admin-fe/
746
748
  - 权限指令 / RBAC 组件
747
749
  - 图表、富文本、表单 schema 引擎
748
750
  - 与后端共享的 RPC/SDK
749
- - **number** 版 `buildAdminOffsetParams`(观察期,见 001 **R5** / §5.8 升级清单第 3 条)
751
+ - **number** 版 `buildAdminOffsetParams`(**R5 审计后默认不增**;见 001 R5 笔记 / §5.8 升级清单第 3 条)
750
752
  - 强迫旧审计页迁 `AdminMetadataDetail`(组件留给**新页**)
751
753
 
752
754
  若新产品需要上述能力:在产品内选型;当 **≥2 个 CF 管理端** 出现可复用的同一薄封装时,再提案进入 `cf-admin-fe`。
@@ -767,11 +769,18 @@ cf-admin-fe/
767
769
  ### 10.2 封版前自检(kit)
768
770
 
769
771
  ```bash
770
- npm test # 预期全绿(约 122)
772
+ # 推荐一键(003 中端):type-check → test size-report:check + 打印消费方 bump 提示
773
+ npm run pre-release
774
+
775
+ # 或分步:
771
776
  npm run type-check
777
+ npm test # 0.2.8:31 文件 / 149 用例量级(以本地输出为准)
778
+ npm run size-report:check
772
779
  npm run build # 生成 dist 声明;可选 npm pack 干跑
773
780
  ```
774
781
 
782
+ `pre-release` **不会**改 version、publish 或改三仓依赖;通过后人工 bump / 发版。
783
+
775
784
  ### 10.3 三端接线冻结摘要(部署对照)
776
785
 
777
786
  | 产品 | 会话 | 本版关键接线 | 部署前至少 |
@@ -784,7 +793,8 @@ npm run build # 生成 dist 声明;可选 npm pack 干跑
784
793
 
785
794
  ### 10.4 明确不进本封版
786
795
 
787
- R5 观察项(number offset helper、silent copy 选项、cursor helper、batch profile 工厂)、旧页迁 `AdminMetadataDetail`、领域 CRUD、C 端 storefront。
796
+ R5 观察项(number offset helper、silent copy 选项、cursor helper、batch profile 工厂)、旧页迁 `AdminMetadataDetail`、领域 CRUD、C 端 storefront。
797
+ 后续「该不该做、做到什么程度」见 [`003` 极致化方案与取舍](./003_cf-admin-fe极致化方案与取舍_2026-08-05.md)(P0 三件套 / 冻结项与触发条件);**勿把 003 当本说明书的 API 增补**。
788
798
 
789
799
  ---
790
800
 
@@ -797,5 +807,9 @@ R5 观察项(number offset helper、silent copy 选项、cursor helper、batch
797
807
  | 2026-08-05 | **P1–P3 文档收口(无 API)**:README/recipes/001/002 对称 string vs number offset、batch limit 跟后端 max、MetadataDetail 新页优先、R5 观察期 |
798
808
  | 2026-08-05 | **§10 封版与部署注意**;三端验证全绿后的冻结摘要 |
799
809
  | 2026-08-05 | **AdminShell 用户区上移顶栏右上**(省侧栏高度;`#sidebar-footer` 可选;窄屏可藏用户名) |
810
+ | 2026-08-05 | **0.2.7** `.table-wrap` 去掉 `scrollbar-gutter: stable`,消除全站表格右侧缺口 |
811
+ | 2026-08-05 | 配套链增加 [`003` 极致化方案与取舍](./003_cf-admin-fe极致化方案与取舍_2026-08-05.md);§10.4 指向 003 作路线图(非 API) |
812
+ | 2026-08-05 | **0.2.8** P0:`resolveAdminT`/`tKit`、SFC 契约测、`size-report`;版本锚点同步 |
813
+ | 2026-08-05 | **中端(无 API)**:`npm run pre-release`;001 R5 number offset 审计笔记(维持不增 API;推荐 `offset.value`);recipes 最小接入清单补强 |
800
814
 
801
815
  **维护约定:** API 变更时同步改本文件、根 README 公开 API 节、`recipes/README.md` 与 `tests/public-api.test.ts`。说明书只描述已实现行为;示例代码须与 `src/` 签名一致。
@@ -0,0 +1,333 @@
1
+ # @usethink/cf-admin-fe 极致化方案与取舍
2
+
3
+ > **版本锚点:** 以 `package.json` 为准(**0.2.8** 已落地 §9.1 P0 三件套;起草基线为 0.2.7)
4
+ > **日期:** 2026-08-05(P0 落地修订同日)
5
+ > **性质:** 路线图与决策备忘(**不是**待办全开的 backlog;**不是**对外 API 说明书)
6
+ > **配套:** [`002` 使用说明书](./002_cf-admin-fe使用说明书与注意事项.md) · [`001` 提取差距结案](./001_cf-admin-fe提取差距分析报告_2026-08-05.md) · 根 [`README.md`](../README.md) · [`recipes/README.md`](../recipes/README.md)
7
+
8
+ ---
9
+
10
+ ## 0. 文档怎么用
11
+
12
+ | 读者问题 | 读哪里 |
13
+ |----------|--------|
14
+ | 怎么接套件、有哪些 API | **002** + README + recipes |
15
+ | 当初抽了什么、故意不抽什么 | **001**(结案,勿当未完成清单) |
16
+ | **下一步该不该做、做到什么程度** | **本文 003** |
17
+ | 某条观察项(如 number offset)何时可提 API | 本文 §5.2 + 001 **R5** + 002 §5.8 / 不做清单 |
18
+
19
+ **维护约定:**
20
+
21
+ - 实施完成后,把对应条目改成「已落地(版本号)」,**不要**删决策理由。
22
+ - 新想法先对照 §1 定位与 §2 提取纪律;通不过则记入 §7「冻结 / 触发条件」,勿直接开码。
23
+ - 本文可随迭代修订;**破坏性结论变更**须在文末修订表留一行。
24
+
25
+ ---
26
+
27
+ ## 1. 核心定位(一切极致化的前提)
28
+
29
+ ### 1.1 一句话
30
+
31
+ `@usethink/cf-admin-fe` 是 **CF 产品族 Vue 3 管理端「业务模式」基础套件**,不是通用 UI 组件库,不是业务中台,不是 Worker/API 包。
32
+
33
+ ### 1.2 已验证边界(0.2.x 守住的)
34
+
35
+ | 是 | 不是 |
36
+ |----|------|
37
+ | 壳(AdminShell / LoginShell)、表格 UX、分页/选择/确认/Toast | Element / Ant 式通用控件集 |
38
+ | 双会话工厂(Bearer token / Cookie+flag)+ request 旋钮 | 产品域 `api/admin.ts` 方法全集 |
39
+ | ConfigField、列表 loader、错误/批量助手、offset **string** 查询助手 | 订单/抽奖/OAuth/RBAC 页面 |
40
+ | i18n **种子** + recipes 复制清单 | 「一条命令生成完整业务后台」的产品本身 |
41
+ | 在 **≥2 真实消费方**(或第 3 路径验证出的通用缺口)后提取 | 为对齐单一产品而抹平特殊性 |
42
+
43
+ **线上验证面(起草时):**
44
+
45
+ | 产品 | 会话 | 在套件中的角色 |
46
+ |------|------|----------------|
47
+ | **cf-shop** | Bearer | 主路径参考 |
48
+ | **cf-lottery** | Bearer + 平台/仿冒胶水 | Bearer 扩展;布局可有产品自有部分 |
49
+ | **cf-auth** | Cookie + flag | **检验 cookie 路径**;禁止把 OAuth/业务页抽进 kit |
50
+
51
+ ### 1.3 「极致」在本仓库的定义
52
+
53
+ 对 solo / 小团队维护者,**极致 = 减少摩擦 + 提高改动安全感**,**不是**功能表面积最大化。
54
+
55
+ 优先序:
56
+
57
+ 1. **改组件不踩雷**(契约可测)
58
+ 2. **重复逻辑有单一真相**(如 i18n fallback)
59
+ 3. **体积与依赖不偷偷膨胀**
60
+ 4. **提取纪律可执行**(≥2 同形,写进政策)
61
+ 5. 仅在摩擦真实变大时,再上脚手架 / 文档站 / 更深工厂
62
+
63
+ ---
64
+
65
+ ## 2. 治理纪律(建议升格为公开政策)
66
+
67
+ 以下三条应与 001/002 一致,并在评审新 PR 时当作硬门禁:
68
+
69
+ 1. **不进** Element/Ant 式通用组件库路线。
70
+ 2. **不抽** 仅单产品出现的页面编排(业务列定义、域 CRUD、产品特有批量语义)。
71
+ 3. **提取门槛:**
72
+ - 同形模式出现在 **≥2 个消费方**,或
73
+ - **同一产品 ≥3 处**稳定复制且契约不再摇摆,或
74
+ - **第 3 条路径**(如 cookie)验证出的通用缺口。
75
+
76
+ **禁止:** 为「看起来整齐」而预抽;为「对齐 shop」而抹平 auth。
77
+
78
+ ---
79
+
80
+ ## 3. 现状快照(相对极致化议题)
81
+
82
+ | 维度 | 起草时实况 | 含义 |
83
+ |------|------------|------|
84
+ | 测试 | ~27 个测试文件,**composable / utils 为主**;**8 个 `.vue` 几乎无渲染测** | 模板/插槽回归靠手测,套件信任短板 |
85
+ | 交付形态 | `exports` 指向 **`src/`**;`build` 主要产 **`.d.ts`**;CSS 在 `src/styles` | 体积 KPI 不能按「单一 runtime bundle」幻想来设计 |
86
+ | i18n | `AdminShell.tFallback` 与 `ConfigField.tSafe` **同构重复**;部分组件模板直接 `$t` | 收口 ROI 高、对外行为可兼容 |
87
+ | 请求层 | `createAdminRequest` 已有 `resultMode` / `credentials` 等;产品仍自写 `api/*` | 实例级配置已够用;路径级 registry 证据不足 |
88
+ | 分页 | `buildAdminOffsetParams` **仅 string**(URLSearchParams);number body 手写 | 与 001 **R5** / 002 一致,默认不扩 API |
89
+ | 样式 | `.table-wrap` 已在 **0.2.7** 去掉 `scrollbar-gutter: stable`(消除无溢出右侧缺口) | 表格以视觉对齐优先;弹层/卡片列表仍可 stable |
90
+ | 接入 | recipes **清单式复制**,无官方脚手架 | 3 内部消费方阶段可接受 |
91
+
92
+ ---
93
+
94
+ ## 4. 分层裁决总表
95
+
96
+ 图例:**采纳** = 应排期;**轻量采纳** = 做,但改写目标;**暂缓** = 有条件再开;**冻结** = 默认不做。
97
+
98
+ | 层级 | 议题 | 裁决 | 一句话理由 |
99
+ |------|------|------|------------|
100
+ | **P0** | 组件 SFC 契约测试 | **已落地(0.2.8)** | `tests/AdminShell|ConfigField|AdminMetadataDetail.test.ts` + happy-dom |
101
+ | **P0** | 体积 / 依赖监控 | **已落地(0.2.8)** | `scripts/size-report.mjs` + `size-baseline.json`;`size-report` / `:write` / `:check` |
102
+ | **P0** | 统一 i18n fallback(`tKit` 等) | **已落地(0.2.8)** | `resolveAdminT` / `tKit`;Shell + ConfigField 收口 |
103
+ | **P1** | `createAdminApi` 路径 resultMode 工厂 | **暂缓** | 第二真相源;现主流是一产品一 request 配置 |
104
+ | **P1** | `create-cf-admin` 脚手架 | **后置** | 模板双维护税 > 当前 onboarding 痛 |
105
+ | **P1** | VitePress 组件演示站 | **冻结(现阶段)** | 卖的是模式套件;002 已够内部用 |
106
+ | **P2** | number offset helper | **观察(R5)** | 先审计同形,默认不提 API |
107
+ | **P2** | Light 主题全集 | **冻结** | 产品族暗色;变量可覆盖即可 |
108
+ | **P2** | dist 过期「玄学」强化 | **低优** | prepack clean 对发布已够 |
109
+ | 架构 | AdminShell 注入式侧栏 | **有第二行为再做** | 现硬依赖 `useAdminSidebar` 合理 |
110
+ | 架构 | 源码交付改 dist-only runtime | **需单独 RFC** | 改变调试与消费模型,非默认极致 |
111
+
112
+ ---
113
+
114
+ ## 5. P0 — 高影响、值得做(细则)
115
+
116
+ ### 5.1 组件级测试(SFC / 契约测试)
117
+
118
+ **问题:** composable 测得再满,**插槽条件、cents 编辑、元数据安全回退**仍可能被「只改模板」弄坏。
119
+
120
+ **目标重写(重要):**
121
+
122
+ - 要的是 **行为契约**(渲染条件、emit、边界 props),不是行覆盖率表演。
123
+ - 与产品仓「读源码 contract」互补:
124
+ - **kit 内 SFC 测** = 套件契约
125
+ - **产品 contract** = 接线/业务约定
126
+
127
+ **建议优先级:**
128
+
129
+ | 优先级 | 组件 | 覆盖要点 |
130
+ |--------|------|----------|
131
+ | 必做 | `AdminShell` | `#sidebar-footer` 有/无才渲染;`showUser` / `showLogout`;`#header-actions` 与用户区相对位置;`navigate` / `logout` emit;窄屏名称可藏(若可稳定断言) |
132
+ | 必做 | `ConfigField` | cents 往返;枚举空校验;sensitive 占位;status 文案(saving/saved/error) |
133
+ | 应做 | `AdminMetadataDetail` | 空、非法结构、循环/过深时的安全回退展示 |
134
+ | 可后做 | `AdminPagination` / `ConfirmDialog` / `ToastContainer` | 逻辑多在 composable 已测则 **薄测** 即可 |
135
+ | 低 | `AdminLoginShell` / `AdminModal` | 结构 + 关键 a11y 点;少绑 class 字符串 |
136
+
137
+ **基建建议:** Vitest + `@vue/test-utils` + **happy-dom**(相对 jsdom 更轻)。
138
+ **反模式:** 为冲 80% 组件行覆盖写脆测试;断言大段 HTML 快照无说明。
139
+
140
+ **预期发版形态:** 行为兼容 → **0.2.x patch**(如与 i18n 收口同发 0.2.8)。
141
+
142
+ ---
143
+
144
+ ### 5.2 构建产物与「体积」监控(轻量版)
145
+
146
+ **问题重定义:**
147
+ 本包 **不是**「npm 只下 dist JS、运行全走打包产物」。消费者 Vite 直接编 **`src/*.vue` + CSS`**;`tsc` 的 `dist/` 以声明为主。
148
+ 因此「打一个假入口把全部 subpath 打成一个 app 再 gzip」可以做实验,但 **不宜当主 KPI**,否则会优化错对象。
149
+
150
+ **建议监控(按优先级):**
151
+
152
+ 1. **`src/styles/*.css` raw / gzip 体积**(`admin-primitives.css` 是大头之一)
153
+ 2. **按 exports 子路径的源码规模**(composables / components / utils / i18n:文件数、近似 LOC)——防 utils 黑洞
154
+ 3. **依赖门禁:** 除 `peerDependencies`(`vue`;可选 `vue-i18n`)外,**禁止悄悄增加 runtime dependency**(比多 2KB CSS 更致命)
155
+ 4. 可选:对 **纯 TS、无 SFC** 的子树做 esbuild metafile 抽样
156
+
157
+ **CI / 脚本形态:**
158
+
159
+ - `scripts/size-report.mjs`(或同等)输出可读报告 + 可提交的 baseline JSON
160
+ - **仅在明显膨胀时失败**(例如 CSS gzip +15% 且无说明),避免每次微改必红
161
+
162
+ **明确不做(现阶段):** 把套件改成「唯一预打包 runtime bundle」作为默认交付——除非另开 RFC 并改所有消费方调试习惯。
163
+
164
+ ---
165
+
166
+ ### 5.3 统一 i18n 辅助(P0 中 ROI 最高、风险最低)
167
+
168
+ **事实:** `AdminShell` 的 `tFallback` 与 `ConfigField` 的 `tSafe` 同为:
169
+
170
+ `getCurrentInstance()` → `proxy.$t` → 失败或 key 原样则 **fallback**(ConfigField 另支持 params)。
171
+
172
+ **建议 API 形态(实现时二选一,文档写死):**
173
+
174
+ | 形态 | 适用 | 注意 |
175
+ |------|------|------|
176
+ | `tKit(key, fallback, params?)` 在 **setup 内** 调用 | 组件 script | 依赖 `getCurrentInstance`,勿在模块顶层调用 |
177
+ | 纯函数 `resolveAdminT(proxy, key, fallback, params?)` | 可单测、可非 setup | 组件内一行薄封装 |
178
+
179
+ **策略必须统一(避免半套):**
180
+
181
+ - **A:** 套件组件展示文案 **全部** 走 `tKit` / `resolveAdminT`(无 vue-i18n 也能跑中文 fallback);或
182
+ - **B:** 文档强制消费方 `mergeAdminKitMessages`,模板可用 `$t`,但 **kit 内仍建议统一走 tKit** 以免漏 merge 时白屏/露 key。
183
+
184
+ 推荐 **A 为主**(与「vue-i18n 为 optional peer」一致)。
185
+
186
+ **范围:** 先收 `AdminShell`、`ConfigField`,再扫其他组件是否有第三份拷贝。
187
+ **发版:** 对外行为不变 → **patch**。
188
+
189
+ ---
190
+
191
+ ## 6. P1 — 中等影响,看 ROI
192
+
193
+ ### 6.1 通信协议:`createAdminApi` 路径级工厂 — **暂缓**
194
+
195
+ **设想:** 按 path 注册 `resultMode` / `errorField`,调用时自动匹配。
196
+
197
+ **暂缓理由(硬):**
198
+
199
+ - 现抽象已在 **`createAdminRequest` 实例级**;shop / auth / lottery 主流是 **一产品一配置**,不是「一 path 一 envelope」为主矛盾。
200
+ - 路径表会成为 **第二份路由真相**,与产品 `api/admin.ts` 双维护。
201
+ - 强类型 path→响应要么弱(近 `any`),要么维护税接近 OpenAPI。
202
+
203
+ **触发条件(同时满足再评估):**
204
+
205
+ - ≥2 产品在 **同一 request 实例内** 出现多 envelope,且手写分支重复;
206
+ - 配置表能明显减少重复,而不是只是搬家。
207
+
208
+ **更贴定位的替代:** 强化 recipes 中「如何组织 `api/admin.ts`」;必要时只加 **小纯函数**(如 envelope 断言),不建 path registry。
209
+
210
+ ---
211
+
212
+ ### 6.2 准入脚手架 `create-cf-admin` — **后置**
213
+
214
+ **痛点真实,但养模板仓库的成本更高:** kit 每个 API 变更要 **双改模板**,solo 最易腐烂。
215
+
216
+ **更极致、更便宜的中间态:**
217
+
218
+ 1. recipes 保持权威清单;
219
+ 2. 可选 **`examples/minimal`**(可运行的最小壳,仍非业务后台);
220
+ 3. 可选 **检查脚本**(是否 import styles、是否 merge i18n 种子)——**验证**优于**生成**。
221
+
222
+ **触发条件:** 第 4 个消费方(尤其外部)或公开 onboarding 压力明显,再开脚手架。
223
+
224
+ ---
225
+
226
+ ### 6.3 VitePress 文档站 — **现阶段冻结**
227
+
228
+ - 对内:002 + recipes 已是高水位。
229
+ - 对外:本包卖 **模式与纪律**,不是组件货架;Playground 维护税高。
230
+
231
+ **例外:** 若只需 ConfigField / AdminShell **静态一页 demo**,可放 recipes 或单页,**不上**完整文档站基建。
232
+
233
+ ---
234
+
235
+ ## 7. P2 与架构 — 低优先 / 触发后再做
236
+
237
+ ### 7.1 number 版 offset helper(R5)
238
+
239
+ - 说明书已标明:string 给 `URLSearchParams`;**number body 手写**。
240
+ - shop 全仓大量 `limit/offset` **不等于** 前端构造参数「同形」;服务层分页与 admin API 拼装要分开看。
241
+
242
+ **允许动作:** 审计 `cf-shop/frontend`(及 lottery)里 number body 分页构造;若 **稳定同形 ≥2 处跨产品或 ≥3 处单产品**,再提 `buildAdminOffsetBody` 之类。
243
+ **默认:** 不增 API;结论写回 001 R5 / 002。
244
+
245
+ ### 7.2 CSS 主题(Light)
246
+
247
+ - 产品族管理端 **暗色**;无第二套视觉 QA 面需求。
248
+ - **保持** CSS 变量可被消费方覆盖即可;kit **不维护**官方 light 主题包。
249
+
250
+ ### 7.3 dist 过期防护
251
+
252
+ - `prepack` → clean → build 对 **发布** 足够。
253
+ - IDE 误解析:以 `exports`→`src` 为准;不必为开发体验叠加复杂 outDir 策略。
254
+
255
+ ### 7.4 AdminShell ↔ `useAdminSidebar`
256
+
257
+ - 三家断点一致时,**硬依赖是合理默认**,不是缺陷。
258
+ - **注入整棵侧栏状态机** = 有第二套断点/行为时再做。
259
+ - 现阶段扩展优先靠 **已有插槽**(`#nav` / `#nav-extra` / `#sidebar-footer` / `#header-actions` / `#banner`)。
260
+
261
+ ### 7.5 模板中的 `$t`
262
+
263
+ - 根因是 **optional `vue-i18n`**,不是缺 DI 容器。
264
+ - 用 **统一 tKit + merge 种子契约** 收敛,优于「注入 i18n 实例」大重构。
265
+
266
+ ---
267
+
268
+ ## 8. 摩擦点:愿景 vs 可执行极致
269
+
270
+ | 摩擦点 | 常见「极致」想象 | **本仓库建议的极致** |
271
+ |--------|------------------|----------------------|
272
+ | 新产品接入 | `npx create-cf-admin` | recipes + 可选 minimal example + 可选检查脚本 |
273
+ | 改套件后验证 | 全自动消费方 monorepo CI | **kit SFC 契约测** + 发版前三仓 `vue-tsc`(可先脚本化) |
274
+ | 组件行为确认 | 读源码 / 全量快照 | **高风险 SFC 行为测** |
275
+ | 升级影响 | 类型断崖吓阻 | **0.2 尽量兼容**;破坏性进 0.3;changelog 记行为 |
276
+ | 是否该提取 | 自动模式挖掘工具 | **人工 ≥2 + R 观察列表**(工具 ROI 过低) |
277
+
278
+ ---
279
+
280
+ ## 9. 推荐落地路线图
281
+
282
+ ### 9.1 近端 — **0.2.8 三件套(已完成)**
283
+
284
+ 1. ~~抽取 **`tKit` / `resolveAdminT`**~~ → `src/utils/i18n.ts`;Shell / ConfigField 收口;`tests/i18n-resolve.test.ts`。
285
+ 2. ~~SFC 测试基建 + 契约测~~ → vitest `happy-dom`;AdminShell / ConfigField / AdminMetadataDetail。
286
+ 3. ~~**轻量 size-report**~~ → `npm run size-report` / `size-report:write` / `size-report:check`。
287
+
288
+ **刻意不碰(仍有效):** path 级 `createAdminApi`、脚手架、VitePress、双主题、注入式侧栏。
289
+
290
+ ### 9.2 中端(已落地,无 API / 无发版号 bump)
291
+
292
+ 4. ~~发版辅助脚本~~ → `scripts/pre-release.mjs` + `npm run pre-release`(type-check → test → size-report:check + 消费方 bump 提示;不 publish)。
293
+ 5. ~~number offset 审计笔记~~ → 001 **R5**:shop 4 处手写 + lottery 抽奖 1 处手写 + auth 用 `offset.value`;**维持不增** number helper;推荐 number body 用 `offset.value`。
294
+ 6. ~~recipes 最小接入补强~~ → `recipes/README.md` 增加「最小可跑通」核对清单(仍非脚手架 / 非 examples 应用)。
295
+
296
+ ### 9.3 冻结直到触发条件
297
+
298
+ | 项 | 触发条件(示例) |
299
+ |----|------------------|
300
+ | `createAdminApi` 路径表 | §6.1 条件 |
301
+ | `create-cf-admin` | 第 4 消费方或外部 onboarding 压力 |
302
+ | VitePress | 明确对外品牌/演示需求且有维护带宽 |
303
+ | Light 主题 | 真实产品浅色管理端需求 |
304
+ | 注入式侧栏 | 第二套断点/行为无法用插槽解决 |
305
+ | dist-only runtime | 书面 RFC + 消费方迁移窗 |
306
+
307
+ ---
308
+
309
+ ## 10. 与「极致」容易打架的三条钉死原则
310
+
311
+ 1. **源码交付是优势**(可跳进 SFC 调试);勿为「像标准组件库」默认改成仅 dist 运行时。
312
+ 2. **测试的极致是契约稳定**,不是测试文件数量或行覆盖率。
313
+ 3. **每多一个工厂 / 脚手架 / 主题,就多一条要与 shop·lottery·auth 同步的真理之源**;在 3 仓阶段,**少真相源才是极致**。
314
+
315
+ ---
316
+
317
+ ## 11. 总结(给决策者的一页)
318
+
319
+ - **定位与 ≥2 提取纪律:全盘坚持,并当 PR 门禁。**
320
+ - **P0 三件套已在 0.2.8 落地**(契约测 + i18n 收口 + size-report)。
321
+ - **API 路径工厂、脚手架、文档站、双主题:远见可记,默认冻结。**
322
+ - **R5 number offset:已审计,维持不增 API**(优先 `useTablePagination().offset`)。
323
+ - **中端三项已落地**(pre-release / R5 笔记 / recipes 最小接入清单);仍不扩冻结项。下一动作按触发条件或消费方 bump 0.2.8。
324
+
325
+ ---
326
+
327
+ ## 12. 修订记录
328
+
329
+ | 日期 | 说明 |
330
+ |------|------|
331
+ | 2026-08-05 | 初版:基于 0.2.7 现状与消费方(shop / lottery / auth)对照;固化 P0/P1/P2 裁决、触发条件与 0.2.8 三件套路线;明确极致定义=减摩擦+安全感而非扩面 |
332
+ | 2026-08-05 | **0.2.8 P0 落地**:`tKit`/`resolveAdminT`、SFC 契约测、size-report;§4/§9.1/§11 状态同步 |
333
+ | 2026-08-05 | **中端落地(无 API)**:`pre-release`、R5 number offset 审计结案、recipes 最小接入清单;§9.2/§11 同步 |
@@ -0,0 +1,81 @@
1
+ # SHARED cf-admin-fe 共用包变更纪律(复盘 + 正途)
2
+
3
+ > 日期:2026-09-11
4
+ > 背景:Wave327 在 `@usethink/cf-admin-fe` 上犯了可避免的工程错误。
5
+
6
+ ---
7
+
8
+ ## 一、已发生的错误(事实,非推测)
9
+
10
+ | # | 错误 | 后果 |
11
+ |---|------|------|
12
+ | E1 | cf-admin-fe 使用 `file:../../../2-node/cf-admin-fe` | VPS/CI `npm install` **必失败**(路径不存在) |
13
+ | E2 | AdminModal 遮罩 `@keydown` **未用 `.self`**,`blockOverlayTabKeydown` 冒泡 | 弹窗内 **Tab 无法正常切换**(中间项也被 preventDefault) |
14
+ | E3 | 未写组件级回归测试就宣称「统一 a11y」 | 缺陷进共用包,靠人工回退而非测试门禁 |
15
+ | E4 | 出问题后 **回退依赖** 而非 **修包 + 发布 + 升级** | 公共能力未沉淀,Wave327 目标半残 |
16
+
17
+ **正确态度**:共用包问题 → 在 **cf-admin-fe 仓库** 修对、测全、发版;业务仓只改 semver 升级。**禁止**用 `file:` 或回退版本糊弄。
18
+
19
+ ---
20
+
21
+ ## 二、正途流程(以后强制执行)
22
+
23
+ ```
24
+ 审查影响面 → 写回归测试 → 实现 → cf-admin-fe npm test + type-check
25
+ → npm publish 0.2.x → 各业务仓 npm install @usethink/cf-admin-fe@^x.y.z
26
+ → 各业务仓 vitest / vue-tsc
27
+ ```
28
+
29
+ ### 2.1 变更前审查清单
30
+
31
+ - [ ] `rg "@usethink/cf-admin-fe"` 列出 **所有下游**(cf-admin-fe、cf-pay…)
32
+ - [ ] 与上一版 **逐 prop / 默认值** diff,写入 cf-admin-fe `CHANGELOG.md`
33
+ - [ ] 识别默认行为变更 → 必须提供 **opt-out prop** 或 major bump
34
+ - [ ] **禁止**业务仓 lockfile 出现 `file:` / `link: true`
35
+
36
+ ### 2.2 AdminModal 0.2.9 正确设计(审查结论)
37
+
38
+ | 能力 | 实现 | 测试 |
39
+ |------|------|------|
40
+ | Tab 循环 | `handleModalTabTrap` 绑 **弹窗容器** | 末项→首项、首项 Shift+Tab→末项、**中间 Tab 不 preventDefault** |
41
+ | 遮罩 Tab | `@keydown.self` + `blockOverlayTabKeydown` | **禁止**无 `.self` 的 mask handler |
42
+ | body 滚动锁 | 默认 `lockScroll=true`,`body-scroll-lock` 引用计数 | 开/关弹窗、`:lock-scroll="false"` |
43
+ | 焦点 | 打开 focus 容器、关闭 restore | 已有 0.2.8 逻辑保留 |
44
+ | Esc | `closeOnEscape` 时关闭 | AdminModal.test |
45
+
46
+ ### 2.3 下游升级(cf-admin-fe)
47
+
48
+ **当前**:`frontend/package.json` 保持 `^0.2.8`(registry),直至 0.2.9 **发布到 npm**。
49
+
50
+ 发布后执行:
51
+
52
+ ```bash
53
+ cd templates/cf-admin-fe/frontend
54
+ npm install @usethink/cf-admin-fe@^0.2.9
55
+ cd ..
56
+ npm test && npm run type-check:all
57
+ ```
58
+
59
+ Wave327 **已保留且安全**的部分(仅 cf-admin-fe 本地,与 cf-admin-fe 无关):
60
+
61
+ - Integrations / Blacklist / Members 的 `useTenantPendingGuard` 写 CTA
62
+
63
+ ---
64
+
65
+ ## 三、cf-admin-fe 待落地提交(在 `0-X/2-node/cf-admin-fe`)
66
+
67
+ > 以下应在 **cf-admin-fe 仓库** 单独 commit + publish,**不要**在 cf-admin-fe 写 file 依赖。
68
+
69
+ 1. `src/utils/modal-focus-trap.ts`(已有,补 DOM 单测)
70
+ 2. `src/components/AdminModal.vue`:`lockScroll` 默认 true;mask `@keydown.self`
71
+ 3. `tests/AdminModal.test.ts`(新建)
72
+ 4. `tests/modal-focus-trap.test.ts`(补中间 Tab 不拦截用例)
73
+ 5. `docs/SHARED_PACKAGE_CHANGE_PROCESS.md` + `CHANGELOG.md` 0.2.9
74
+ 6. `npm test` → `npm publish`
75
+
76
+ ---
77
+
78
+ ## 四、一句话
79
+
80
+ **共用包 = 先审后写、测试门禁、registry 发布、下游 semver 升级。**
81
+ 回退业务仓依赖 **不是** 修复共用包;`file:` 本地链 **是** 部署灾难。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usethink/cf-admin-fe",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
4
4
  "description": "Vue 3 frontend admin kit for CF product family — composables, shell, ConfigField, list loader, login chrome, request/token/raw factories, error/batch helpers, system-config engine, nav helpers, i18n seeds, recipes (FE only; not cf-core)",
5
5
  "type": "module",
6
6
  "private": false,
@@ -70,6 +70,8 @@
70
70
  "devDependencies": {
71
71
  "@types/node": "^22.10.0",
72
72
  "@vitejs/plugin-vue": "^5.2.1",
73
+ "@vue/test-utils": "^2.4.11",
74
+ "happy-dom": "^20.11.1",
73
75
  "typescript": "^5.7.2",
74
76
  "vite": "^6.0.0",
75
77
  "vitest": "^3.0.0",
@@ -82,7 +84,13 @@
82
84
  "test:watch": "vitest",
83
85
  "build": "npm run clean && tsc -p tsconfig.build.json",
84
86
  "prepack": "npm run build",
85
- "clean": "node scripts/clean-dist.mjs"
87
+ "clean": "node scripts/clean-dist.mjs",
88
+ "size-report": "node scripts/size-report.mjs",
89
+ "size-report:write": "node scripts/size-report.mjs --write",
90
+ "size-report:check": "node scripts/size-report.mjs --check",
91
+ "verify:boundaries": "node scripts/verify-boundaries.mjs",
92
+ "pre-release": "node scripts/pre-release.mjs",
93
+ "prepublishOnly": "npm run type-check && npm test && npm run verify:boundaries && npm run pre-release"
86
94
  },
87
95
  "keywords": [
88
96
  "admin",
package/recipes/README.md CHANGED
@@ -2,7 +2,29 @@
2
2
 
3
3
  这些文件是**起点模板**,不是运行时导入。将它们复制(或重新导出)到你的产品 `src/` 目录下,然后补齐产品专属部分(菜单、领域 API 方法、登录 UI)。
4
4
 
5
- **完整用法、注意事项、能力榨干清单:** 见 [`docs/002_cf-admin-fe使用说明书与注意事项.md`](../docs/002_cf-admin-fe使用说明书与注意事项.md)。
5
+ **完整用法、注意事项、能力榨干清单:** 见 [`docs/002_cf-admin-fe使用说明书与注意事项.md`](../docs/002_cf-admin-fe使用说明书与注意事项.md)。
6
+ **套件下一步该做什么 / 明确不做:** 见 [`docs/003_cf-admin-fe极致化方案与取舍_2026-08-05.md`](../docs/003_cf-admin-fe极致化方案与取舍_2026-08-05.md)(路线图,非本 recipes 步骤替代)。
7
+
8
+ ## 最小可跑通(核对清单,非脚手架)
9
+
10
+ 目标:**壳 + 会话 + 请求 + 一页列表** 能 type-check / 手工点通。
11
+ **不是** `create-cf-admin`,**不是**可运行 monorepo example(003 仍冻结脚手架;有余力再加 `examples/minimal`)。
12
+
13
+ | # | 必做 | 验收 |
14
+ |---|------|------|
15
+ | M1 | 安装 `@usethink/cf-admin-fe` + peer `vue`(可选 `vue-i18n`) | `package.json` 有依赖 |
16
+ | M2 | `import '@usethink/cf-admin-fe/styles'`(或 tokens + primitives) | 管理端有基础 token / 表格样式 |
17
+ | M3 | 根布局挂载 **一次** `ToastContainer` | 任意页 `showToast` 可见 |
18
+ | M4 | 选 **一种** 会话:Bearer → `useAdminAuth.shop` + `useAdminRequest`;Cookie → `.cookie` 两件套 | 登录后请求带 token 或 `credentials: 'include'` |
19
+ | M5 | `mergeAdminKitMessages`(见 `i18n-merge.snippet.ts`) | 无 vue-i18n 时组件仍靠 tKit fallback;有 i18n 时壳文案不露 key |
20
+ | M6 | `AdminLayout.vue` + 产品菜单 + `decideAdminRouteAccess` | 未登录进 admin → 登录页;登录后进壳 |
21
+ | M7 | 登录页:`AdminLoginShell` + 产品表单 + `resolveAdminLoginRedirect` | `?redirect=` 回跳安全 |
22
+ | M8 | **一页**列表:`list-page.snippet.ts` 骨架;**先**确认 string query vs number body | string → `buildAdminOffsetParams`;number → `offset.value`(或等价手写) |
23
+ | M9 | 产品 `vue-tsc` / type-check | 无类型红 |
24
+
25
+ 可选(第二页再加):`useTableSelection` + `checkAdminBatchLimit`(**limit 对齐后端 max**)、`copyWithToast`、`ConfigField`、`AdminMetadataDetail`(**新**审计详情优先)。
26
+
27
+ 完整逐步说明仍以 [`docs/002`](../docs/002_cf-admin-fe使用说明书与注意事项.md) 为准;上表只防「漏一步导致假接入」。
6
28
 
7
29
  ## 推荐复制顺序
8
30
 
@@ -19,7 +41,7 @@
19
41
  | 7 | 样式 | `import '@usethink/cf-admin-fe/styles'` **或** `@import` 基础样式(+ 登录 CSS) |
20
42
  | 8 | 应用外壳 | 在应用根节点挂载一次 `ToastContainer` |
21
43
  | 9 | 登录 | 用 `AdminLoginShell` 包裹**产品**表单;用 `resolveAdminLoginRedirect` 处理 `?redirect=` |
22
- | 10 | 列表页 | 见 `list-page.snippet.ts`:`useAdminListLoader` + 分页/多选 + `toErrorMessage` / `copyWithToast`;**先确认**后端是 string query 还是 number body offset |
44
+ | 10 | 列表页 | 见 `list-page.snippet.ts`:`useAdminListLoader` + 分页/多选 + `toErrorMessage` / `copyWithToast`;**先确认**后端是 string query 还是 number body offset(number 推荐 `offset.value`) |
23
45
 
24
46
  ### 两种一等会话模型
25
47
 
@@ -94,7 +116,7 @@ if (!limitCheck.ok) {
94
116
  | 形态 | 工具 | 参考产品 |
95
117
  |---|---|---|
96
118
  | `URLSearchParams` / query:`limit`、`offset` 为 **字符串** | `buildAdminOffsetParams(page, limit)` 或 `applyAdminOffsetParams` | cf-lottery 平台审计、租户、webhook、兑换码 |
97
- | 类型化 filter / body:`limit`、`offset` 为 **数字** | **手写** `(page - 1) * limit`;**不要**套 string helper | cf-shop 余额 / 充值 / 代金券等 |
119
+ | 类型化 filter / body:`limit`、`offset` 为 **数字** | **`offset.value`**(`useTablePagination`)或等价手写 `(page - 1) * limit`;**不要**套 string helper;**不**抽 number helper(001 R5) | cf-auth 多用 `offset`;cf-shop 余额/充值/代金券等多手写;lottery 抽奖列表 1 处手写 |
98
120
  | Cursor | 产品自管 | shop 部分订单/日志 |
99
121
 
100
122
  `buildAdminOffsetParams` 的返回值是 `{ limit: string; offset: string }`,塞进需要 `number` 的 TypeScript filter 会类型错误或静默把数字变成字符串——属于误用,不是「再抽一个兼容层就能糊弄过去」。
@@ -19,10 +19,13 @@
19
19
  * 参考:cf-lottery 平台审计 / 租户 / webhook / 兑换码
20
20
  *
21
21
  * B) 类型化 filter / JSON body:limit、offset 为 number
22
- * 手写即可,例如:
22
+ * 推荐用 useTablePagination 自带 offset(与手写公式等价):
23
+ * { limit: limit.value, offset: offset.value }
24
+ * 等价手写:
23
25
  * { limit: limit.value, offset: (page.value - 1) * limit.value }
24
- * 参考:cf-shop AdminBalance / Recharges / Vouchers
25
- * 禁止把 B 强行改成 A 只为「用上 helper」。number helper 观察期:≥2 产品同形再提案。
26
+ * 参考:cf-auth(offset.value);cf-shop Balance/Recharges/Vouchers(多手写);lottery 抽奖列表 1 处手写
27
+ * 禁止把 B 强行改成 A 只为「用上 helper」。
28
+ * number helper:**R5 审计后默认不增 API**(见 docs/001 R5);1 行公式 / offset 已够。
26
29
  *
27
30
  * 真实 API 签名(务必与 src/utils/admin-query.ts 一致):
28
31
  * buildAdminOffsetParams(page: number, limit: number, options?)
@@ -63,7 +66,9 @@ const {
63
66
  const { loading, loadError, runLoad } = useAdminListLoader()
64
67
 
65
68
  // 示例:产品侧 admin 请求(Bearer 或 cookie 由 useAdminRequest recipe 决定)
66
- declare function listDomainItems(params: Record<string, string>): Promise<{ items: Row[]; total: number }>
69
+ declare function listDomainItems(
70
+ params: Record<string, string> | { limit: number; offset: number; status?: string },
71
+ ): Promise<{ items: Row[]; total: number }>
67
72
  declare function batchDisable(ids: string[]): Promise<{ success: number; failed: number }>
68
73
  declare function showToast(message: string, type?: 'success' | 'error' | 'info'): void
69
74
  declare function t(key: string, params?: Record<string, unknown>): string
@@ -71,7 +76,7 @@ declare function t(key: string, params?: Record<string, unknown>): string
71
76
  async function loadData() {
72
77
  await runLoad(
73
78
  async (ctx) => {
74
- // ── 形态 A:string query(本示例)──
79
+ // ── 形态 A:string query(本示例主路径)──
75
80
  // 位置参数:page(1-based)、limit(每页条数)→ 字符串 limit/offset
76
81
  const params = buildAdminOffsetParams(page.value, limit.value)
77
82
  // 若已有 URLSearchParams(含业务筛选),再写入 limit/offset:
@@ -80,10 +85,11 @@ async function loadData() {
80
85
  const res = await listDomainItems(params)
81
86
 
82
87
  // ── 形态 B:number body / 类型化 filter(勿用 buildAdminOffsetParams)──
88
+ // 推荐:
83
89
  // const res = await listDomainItems({
84
90
  // status: filter.status,
85
91
  // limit: limit.value,
86
- // offset: (page.value - 1) * limit.value,
92
+ // offset: offset.value, // === (page - 1) * limit
87
93
  // })
88
94
 
89
95
  if (ctx.isStale()) return
@@ -134,6 +140,7 @@ async function runBatchDisable() {
134
140
  }
135
141
  }
136
142
 
143
+ // 形态 B 会用到 offset;形态 A 主路径下保留引用避免 snippet 被 tree-shake 误导
137
144
  void offset
138
145
  void applyAdminOffsetParams
139
146
  void total
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div v-if="modelValue" class="modal-mask" @click.self="handleBackdropClick">
2
+ <div v-if="modelValue" class="modal-mask" @click.self="handleBackdropClick" @keydown.self="handleMaskKeydown">
3
3
  <div
4
4
  ref="modalRef"
5
5
  class="modal"
@@ -9,7 +9,7 @@
9
9
  :aria-labelledby="title ? titleId : undefined"
10
10
  :aria-label="$t('adminModal.title')"
11
11
  tabindex="-1"
12
- @keydown="handleKeydown"
12
+ @keydown="handleModalKeydown"
13
13
  >
14
14
  <h3 v-if="title" :id="titleId" class="modal-title">{{ title }}</h3>
15
15
  <div ref="bodyRef" class="modal-body">
@@ -26,6 +26,8 @@
26
26
 
27
27
  <script setup lang="ts">
28
28
  import { nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
29
+ import { lockBodyScroll, unlockBodyScroll } from '../utils/body-scroll-lock'
30
+ import { blockOverlayTabKeydown, handleModalTabTrap } from '../utils/modal-focus-trap'
29
31
 
30
32
  const props = withDefaults(defineProps<{
31
33
  modelValue: boolean
@@ -34,10 +36,16 @@ const props = withDefaults(defineProps<{
34
36
  hideActions?: boolean
35
37
  closeOnBackdrop?: boolean
36
38
  closeOnEscape?: boolean
39
+ /**
40
+ * 打开时是否锁定 document.body 滚动(默认 false,与 0.2.8 一致;显式 true 才锁)。
41
+ * 与页面级 lock 可嵌套(body-scroll-lock 引用计数)。
42
+ */
43
+ lockScroll?: boolean
37
44
  }>(), {
38
45
  hideActions: false,
39
46
  closeOnBackdrop: false,
40
47
  closeOnEscape: false,
48
+ lockScroll: false,
41
49
  })
42
50
 
43
51
  const emit = defineEmits<{
@@ -57,54 +65,40 @@ function handleBackdropClick() {
57
65
  if (props.closeOnBackdrop) close()
58
66
  }
59
67
 
60
- function getFocusableElements() {
61
- return Array.from(modalRef.value?.querySelectorAll<HTMLElement>(
62
- 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
63
- ) || [])
68
+ /** 弹窗容器:Tab 循环 + 可选 Esc(边界行为同 0.2.8,实现抽到 util) */
69
+ function tryCloseOnEscape(e: KeyboardEvent) {
70
+ if (e.key !== 'Escape' || !props.closeOnEscape) return
71
+ e.preventDefault()
72
+ close()
64
73
  }
65
74
 
66
- function handleKeydown(event: KeyboardEvent) {
67
- if (event.key === 'Escape') {
68
- if (!props.closeOnEscape) return
69
- event.preventDefault()
70
- close()
71
- return
72
- }
73
- if (event.key !== 'Tab') return
74
-
75
- const focusable = getFocusableElements()
76
- if (focusable.length === 0) {
77
- event.preventDefault()
78
- modalRef.value?.focus()
79
- return
80
- }
75
+ /** 遮罩 .self:仅焦点在遮罩时拦截 Tab,禁止冒泡误伤弹窗内控件 */
76
+ function handleMaskKeydown(e: KeyboardEvent) {
77
+ blockOverlayTabKeydown(e)
78
+ tryCloseOnEscape(e)
79
+ }
81
80
 
82
- const first = focusable[0]
83
- const last = focusable[focusable.length - 1]
84
- if (event.shiftKey && (document.activeElement === first || document.activeElement === modalRef.value)) {
85
- event.preventDefault()
86
- last.focus()
87
- } else if (!event.shiftKey && document.activeElement === last) {
88
- event.preventDefault()
89
- first.focus()
90
- }
81
+ function handleModalKeydown(e: KeyboardEvent) {
82
+ handleModalTabTrap(e, modalRef.value)
83
+ tryCloseOnEscape(e)
91
84
  }
92
85
 
93
86
  watch(() => props.modelValue, async (visible, wasVisible) => {
94
87
  if (visible && !wasVisible) {
88
+ if (props.lockScroll) lockBodyScroll()
95
89
  restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
96
90
  await nextTick()
97
- // 长详情中的第一个按钮通常位于正文底部;若直接聚焦它,浏览器会把滚动区自动拉到底部。
98
- // 打开时先回到正文顶部并聚焦对话框容器,用户可以从订单基本信息开始阅读,再用 Tab 进入控件。
99
91
  if (bodyRef.value) bodyRef.value.scrollTop = 0
100
92
  modalRef.value?.focus({ preventScroll: true })
101
93
  } else if (!visible && wasVisible) {
94
+ if (props.lockScroll) unlockBodyScroll()
102
95
  restoreFocus?.focus()
103
96
  restoreFocus = null
104
97
  }
105
- })
98
+ }, { immediate: true })
106
99
 
107
100
  onBeforeUnmount(() => {
101
+ if (props.modelValue && props.lockScroll) unlockBodyScroll()
108
102
  restoreFocus?.focus()
109
103
  })
110
104
  </script>
@@ -131,14 +125,12 @@ onBeforeUnmount(() => {
131
125
  min-height: 0;
132
126
  display: flex;
133
127
  flex-direction: column;
134
- /* 比页面底略抬一层,避免与遮罩糊成一团 */
135
128
  background: var(--tg-secondary-bg, #151b28);
136
129
  color: var(--tg-text);
137
130
  border-radius: var(--r-lg, 12px);
138
131
  padding: 22px;
139
132
  border: 1px solid var(--border-strong, rgba(255, 255, 255, 0.16));
140
133
  box-shadow: var(--shadow-lg, 0 20px 50px rgba(0, 0, 0, 0.45));
141
- /* 勿用 overflow:hidden:会裁切 :focus-visible 描边(控件四边显示不全) */
142
134
  overflow: visible;
143
135
  }
144
136
 
@@ -161,13 +153,11 @@ onBeforeUnmount(() => {
161
153
  overflow-x: visible;
162
154
  overflow-y: auto;
163
155
  overscroll-behavior: contain;
164
- /* 给 focus 描边(outline-offset: 2px)留出左右上下内边距,避免被滚动裁切 */
165
156
  padding: 4px 6px 6px;
166
157
  margin: -4px -6px -6px;
167
158
  scrollbar-gutter: stable;
168
159
  }
169
160
 
170
- /* 子级 form 需要吃满高度时(如商品编辑粘性脚部) */
171
161
  .modal-body > form {
172
162
  min-height: 0;
173
163
  }
@@ -83,8 +83,9 @@
83
83
  </template>
84
84
 
85
85
  <script setup lang="ts">
86
- import { computed, getCurrentInstance } from 'vue'
86
+ import { computed } from 'vue'
87
87
  import { useAdminSidebar } from '../composables/useAdminSidebar'
88
+ import { tKit } from '../utils/i18n'
88
89
 
89
90
  export type AdminShellMenuItem = {
90
91
  name: string
@@ -133,24 +134,11 @@ const emit = defineEmits<{
133
134
 
134
135
  const { isMobile, sidebarOpen, toggleSidebar, closeSidebar } = useAdminSidebar()
135
136
 
136
- function tFallback(key: string, fallback: string): string {
137
- const proxy = getCurrentInstance()?.proxy as { $t?: (k: string) => string } | undefined
138
- try {
139
- if (typeof proxy?.$t === 'function') {
140
- const out = proxy.$t(key)
141
- if (typeof out === 'string' && out !== key) return out
142
- }
143
- } catch {
144
- /* 无 i18n */
145
- }
146
- return fallback
147
- }
148
-
149
137
  const resolvedLogoutLabel = computed(
150
- () => props.logoutLabel || tFallback('adminLayout.logout', '退出'),
138
+ () => props.logoutLabel || tKit('adminLayout.logout', '退出'),
151
139
  )
152
140
  const resolvedToggleLabel = computed(
153
- () => props.toggleSidebarLabel || tFallback('adminLayout.toggleSidebar', '菜单'),
141
+ () => props.toggleSidebarLabel || tKit('adminLayout.toggleSidebar', '菜单'),
154
142
  )
155
143
 
156
144
  const resolvedTitle = computed(() => {
@@ -86,12 +86,13 @@
86
86
  </template>
87
87
 
88
88
  <script setup lang="ts">
89
- import { computed, getCurrentInstance, ref, watch } from 'vue'
89
+ import { computed, ref, watch } from 'vue'
90
90
  import type {
91
91
  AdminSystemConfigFieldDefinition,
92
92
  ConfigFieldStatus,
93
93
  } from '../types/system-config'
94
94
  import { formatCents, parseYuanToCents } from '../utils/currency-cents'
95
+ import { tKit } from '../utils/i18n'
95
96
 
96
97
  const props = defineProps<{
97
98
  item: AdminSystemConfigFieldDefinition
@@ -107,24 +108,9 @@ const emit = defineEmits<{
107
108
 
108
109
  const localError = ref('')
109
110
 
110
- /**
111
- * 可选 i18n:宿主可注入 vue-i18n 全局 `$t` / `$te`。
112
- * 切勿把后端自由文本作为 defaultMessage 传入(裸 `@` / `{}` 会导致解析失败)。
113
- */
111
+ /** 可选 i18n:统一走 tKit(无 vue-i18n 时用中文 fallback) */
114
112
  function tSafe(key: string, fallback: string, params?: Record<string, unknown>): string {
115
- const proxy = getCurrentInstance()?.proxy as
116
- | { $t?: (k: string, p?: Record<string, unknown>) => string; $te?: (k: string) => boolean }
117
- | undefined
118
- try {
119
- if (proxy?.$te && !proxy.$te(key)) return fallback
120
- if (typeof proxy?.$t === 'function') {
121
- const out = params ? proxy.$t(key, params) : proxy.$t(key)
122
- if (typeof out === 'string' && out !== key) return out
123
- }
124
- } catch {
125
- /* 无 i18n */
126
- }
127
- return fallback
113
+ return tKit(key, fallback, params)
128
114
  }
129
115
 
130
116
  function translateConfigText(
package/src/index.ts CHANGED
@@ -130,8 +130,15 @@ export {
130
130
  formatMetadata,
131
131
  buildAdminOffsetParams,
132
132
  applyAdminOffsetParams,
133
+ resolveAdminT,
134
+ tKit,
135
+ collectModalFocusable,
136
+ handleModalTabTrap,
137
+ blockOverlayTabKeydown,
133
138
  } from './utils'
134
139
 
140
+ export type { AdminI18nProxy } from './utils'
141
+
135
142
  export {
136
143
  normalizeOtpDigits,
137
144
  isOtpComplete,
@@ -177,6 +177,11 @@
177
177
  /* ── 表格容器 ──
178
178
  * 滚动裁剪发生在这里(overflow: auto)。sticky 表头相对 .table-wrap 吸顶。
179
179
  * 禁止在页面 scoped 再写一套 table-wrap / th sticky。
180
+ *
181
+ * 滚动条策略(勿用 scrollbar-gutter: stable):
182
+ * - stable 会在无溢出时也预留纵向槽,表右侧出现「缺口」、最后一列与圆角边框不对齐;
183
+ * - 默认 auto:仅在真正溢出时占位;细滚动条见下方 ::-webkit-scrollbar。
184
+ * 弹层/长卡片列表可另用 stable(防布局跳动);表格以视觉对齐优先。
180
185
  */
181
186
  .table-wrap {
182
187
  background: var(--tg-secondary-bg, #151b28);
@@ -184,7 +189,6 @@
184
189
  border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
185
190
  overflow: auto;
186
191
  overscroll-behavior: contain;
187
- scrollbar-gutter: stable;
188
192
  flex: 1;
189
193
  min-height: 0;
190
194
  /* 建立独立层叠上下文,避免表头与外部 sticky toolbar 抢 z-index */
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 套件组件统一 i18n 回退(vue-i18n 为 optional peer)。
3
+ *
4
+ * - 有 `$t` 且返回非 key 本身 → 用翻译
5
+ * - 有 `$te` 且 key 不存在 → 直接 fallback(避免 vue-i18n 警告/返回 key)
6
+ * - 无 i18n 或抛错 → fallback
7
+ *
8
+ * 切勿把后端自由文本当 fallback 里的插值模板(裸 `@` / `{}` 可能被 i18n 解析)。
9
+ */
10
+ import { getCurrentInstance } from 'vue'
11
+
12
+ export type AdminI18nProxy = {
13
+ $t?: (key: string, params?: Record<string, unknown>) => unknown
14
+ $te?: (key: string) => boolean
15
+ } | null | undefined
16
+
17
+ /**
18
+ * 纯函数:可在单测中直接注入 proxy,不依赖 setup。
19
+ */
20
+ export function resolveAdminT(
21
+ proxy: AdminI18nProxy,
22
+ key: string,
23
+ fallback: string,
24
+ params?: Record<string, unknown>,
25
+ ): string {
26
+ try {
27
+ if (typeof proxy?.$te === 'function' && !proxy.$te(key)) return fallback
28
+ if (typeof proxy?.$t === 'function') {
29
+ const out = params !== undefined ? proxy.$t(key, params) : proxy.$t(key)
30
+ if (typeof out === 'string' && out !== key) return out
31
+ }
32
+ } catch {
33
+ /* 无 i18n 或运行时错误 */
34
+ }
35
+ return fallback
36
+ }
37
+
38
+ /**
39
+ * 从当前组件实例的 appContext.globalProperties 读取 `$t` / `$te`。
40
+ * 使用 globalProperties 而非 `proxy.$t`,避免未安装 vue-i18n 时访问实例代理触发 Vue warn。
41
+ * 勿在模块顶层调用。
42
+ */
43
+ export function tKit(
44
+ key: string,
45
+ fallback: string,
46
+ params?: Record<string, unknown>,
47
+ ): string {
48
+ const instance = getCurrentInstance()
49
+ const gp = instance?.appContext.config.globalProperties as
50
+ | Record<string, unknown>
51
+ | undefined
52
+ const proxy: AdminI18nProxy = gp
53
+ ? {
54
+ $t: typeof gp.$t === 'function'
55
+ ? (gp.$t as (k: string, p?: Record<string, unknown>) => unknown).bind(gp)
56
+ : undefined,
57
+ $te: typeof gp.$te === 'function'
58
+ ? (gp.$te as (k: string) => boolean).bind(gp)
59
+ : undefined,
60
+ }
61
+ : undefined
62
+ return resolveAdminT(proxy, key, fallback, params)
63
+ }
@@ -15,6 +15,12 @@ export {
15
15
  __resetBodyScrollLockForTests,
16
16
  } from './body-scroll-lock'
17
17
 
18
+ export {
19
+ collectModalFocusable,
20
+ handleModalTabTrap,
21
+ blockOverlayTabKeydown,
22
+ } from './modal-focus-trap'
23
+
18
24
  export { normalizeCents, formatCents, parseYuanToCents } from './currency-cents'
19
25
 
20
26
  export {
@@ -79,3 +85,9 @@ export {
79
85
  applyAdminOffsetParams,
80
86
  type AdminOffsetParams,
81
87
  } from './admin-query'
88
+
89
+ export {
90
+ resolveAdminT,
91
+ tKit,
92
+ type AdminI18nProxy,
93
+ } from './i18n'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 弹窗焦点陷阱纯函数(docs/326 · 与 cf-lottery modal-focus-trap 对齐)
3
+ *
4
+ * 与 body-scroll-lock 分工:scroll lock 由 AdminModal 统一;各弹窗只负责 Tab 循环 + 初始 focus。
5
+ */
6
+
7
+ /** 收集弹窗内可聚焦元素(顺序与 DOM 一致) */
8
+ export function collectModalFocusable(root: HTMLElement): HTMLElement[] {
9
+ return Array.from(root.querySelectorAll(
10
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
11
+ )) as HTMLElement[]
12
+ }
13
+
14
+ /** 弹窗根节点内 Tab 循环 */
15
+ export function handleModalTabTrap(e: KeyboardEvent, root: HTMLElement | null): void {
16
+ if (e.key !== 'Tab' || !root) return
17
+ const items = collectModalFocusable(root)
18
+ if (items.length === 0) {
19
+ e.preventDefault()
20
+ root.focus()
21
+ return
22
+ }
23
+ const first = items[0]
24
+ const last = items[items.length - 1]
25
+ if (e.shiftKey && (document.activeElement === first || document.activeElement === root)) {
26
+ e.preventDefault()
27
+ last.focus()
28
+ } else if (!e.shiftKey && document.activeElement === last) {
29
+ e.preventDefault()
30
+ first.focus()
31
+ }
32
+ }
33
+
34
+ /** 遮罩层:禁止 Tab 把焦点漏到背景页 */
35
+ export function blockOverlayTabKeydown(e: KeyboardEvent): void {
36
+ if (e.key === 'Tab') e.preventDefault()
37
+ }