@usethink/cf-admin-fe 0.2.8 → 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 +23 -3
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/modal-focus-trap.d.ts +11 -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 +21 -4
- 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 +21 -11
- 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 +7 -6
- package/docs/SHARED_PACKAGE_CHANGE_PROCESS.md +81 -0
- package/package.json +5 -2
- package/recipes/README.md +23 -2
- package/recipes/list-page.snippet.ts +13 -6
- package/src/components/AdminModal.vue +27 -37
- package/src/index.ts +3 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/modal-focus-trap.ts +37 -0
package/README.md
CHANGED
|
@@ -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
|
|
269
|
-
npm run
|
|
270
|
-
npm
|
|
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
|
**封包注意:**
|
|
@@ -289,6 +308,7 @@ npm pack # 触发 prepack→build;入口仍以 src/ 为准
|
|
|
289
308
|
- **0.2.6 文档收口(无 API 变更)** — README/recipes/001/002 对齐:string vs number offset、batch `limit` 须跟后端 max、shop §5.8 接线对照、MetadataDetail「新页优先」
|
|
290
309
|
- **0.2.7** — `.table-wrap` 去掉 `scrollbar-gutter: stable`,消除无溢出时表格右侧缺口
|
|
291
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「最小可跑通」清单
|
|
292
312
|
|
|
293
313
|
## 许可证
|
|
294
314
|
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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';
|
|
@@ -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.
|
|
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
|
|
7
|
+
**状态:** foundation **维护态**(无阻塞 API 缺口);**cf-auth 角色 = 检验 cookie 路径,不是 shop 克隆目标**;文档与源码注释以**中文**为准
|
|
8
8
|
|
|
9
9
|
> 本文原为差距清单。实施后多项已落地,**勿再把全文当待办路线图**。
|
|
10
10
|
> 历史错误 claim(如 kit 无 `requirePhrase`、`./components` 未导出、adminLogs「60+ 完全一致」)已纠正。
|
|
@@ -177,11 +177,28 @@
|
|
|
177
177
|
|
|
178
178
|
| 候选 | 现状 | 触发条件 |
|
|
179
179
|
|---|---|---|
|
|
180
|
-
| `buildAdminOffsetNumbers`(number limit/offset) |
|
|
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
|
+
#### 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
|
+
|
|
185
202
|
**维护文档真相源:** 接线细节以 [`002` §5.8](./002_cf-admin-fe使用说明书与注意事项.md) 为准;本文只记决策与结案,**勿把 R5 当立即开工的 backlog**。
|
|
186
203
|
**后续取舍与近端路线(含 P0 三件套、脚手架/文档站冻结条件):** 见 [`003` 极致化方案与取舍](./003_cf-admin-fe极致化方案与取舍_2026-08-05.md)。
|
|
187
204
|
|
|
@@ -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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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`
|
|
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 列表 →
|
|
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 →
|
|
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
|
|
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
|
-
|
|
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
|
| 产品 | 会话 | 本版关键接线 | 部署前至少 |
|
|
@@ -801,5 +810,6 @@ R5 观察项(number offset helper、silent copy 选项、cursor helper、batch
|
|
|
801
810
|
| 2026-08-05 | **0.2.7** `.table-wrap` 去掉 `scrollbar-gutter: stable`,消除全站表格右侧缺口 |
|
|
802
811
|
| 2026-08-05 | 配套链增加 [`003` 极致化方案与取舍](./003_cf-admin-fe极致化方案与取舍_2026-08-05.md);§10.4 指向 003 作路线图(非 API) |
|
|
803
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 最小接入清单补强 |
|
|
804
814
|
|
|
805
815
|
**维护约定:** API 变更时同步改本文件、根 README 公开 API 节、`recipes/README.md` 与 `tests/public-api.test.ts`。说明书只描述已实现行为;示例代码须与 `src/` 签名一致。
|
|
@@ -287,11 +287,11 @@
|
|
|
287
287
|
|
|
288
288
|
**刻意不碰(仍有效):** path 级 `createAdminApi`、脚手架、VitePress、双主题、注入式侧栏。
|
|
289
289
|
|
|
290
|
-
### 9.2
|
|
290
|
+
### 9.2 中端(已落地,无 API / 无发版号 bump)
|
|
291
291
|
|
|
292
|
-
4.
|
|
293
|
-
5. number offset
|
|
294
|
-
6. recipes
|
|
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
295
|
|
|
296
296
|
### 9.3 冻结直到触发条件
|
|
297
297
|
|
|
@@ -319,8 +319,8 @@
|
|
|
319
319
|
- **定位与 ≥2 提取纪律:全盘坚持,并当 PR 门禁。**
|
|
320
320
|
- **P0 三件套已在 0.2.8 落地**(契约测 + i18n 收口 + size-report)。
|
|
321
321
|
- **API 路径工厂、脚手架、文档站、双主题:远见可记,默认冻结。**
|
|
322
|
-
- **R5 number offset
|
|
323
|
-
-
|
|
322
|
+
- **R5 number offset:已审计,维持不增 API**(优先 `useTablePagination().offset`)。
|
|
323
|
+
- **中端三项已落地**(pre-release / R5 笔记 / recipes 最小接入清单);仍不扩冻结项。下一动作按触发条件或消费方 bump 0.2.8。
|
|
324
324
|
|
|
325
325
|
---
|
|
326
326
|
|
|
@@ -330,3 +330,4 @@
|
|
|
330
330
|
|------|------|
|
|
331
331
|
| 2026-08-05 | 初版:基于 0.2.7 现状与消费方(shop / lottery / auth)对照;固化 P0/P1/P2 裁决、触发条件与 0.2.8 三件套路线;明确极致定义=减摩擦+安全感而非扩面 |
|
|
332
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.
|
|
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,
|
|
@@ -87,7 +87,10 @@
|
|
|
87
87
|
"clean": "node scripts/clean-dist.mjs",
|
|
88
88
|
"size-report": "node scripts/size-report.mjs",
|
|
89
89
|
"size-report:write": "node scripts/size-report.mjs --write",
|
|
90
|
-
"size-report:check": "node scripts/size-report.mjs --check"
|
|
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"
|
|
91
94
|
},
|
|
92
95
|
"keywords": [
|
|
93
96
|
"admin",
|
package/recipes/README.md
CHANGED
|
@@ -5,6 +5,27 @@
|
|
|
5
5
|
**完整用法、注意事项、能力榨干清单:** 见 [`docs/002_cf-admin-fe使用说明书与注意事项.md`](../docs/002_cf-admin-fe使用说明书与注意事项.md)。
|
|
6
6
|
**套件下一步该做什么 / 明确不做:** 见 [`docs/003_cf-admin-fe极致化方案与取舍_2026-08-05.md`](../docs/003_cf-admin-fe极致化方案与取舍_2026-08-05.md)(路线图,非本 recipes 步骤替代)。
|
|
7
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) 为准;上表只防「漏一步导致假接入」。
|
|
28
|
+
|
|
8
29
|
## 推荐复制顺序
|
|
9
30
|
|
|
10
31
|
| 步骤 | Recipe | 产品路径(示例) |
|
|
@@ -20,7 +41,7 @@
|
|
|
20
41
|
| 7 | 样式 | `import '@usethink/cf-admin-fe/styles'` **或** `@import` 基础样式(+ 登录 CSS) |
|
|
21
42
|
| 8 | 应用外壳 | 在应用根节点挂载一次 `ToastContainer` |
|
|
22
43
|
| 9 | 登录 | 用 `AdminLoginShell` 包裹**产品**表单;用 `resolveAdminLoginRedirect` 处理 `?redirect=` |
|
|
23
|
-
| 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`) |
|
|
24
45
|
|
|
25
46
|
### 两种一等会话模型
|
|
26
47
|
|
|
@@ -95,7 +116,7 @@ if (!limitCheck.ok) {
|
|
|
95
116
|
| 形态 | 工具 | 参考产品 |
|
|
96
117
|
|---|---|---|
|
|
97
118
|
| `URLSearchParams` / query:`limit`、`offset` 为 **字符串** | `buildAdminOffsetParams(page, limit)` 或 `applyAdminOffsetParams` | cf-lottery 平台审计、租户、webhook、兑换码 |
|
|
98
|
-
| 类型化 filter / body:`limit`、`offset` 为 **数字** |
|
|
119
|
+
| 类型化 filter / body:`limit`、`offset` 为 **数字** | **`offset.value`**(`useTablePagination`)或等价手写 `(page - 1) * limit`;**不要**套 string helper;**不**抽 number helper(001 R5) | cf-auth 多用 `offset`;cf-shop 余额/充值/代金券等多手写;lottery 抽奖列表 1 处手写 |
|
|
99
120
|
| Cursor | 产品自管 | shop 部分订单/日志 |
|
|
100
121
|
|
|
101
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
|
|
25
|
-
* 禁止把 B 强行改成 A 只为「用上 helper」。
|
|
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(
|
|
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:
|
|
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="
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
}
|
package/src/index.ts
CHANGED
package/src/utils/index.ts
CHANGED
|
@@ -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 {
|
|
@@ -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
|
+
}
|