@oj-module/cli 0.2.53 → 0.2.54
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/devkit/SKILL.md +2 -2
- package/devkit/manual.md +32 -25
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +7 -4
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/templates-tenant-scope.test.ts +3 -2
- package/src/config.ts +9 -0
- package/src/contract/__tests__/client-path.test.ts +30 -0
- package/src/contract/__tests__/emit-client.test.ts +18 -0
- package/src/contract/__tests__/handler-scan.test.ts +23 -0
- package/src/contract/check.ts +5 -5
- package/src/contract/client-path.ts +30 -0
- package/src/contract/emit-client.ts +18 -4
- package/src/contract/handler-scan.ts +40 -3
- package/src/contract/run.ts +5 -3
- package/src/init.ts +19 -1
- package/templates/README.md +40 -44
- package/templates/init.include.yaml +12 -8
- package/templates/openapi.json +1393 -34
- package/templates/src/_platform/api/contacts/api.ts +29 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +25 -1
- package/templates/src/_platform/api/solutions/api.ts +60 -1
- package/templates/src/_platform/web/__client/api.schemas.ts +99 -35
- package/templates/src/_platform/web/__client/api.ts +103 -59
- package/templates/src/app/web/entry.ts +77 -0
- package/templates/src/{home/web → app/web/home}/__client/api.ts +5 -2
- package/templates/src/app/web/home/routes.ts +41 -0
- package/templates/src/app/web/login/routes.ts +71 -0
- package/templates/src/{notification/web → app/web/notification}/__client/api.schemas.ts +14 -0
- package/templates/src/{notification/web → app/web/notification}/__client/api.ts +67 -20
- package/templates/src/app/web/notification/routes.ts +70 -0
- package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.ts +5 -2
- package/templates/src/app/web/personal-center/routes.ts +49 -0
- package/templates/src/{system/web → app/web/system}/__client/api.schemas.ts +28 -22
- package/templates/src/{system/web → app/web/system}/__client/api.ts +103 -58
- package/templates/src/{system/web → app/web/system}/pages/users/index.tsx +3 -3
- package/templates/src/{system/web → app/web/system}/pages/wecom/index.tsx +12 -3
- package/templates/src/{system/web/entry.ts → app/web/system/routes.ts} +24 -35
- package/templates/src/auth/api/api.ts +59 -0
- package/templates/src/auth/api/ldap-login/api.ts +37 -1
- package/templates/src/auth/api/wecom/api.ts +23 -0
- package/templates/src/auth/web/__client/api.schemas.ts +133 -13
- package/templates/src/auth/web/__client/api.ts +129 -31
- package/templates/src/notification/api/api.ts +17 -0
- package/templates/src/notification/api/del/api.ts +2 -0
- package/templates/src/system/api/roles/api.ts +3 -0
- package/templates/src/system/api/roles/bindings/api.ts +3 -0
- package/templates/src/system/api/settings/api.ts +3 -0
- package/templates/src/system/api/users/forbidden/api.ts +3 -0
- package/templates/src/system/api/users/password/api.ts +3 -0
- package/templates/src/system/api/users/roles/api.ts +3 -0
- package/templates/src/web/api/get-async-routes/api.ts +16 -1
- package/templates/src/web/api/hello/api.ts +13 -1
- package/templates/src/web/api/user-info/api.ts +21 -1
- package/templates/src/web/web/__client/api.schemas.ts +28 -0
- package/templates/src/web/web/__client/api.ts +33 -9
- package/templates/web.config.ts +21 -15
- package/vendor/host-versions.json +3 -3
- package/templates/src/home/web/entry.ts +0 -49
- package/templates/src/login/web/entry.ts +0 -79
- package/templates/src/notification/web/entry.ts +0 -84
- package/templates/src/personal-center/web/entry.ts +0 -60
- /package/templates/src/{home/web → app/web/home}/__client/api.schemas.ts +0 -0
- /package/templates/src/{home/web → app/web/home}/locales/en-US.json +0 -0
- /package/templates/src/{home/web → app/web/home}/locales/zh-CN.json +0 -0
- /package/templates/src/{home/web → app/web/home}/pages/components/bar-chart.tsx +0 -0
- /package/templates/src/{home/web → app/web/home}/pages/components/card-list.tsx +0 -0
- /package/templates/src/{home/web → app/web/home}/pages/components/line-chart.tsx +0 -0
- /package/templates/src/{home/web → app/web/home}/pages/components/pie-chart.tsx +0 -0
- /package/templates/src/{home/web → app/web/home}/pages/index.tsx +0 -0
- /package/templates/src/{login/web → app/web/login}/auth-provider.ts +0 -0
- /package/templates/src/{login/web → app/web/login}/locales/en-US.json +0 -0
- /package/templates/src/{login/web → app/web/login}/locales/zh-CN.json +0 -0
- /package/templates/src/{login/web → app/web/login}/pages/change-password.tsx +0 -0
- /package/templates/src/{login/web → app/web/login}/pages/login.tsx +0 -0
- /package/templates/src/{login/web → app/web/login}/wecom.ts +0 -0
- /package/templates/src/{notification/web → app/web/notification}/locales/en-US.json +0 -0
- /package/templates/src/{notification/web → app/web/notification}/locales/zh-CN.json +0 -0
- /package/templates/src/{notification/web → app/web/notification}/pages/center/index.tsx +0 -0
- /package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.schemas.ts +0 -0
- /package/templates/src/{personal-center/web → app/web/personal-center}/locales/en-US.json +0 -0
- /package/templates/src/{personal-center/web → app/web/personal-center}/locales/zh-CN.json +0 -0
- /package/templates/src/{personal-center/web → app/web/personal-center}/pages/my-profile/index.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/locales/en-US.json +0 -0
- /package/templates/src/{system/web → app/web/system}/locales/zh-CN.json +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/departments/index.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/departments/sync-bar.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/departments/tree.ts +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/errors.ts +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/forbidden.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/login-methods/index.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/oplog/index.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/roles/index.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/settings/index.tsx +0 -0
- /package/templates/src/{system/web → app/web/system}/pages/users/roles.tsx +0 -0
package/devkit/SKILL.md
CHANGED
|
@@ -54,7 +54,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
|
|
|
54
54
|
- **注册时机**:一切 `ctx.register.*` 必须在 `lifecycle.onInit` 内、且在 onInit 返回前完成。
|
|
55
55
|
拖到 `onActivate` 或某个异步回调里注册不保证生效。`onInit` 签名是 **async**(返回
|
|
56
56
|
`Promise<void>`)——漏写 `async` 会被 `tsc` 以 TS2322 拦下,范本见模板
|
|
57
|
-
`src/
|
|
57
|
+
`src/app/web/notification/entry.ts`。
|
|
58
58
|
- **一模块一 client**:`create<Module>Client(ctx)` 只在 onInit 里创建一次。
|
|
59
59
|
页面/组件/事件回调里**不要**再创建——会覆盖 request 单槽。页面里直接 import 裸函数调用。
|
|
60
60
|
- **只创建自己模块的 client**:A 模块的 onInit 里创建 B 模块的 client 会 DEV 告警。要别家接口,
|
|
@@ -187,7 +187,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
|
|
|
187
187
|
| 登录 401 且 msg 不是 `invalid credentials` | `/auth/*` 不在 `anonymous_paths` | 加进工程根 `config.yaml`,重启 |
|
|
188
188
|
| WS 连接 401(「文件在却连不上」) | 模块写了 `ws.ts`,但其 `/ws` 路由不在 `anonymous_paths`(oj v0.1.30+ WS 握手也过鉴权) | 匿名 WS 加进 `anonymous_paths`;受保护 WS 带 Bearer/Cookie 是正当用法。`ojm api --check` 会对此 warn |
|
|
189
189
|
| 登出/回登录页落空,或登录后落错误边界 | 工程缺 `login` / `home` 模块 | 在 `web.config.ts` 保留两者 |
|
|
190
|
-
| 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底;脚手架不再提供 root 级 `/api/notifications` | 保留 `src/
|
|
190
|
+
| 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底;脚手架不再提供 root 级 `/api/notifications` | 保留 `src/app/web/notification/entry.ts` 的登记 + onInit 注册四方法;后端由 `src/notification/api` 出 `/api/notification/*` |
|
|
191
191
|
| `oj serve` 启动即退,报 `config uses removed server.* site keys (v0.1.58): api_prefix` | 用了 oj v0.1.58 起删除的旧站点键(`api_prefix`/`app_path`/`app_prefix`/`static_sites`/`app_spa_fallback`) | 改 `server.api_prefix: /api` 为顶层 `mounts:`(module-root 写 `{prefix: "/api", api: "api/dist"}`;历史布局写 `api: "src"`、源码交给 `-d`);迁移完整 YAML 由报错给出 |
|
|
192
192
|
| 改完 `mounts:` 仍全 404 | `api:` 指向目录不对(相对 config 所在目录) | module-root 指向产物目录 `"api/dist"`、历史布局指向源码树 `"src"`;`-c` 指向工程根 `config.yaml`(ojm dev/preview 已内置)。module-root 模板**故意不写 api 挂载**(源码交给 `--module-root src`、产物交给 `--api-path api/dist`,同时出现会报 module declared in multiple places) |
|
|
193
193
|
| `oj serve` 启动即退,报 `module '_shared' missing manifest.yaml` | 把共享后端代码放到了扫描根顶层(`src/_shared/`)——oj 把扫描根下的顶层目录都当模块;历史布局是 `api/src/_shared/` | 嵌进所属模块:`src/<模块>/api/_shared/`(历史布局 `api/src/<模块>/_shared/`),用 `#<本模块>/...` 别名 |
|
package/devkit/manual.md
CHANGED
|
@@ -45,13 +45,21 @@
|
|
|
45
45
|
│ ├── _platform/ # 框架级:users 表 / rbac / 市场编排 / 生命周期
|
|
46
46
|
│ │ ├── api/ # + _shared/(respond、service/、site-facade)
|
|
47
47
|
│ │ └── web/ # 仅 __client 生成物(无页面)
|
|
48
|
+
│ ├── app/web/ # 框架内置页面**多根模块**(一个 entry 多个根):
|
|
49
|
+
│ │ ├── entry.ts # routes 聚合 + i18nNamespaces + 生命周期
|
|
50
|
+
│ │ ├── home/ # /home (routes.ts + pages + locales + __client)
|
|
51
|
+
│ │ ├── login/ # /login、/change-password
|
|
52
|
+
│ │ ├── personal-center/ # /personal-center/my-profile
|
|
53
|
+
│ │ ├── notification/ # /notification-center
|
|
54
|
+
│ │ └── system/ # /system/*(根上 handle.roles = 系统管理员)
|
|
55
|
+
│ ├── oj-market/web/ # 解决方案市场(独立模块,删目录即下线)
|
|
48
56
|
│ ├── auth/api/ # login / refresh / logout / ldap-login
|
|
49
|
-
│ ├── system/
|
|
50
|
-
│ ├── notification/
|
|
51
|
-
│ ├── personal-center/
|
|
57
|
+
│ ├── system/api/ # 用户 / 角色 / 权限 / 设置 / oplog / login-methods(后端)
|
|
58
|
+
│ ├── notification/api/ # 通知互动(表 + push + del)
|
|
59
|
+
│ ├── personal-center/api/ # 头像上传
|
|
52
60
|
│ └── web/{api,web}/ # root 级 /api/web(hello / user-info / get-async-routes 在 api 侧)
|
|
53
|
-
├── api/
|
|
54
|
-
├── web/
|
|
61
|
+
├── dist/api/ # 后端产物(release 根:<name>-<ver>/ + manifests.yaml + versions.json)—构建后才有
|
|
62
|
+
├── dist/web/ # 站点产物(shell + modules/ + modules.json)—构建后才有
|
|
55
63
|
├── bin/ # oj 二进制 + plugins/ + devkit/(不入库)
|
|
56
64
|
└── .claude/skills/ # ojm-module-dev(本 devkit)+ oj-api-dev(随 oj 二进制)
|
|
57
65
|
```
|
|
@@ -62,13 +70,12 @@
|
|
|
62
70
|
所在目录**,所有 `fs.*` 相对路径、`db.default`、`platform.root`、`mounts[].web`
|
|
63
71
|
都相对它解析;证书与密钥落在同级的 `config/`(`ojm` 侧由 `resolveConfigPath()` /
|
|
64
72
|
`resolveCertDir()` 单点派生,你不需要自己拼路径)。
|
|
65
|
-
- **产物有两个根**:后端 `api
|
|
66
|
-
`manifests.yaml` 同层),站点 `web
|
|
67
|
-
`config.yaml` 上移到工程根后,两者不再同名 `dist`。
|
|
73
|
+
- **产物有两个根**:后端 `dist/api`(oj 扁平 release,`<name>-<ver>/` 与
|
|
74
|
+
`manifests.yaml` 同层),站点 `dist/web`(`modules/` + `modules.json`)。
|
|
68
75
|
- **`src/` 下每个目录都得是一个模块**:脚本 / 共享库请放在 `src/` 之外,否则
|
|
69
76
|
`ojm dev/build` 会以「既无 `api/manifest.yaml` 也无 `web/entry.ts`」直接报错。
|
|
70
77
|
|
|
71
|
-
> 模板里还有一批 demo-only 模块(`src/
|
|
78
|
+
> 模板里还有一批 demo-only 模块(`src/home/api`、`src/{about,access,exception,
|
|
72
79
|
> outside,route-nest}/web`)仅供模板自演示,**不进脚手架**;其 `web.config.ts` 条目带
|
|
73
80
|
> `// #region demo-only` 标记,init 拷贝时整段剥离。新布局的清单是「扫描 + 覆盖」并集,
|
|
74
81
|
> 注释掉不会让模块消失——要「不构建」必须写 `enabled: false`。
|
|
@@ -289,14 +296,14 @@ export default {
|
|
|
289
296
|
baseUrl: "", // 模块产物 URL 前缀(可填 CDN 绝对域);与站点部署位置无关
|
|
290
297
|
// siteBase: "/oj", // 站点挂在 https://host/oj/ 下时才需要(默认根路径),见 §13 的路径空间一节
|
|
291
298
|
modules: [
|
|
292
|
-
// home
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// 缺它 `/login` 无路由可跳(登出会落空)。
|
|
297
|
-
{ name: "login", entry: "src/login/web/entry.ts", enabled: true },
|
|
299
|
+
// 框架内置页面聚合为一个**多根模块**(/home /login /personal-center
|
|
300
|
+
// /notification /system):一个 entry 多个根,各根按 handle.order 排菜单。
|
|
301
|
+
// 缺 /home 会让登录回跳 / 点 logo 落错误边界;缺 /login 则登出无路由可跳。
|
|
302
|
+
{ name: "app", entry: "src/app/web/entry.ts", enabled: true },
|
|
298
303
|
{ name: "books", entry: "src/books/web/entry.ts", enabled: true },
|
|
299
304
|
],
|
|
305
|
+
// 多根聚合模块的 client 归属(生成物落 src/app/web/<api 前缀>/__client):
|
|
306
|
+
// clientOwners: { home: "app", "personal-center": "app", notification: "app", system: "app" },
|
|
300
307
|
};
|
|
301
308
|
```
|
|
302
309
|
|
|
@@ -351,7 +358,7 @@ export default defineModule({
|
|
|
351
358
|
`defineModule` 字段:`name`(= 目录名)、`description`、`version`、
|
|
352
359
|
`peerRuntime`、`routes`、`i18n?`、`lifecycle?`。
|
|
353
360
|
|
|
354
|
-
> 页面组件用**静态 import 或 `lazy()` 都行**:模板自带模块(`src/
|
|
361
|
+
> 页面组件用**静态 import 或 `lazy()` 都行**:模板自带模块(`src/app/web/home/entry.ts` 等)
|
|
355
362
|
> 用静态 import,多页面模块可对非首屏页面用 `lazy(() => import("./pages/x"))` 做代码分割。
|
|
356
363
|
> 单个页面就用静态 import,少一层间接。
|
|
357
364
|
|
|
@@ -1349,7 +1356,7 @@ useEffect(() => {
|
|
|
1349
1356
|
复用展示不同内容(列表页/`index` 路由),离开时必须 `resetTableTitle(tabKey)`,否则再进来看到
|
|
1350
1357
|
上一个内容的标题。
|
|
1351
1358
|
|
|
1352
|
-
推荐形态(照 `src/
|
|
1359
|
+
推荐形态(照 `src/app/web/home/pages/`、`src/app/web/personal-center/pages/my-profile/` 抄):
|
|
1353
1360
|
`BasicContent` 包一层 → 搜索区 + `BasicTable` → 新增/编辑用 antd `Modal` + `Form`
|
|
1354
1361
|
→ 提交后重新拉列表。数据来自 §3.4 的生成函数,**页面里直接 import 裸函数调用**。
|
|
1355
1362
|
|
|
@@ -1402,7 +1409,7 @@ useEffect(() => {
|
|
|
1402
1409
|
`useEffect` 重新注册(上面的形态天然满足)。
|
|
1403
1410
|
- 同一 key 关闭流程进行中再次触发被忽略(per-key in-flight 去重),双击不会弹两个框。
|
|
1404
1411
|
|
|
1405
|
-
|
|
1412
|
+
可运行示例见模板工程 `src/app/web/*/pages/` 下的页面(layouts/权限门同源);
|
|
1406
1413
|
模板侧对应 `src/<模块>/web/pages/`,表单 dirty → 关页签弹确认)。
|
|
1407
1414
|
|
|
1408
1415
|
### 6.5 两份 tsconfig:`typecheck` 与 `typecheck:api`
|
|
@@ -1556,7 +1563,7 @@ export default async (path: string, html: string) => {
|
|
|
1556
1563
|
| 登录 401 且 msg 不是 `invalid credentials` | `/auth/*` 不在 `anonymous_paths` | 加进工程根 `config.yaml`,重启 |
|
|
1557
1564
|
| 登出/回登录页空白或 404 | 工程缺 `login` 模块(shell 无内置登录回落) | 在 `web.config.ts` 保留 `login` |
|
|
1558
1565
|
| 登录后 / 点 logo 落 React Router 错误边界 | 工程缺 `home` 模块 | 在 `web.config.ts` 保留 `home`(尽量靠前) |
|
|
1559
|
-
| 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底——而脚手架不再提供 root 级 `/api/notifications` 模块 | 保留 `web.config.ts` 的 `notification` 登记,onInit 里注册四方法(模板 `src/
|
|
1566
|
+
| 通知铃 404 `no route matched`(`/api/notifications`) | 前端 `notification` 模块没加载 / 没注册 `notificationsApi`,回落到内置只读兜底——而脚手架不再提供 root 级 `/api/notifications` 模块 | 保留 `web.config.ts` 的 `notification` 登记,onInit 里注册四方法(模板 `src/app/web/notification/entry.ts`);后端由 `src/notification/api` 提供 `/api/notification/*` |
|
|
1560
1567
|
| `@types/react` / `typescript` 装成 `*` | 宿主 versions.json 未列 | 安装后按实际版本钉死(`*` 不可复现) |
|
|
1561
1568
|
| 迁移账本落后、服务拒绝启动 | release `verify` 闸 | 先 `ojm migrate -d api/dist`(透传自动补 `-c`) |
|
|
1562
1569
|
| `seed.sql` 没生效 / 语法错 | 按 `;` 朴素切分,注释里有分号 | 去掉注释里的分号字面量 |
|
|
@@ -1579,14 +1586,14 @@ export default async (path: string, html: string) => {
|
|
|
1579
1586
|
|
|
1580
1587
|
| 想抄什么 | 抄这里 |
|
|
1581
1588
|
|---|---|
|
|
1582
|
-
| 带页面的模块(路由 / i18n / order) | `src/
|
|
1583
|
-
| 接管通知 provider(四方法全量) | `src/
|
|
1584
|
-
| 上传 provider(action + headers 方法) | `src/personal-center/
|
|
1585
|
-
| 无页面模块(`routes: []`) | 同 `src/
|
|
1589
|
+
| 带页面的模块(路由 / i18n / order) | `src/app/web/home/entry.ts`、`src/app/web/entry.ts` |
|
|
1590
|
+
| 接管通知 provider(四方法全量) | `src/app/web/notification/entry.ts` |
|
|
1591
|
+
| 上传 provider(action + headers 方法) | `src/app/web/personal-center/entry.ts` |
|
|
1592
|
+
| 无页面模块(`routes: []`) | 同 `src/app/web/notification/entry.ts` |
|
|
1586
1593
|
| handler `.schema` 入参 + 平级 `.response` 出参写法(GET + POST、参数段) | `src/notification/api/api.ts` |
|
|
1587
1594
|
| handler 写法(嵌套组列表 / 写入 / 403 归属) | `src/notification/api/api.ts`(模块根单文件,`read`/`read-all`/`clear` 子组) |
|
|
1588
1595
|
| 统一异常出口 `respond()`(service 抛 `Error & {status}` → 信封 `json.fail`) | 模板 `src/_platform/api/_shared/respond.ts` + 各 handler 用法(§1.7-1) |
|
|
1589
|
-
| 生成物长什么样(含工厂) | `src/
|
|
1596
|
+
| 生成物长什么样(含工厂) | `src/app/web/notification/__client/api.ts` |
|
|
1590
1597
|
| 建表三件套 | `src/notification/api/{schema.yaml,manifest.yaml,migrations/}` |
|
|
1591
1598
|
| 全局类型(`window.$message` 等) | 工程根 `env.d.ts` |
|
|
1592
1599
|
|
|
@@ -2229,7 +2236,7 @@ async function post() {
|
|
|
2229
2236
|
3. 页面用本模块 client 调。
|
|
2230
2237
|
|
|
2231
2238
|
多一层 handler 换来三件事:接口形状由**你自己的契约**决定(只出页面要的字段)、框架内部实现不进你的
|
|
2232
|
-
bundle、契约变更走你自己的版本号。仓库里 `src/
|
|
2239
|
+
bundle、契约变更走你自己的版本号。仓库里 `src/app/web/system/pages/departments` 的组织架构页就是这条路的
|
|
2233
2240
|
另一个方向(它消费的是管理端 `/api/system/departments`,见 §14.4)。
|
|
2234
2241
|
|
|
2235
2242
|
### 14.4 管理端 CRUD:只有「管组织架构」的模块才该碰
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oj-module/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.54",
|
|
5
5
|
"description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@faker-js/faker": "^10.4.0",
|
|
52
|
-
"@oj-module/runtime": "0.2.
|
|
52
|
+
"@oj-module/runtime": "0.2.54",
|
|
53
53
|
"@redocly/cli": "^2.51.0",
|
|
54
54
|
"@tailwindcss/vite": "^4.3.0",
|
|
55
55
|
"cac": "^6.7.14",
|
|
@@ -304,10 +304,10 @@ function getAppInfo() {
|
|
|
304
304
|
return {
|
|
305
305
|
"pkg": {
|
|
306
306
|
"name": "@oj-module/runtime",
|
|
307
|
-
"version": "0.2.
|
|
307
|
+
"version": "0.2.54",
|
|
308
308
|
"license": "MIT"
|
|
309
309
|
},
|
|
310
|
-
"lastBuildTime": "2026-11-02
|
|
310
|
+
"lastBuildTime": "2026-11-02 18:04:24"
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
var init_get_app_info = __esmMin((() => {}));
|
|
@@ -7500,11 +7500,14 @@ function topologicalSort(entries, definitions) {
|
|
|
7500
7500
|
return sorted;
|
|
7501
7501
|
}
|
|
7502
7502
|
async function mergeI18nResources(definition) {
|
|
7503
|
-
if (
|
|
7504
|
-
for (const [locale, loader] of Object.entries(definition.i18n)) {
|
|
7503
|
+
if (definition.i18n) for (const [locale, loader] of Object.entries(definition.i18n)) {
|
|
7505
7504
|
const resources = await loader();
|
|
7506
7505
|
i18next.addResourceBundle(locale, definition.name, resources.default || resources);
|
|
7507
7506
|
}
|
|
7507
|
+
for (const [namespace, bundle] of Object.entries(definition.i18nNamespaces ?? {})) for (const [locale, loader] of Object.entries(bundle)) {
|
|
7508
|
+
const resources = await loader();
|
|
7509
|
+
i18next.addResourceBundle(locale, namespace, resources.default || resources);
|
|
7510
|
+
}
|
|
7508
7511
|
}
|
|
7509
7512
|
async function loadAll(manifest) {
|
|
7510
7513
|
const enabledEntries = manifest.modules.filter((m) => m.enabled !== false);
|
package/shell-dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-
|
|
9
|
-
<script type="importmap" nonce="
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-Php0JD1/JaEf2vFUnNnXaQ=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
|
+
<script type="importmap" nonce="Php0JD1/JaEf2vFUnNnXaQ==">{
|
|
10
10
|
"imports": {
|
|
11
11
|
"react": "/assets/react.js",
|
|
12
12
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
.boot-skeleton:after{content:"";width:18px;height:18px;margin-left:8px;border:2px solid #d9d9d9;border-top-color:#1677ff;border-radius:50%;animation:bs-spin .8s linear infinite}
|
|
131
131
|
@keyframes bs-spin{to{transform:rotate(360deg)}}
|
|
132
132
|
</style>
|
|
133
|
-
<script nonce="
|
|
133
|
+
<script nonce="Php0JD1/JaEf2vFUnNnXaQ==" data-ojm-entry="/assets/index-e_Mp2mb5.js">(function(){try{var ua=(navigator.userAgent||"").toLowerCase(),p=location.pathname;while(p.length>1&&p.charAt(p.length-1)==="/")p=p.slice(0,-1);if(ua.indexOf("wxwork")>=0&&p.slice(-6)==="/login"&&!new URLSearchParams(location.search).get("wecom_ticket")){var a=(typeof window.__OJ_API_BASE__==="string"&&window.__OJ_API_BASE__.trim())||"/api";while(a.length>1&&a.charAt(a.length-1)==="/")a=a.slice(0,-1);location.replace(a+"/auth/wecom/login");return}}catch(e){}var s=document.createElement("script");s.type="module";s.src=document.currentScript.getAttribute("data-ojm-entry");document.head.appendChild(s);})();</script>
|
|
134
134
|
</head>
|
|
135
135
|
<body>
|
|
136
136
|
<div id="root"><div class="boot-skeleton">加载中…</div></div>
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.2.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.54",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.54",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.54",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -115,7 +115,7 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
|
|
|
115
115
|
expect(unknown).toEqual([]);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
it("t-4
|
|
118
|
+
it("t-4 27 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
|
|
119
119
|
const { tables } = loadSchemaTables();
|
|
120
120
|
const shared = [...tables].filter(([, d]) => d.tenant === false).map(([n]) => n).sort();
|
|
121
121
|
// Phase 0 落地 21 张(11 A + 10 B);Phase 1 加企微三表,全部 B 组(D9:查它们时必然还没有租户上下文)
|
|
@@ -124,7 +124,8 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
|
|
|
124
124
|
// auth/0002 加 wecom_sync_job / wecom_sync_lock 两张,同属 A 组:同步只在调用方自己的租户内跑
|
|
125
125
|
// (D7 不接受 corpId 参数),故不进 shared_allow——跨租户读元数据那条另走显式 tenant_id 的裸 SQL。
|
|
126
126
|
// 改这张表清单 = 改租户隔离边界,必须同步 config.yaml 的 tenant.shared_allow(t-2/t-3 会把关)。
|
|
127
|
-
|
|
127
|
+
// 2026-11-02 demo 模块下线(ecc7b35e)删掉 demo/api/schema.yaml 的 todos 表,28 → 27。
|
|
128
|
+
expect(tables.size).toBe(27);
|
|
128
129
|
expect(shared).toEqual([
|
|
129
130
|
"_oj_migrations",
|
|
130
131
|
"market_history",
|
package/src/config.ts
CHANGED
|
@@ -48,6 +48,15 @@ export interface ModulesConfig {
|
|
|
48
48
|
* `(path, html) => string | { html, cacheControl? } | Promise<…>`)。
|
|
49
49
|
*/
|
|
50
50
|
htmlTransform?: string
|
|
51
|
+
/**
|
|
52
|
+
* 生成 client 的归属模块:`api 前缀 → 承载其 __client 的 web 模块`。
|
|
53
|
+
*
|
|
54
|
+
* 缺省(不配)= 同名模块,即 `src/<api 前缀>/web/__client`(历史行为,零漂移)。
|
|
55
|
+
* 多根聚合模块把多个业务域的页面/client 收进同一个 web 模块时配它:生成物落
|
|
56
|
+
* `src/<归属模块>/web/<api 前缀>/__client`,页面相对导入 `../__client` 不变。
|
|
57
|
+
* 只影响 `ojm api` 的落盘位置与生成物里的认领护栏,不影响路由与后端。
|
|
58
|
+
*/
|
|
59
|
+
clientOwners?: Record<string, string>
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl" | "siteBase">> = {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ProjectLayout } from "../../layout";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { clientDirOf } from "../client-path";
|
|
5
|
+
|
|
6
|
+
const layout: ProjectLayout = {
|
|
7
|
+
kind: "module-root",
|
|
8
|
+
projectRoot: "/proj",
|
|
9
|
+
modulesSrc: "/proj/src",
|
|
10
|
+
distDir: "/proj/dist/web",
|
|
11
|
+
apiDist: "/proj/dist/api",
|
|
12
|
+
watchTarget: "/proj/src",
|
|
13
|
+
moduleRoot: "/proj/src",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("clientDirOf(client 落盘目录)", () => {
|
|
17
|
+
it("缺省 = 同名模块 src/<模块>/web/__client(历史口径零漂移)", () => {
|
|
18
|
+
expect(clientDirOf(layout, "/proj", "system")).toBe(join("/proj/src/system/web", "__client"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("clientOwners 指定归属 → src/<归属>/web/<api 前缀>/__client", () => {
|
|
22
|
+
expect(clientDirOf(layout, "/proj", "system", { system: "app" }))
|
|
23
|
+
.toBe(join("/proj/src/app/web/system", "__client"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("历史布局(layout=null)同样支持归属", () => {
|
|
27
|
+
expect(clientDirOf(null, "/proj", "system", { system: "app" }))
|
|
28
|
+
.toBe(join("/proj/web/src/app/system", "__client"));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -125,6 +125,24 @@ describe("emitClient 注入安全:模块名含特殊字符不破坏源码", ()
|
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
describe("emitClient 多根聚合归属(owner)", () => {
|
|
129
|
+
it("owner 与 module 不同 → 落 MODULE_OWNER,护栏对比归属模块;MODULE_PREFIX 仍是 api 前缀", () => {
|
|
130
|
+
const ir = irFromOpenapi(DOC, { base: "/v1/api" });
|
|
131
|
+
const { "api.ts": apiTs } = emitClient(ir, { target: "module", module: "system", owner: "app" });
|
|
132
|
+
expect(apiTs).toContain("export const MODULE_PREFIX = \"/system\";");
|
|
133
|
+
expect(apiTs).toContain("export const MODULE_OWNER = \"/app\";");
|
|
134
|
+
expect(apiTs).toContain("ctx.module.name !== MODULE_OWNER.slice(1)");
|
|
135
|
+
expect(apiTs).not.toContain("ctx.module.name !== MODULE_PREFIX.slice(1)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("owner 缺省 = module → 生成物与历史逐字一致(不出现 MODULE_OWNER)", () => {
|
|
139
|
+
const ir = irFromOpenapi(DOC, { base: "/v1/api" });
|
|
140
|
+
const { "api.ts": apiTs } = emitClient(ir, { target: "module", module: "system", owner: "system" });
|
|
141
|
+
expect(apiTs).not.toContain("MODULE_OWNER");
|
|
142
|
+
expect(apiTs).toContain("ctx.module.name !== MODULE_PREFIX.slice(1)");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
128
146
|
describe("emitClient 空 IR 报错", () => {
|
|
129
147
|
it("iR 为空抛错(无端点无需生成)", () => {
|
|
130
148
|
expect(() => emitClient([], { target: "module", module: "x" })).toThrow();
|
|
@@ -181,3 +181,26 @@ describe("handler-scan · 对账键与契约侧同形(参数路由精确命中
|
|
|
181
181
|
expect(pathKey(row.method, row.full)).toBe(pathKey("DELETE", "wiki/{id}"));
|
|
182
182
|
});
|
|
183
183
|
});
|
|
184
|
+
|
|
185
|
+
describe("handler-scan · 工厂返回本地对象(const api = { … }; return api)", () => {
|
|
186
|
+
it("回归:聚合工厂 return 标识符时仍展开动词与嵌套组(solutions/api.ts 形态)", () => {
|
|
187
|
+
const rows = scan("api.ts", `
|
|
188
|
+
import type { Env } from "./types";
|
|
189
|
+
export function createApi(env: Env) {
|
|
190
|
+
\tconst routes = { async get() {} };
|
|
191
|
+
\tconst verify = {
|
|
192
|
+
\t\tasync post() {},
|
|
193
|
+
\t\tlast: { async get() {} },
|
|
194
|
+
\t};
|
|
195
|
+
\tconst api = { routes, verify };
|
|
196
|
+
\treturn api;
|
|
197
|
+
}
|
|
198
|
+
export default createApi({} as Env);
|
|
199
|
+
`);
|
|
200
|
+
expect(rows.map(r => pathKey(r.method, handlerAbs(r))).sort()).toEqual([
|
|
201
|
+
"GET /_platform/market/routes",
|
|
202
|
+
"GET /_platform/market/verify/last",
|
|
203
|
+
"POST /_platform/market/verify",
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/contract/check.ts
CHANGED
|
@@ -4,8 +4,9 @@ import type { IrEndpoint } from "./ir";
|
|
|
4
4
|
import type { OpenApiDoc } from "./openapi-ir";
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join, relative, sep } from "node:path";
|
|
7
|
-
import { apiModuleRoots, apiModulesSrc, apiSrcOf, legacyApiModuleRoots, resolveConfigPath, tryResolveLayout
|
|
7
|
+
import { apiModuleRoots, apiModulesSrc, apiSrcOf, legacyApiModuleRoots, resolveConfigPath, tryResolveLayout } from "../layout";
|
|
8
8
|
import { readOjAnonymousPaths } from "../oj-config";
|
|
9
|
+
import { clientDirOf, loadClientOwners } from "./client-path";
|
|
9
10
|
import { emitClient } from "./emit-client";
|
|
10
11
|
import { planStubWrites } from "./emit-stub";
|
|
11
12
|
import { handlerAbs, implementedRouteKeys, pathKey, scanHandlerFile, VERB_OF, walkApiFiles } from "./handler-scan";
|
|
@@ -308,11 +309,10 @@ async function checkFromOpenapi(openapiPath: string, cwd: string, _exemption: Ap
|
|
|
308
309
|
// 契约工具的扫描单元是「模块 api 根」而不是整棵源码树:module-root 布局里
|
|
309
310
|
// `src/<name>/api` 的 `api` 段不属于路由,且 `web/__client/api.ts` 不能被当 handler。
|
|
310
311
|
const roots = layout ? apiModuleRoots(layout) : legacyApiModuleRoots(cwd);
|
|
311
|
-
const
|
|
312
|
-
join(layout ? webSrcOf(layout, module) : join(cwd, "web/src", module), "__client");
|
|
312
|
+
const owners = await loadClientOwners(layout?.projectRoot ?? cwd);
|
|
313
313
|
for (const [module, eps] of groupIrByModule(ir)) {
|
|
314
|
-
const client = emitClient(eps, { target: "module", module });
|
|
315
|
-
const clientDir = clientDirOf(module);
|
|
314
|
+
const client = emitClient(eps, { target: "module", module, owner: owners[module] });
|
|
315
|
+
const clientDir = clientDirOf(layout, cwd, module, owners);
|
|
316
316
|
for (const [file, content] of [["api.ts", client["api.ts"]], ["api.schemas.ts", client["api.schemas.ts"]]] as const) {
|
|
317
317
|
const p = join(clientDir, file);
|
|
318
318
|
if (!existsSync(p) || readFileSync(p, "utf8") !== content)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ProjectLayout } from "../layout";
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadSiteConfig } from "../config";
|
|
5
|
+
import { webSrcOf } from "../layout";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 生成 client 的落盘目录(`ojm api` 写、`ojm api --check` 核对,同一真源)。
|
|
9
|
+
*
|
|
10
|
+
* 缺省口径不变:`<project>/src/<api 前缀>/web/__client`(历史布局 `web/src/<模块>/__client`)。
|
|
11
|
+
* 多根聚合模块(web.config `clientOwners`)把多个业务域的 client 收进同一个 web 模块:
|
|
12
|
+
* 落 `<project>/src/<归属模块>/web/<api 前缀>/__client`——页面与该目录一起迁移,
|
|
13
|
+
* 页面里的相对导入 `../../__client` 不用改。
|
|
14
|
+
*/
|
|
15
|
+
export function clientDirOf(
|
|
16
|
+
layout: ProjectLayout | null,
|
|
17
|
+
cwd: string,
|
|
18
|
+
apiModule: string,
|
|
19
|
+
owners: Record<string, string> = {},
|
|
20
|
+
): string {
|
|
21
|
+
const owner = owners[apiModule] ?? apiModule;
|
|
22
|
+
const base = layout ? webSrcOf(layout, owner) : path.join(cwd, "web/src", owner);
|
|
23
|
+
return owner === apiModule ? path.join(base, "__client") : path.join(base, apiModule, "__client");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 读 web.config.ts 的 `clientOwners`(缺文件 / 未配置 = 空表,口径回落同名模块) */
|
|
27
|
+
export async function loadClientOwners(projectRoot: string): Promise<Record<string, string>> {
|
|
28
|
+
const config = await loadSiteConfig(projectRoot);
|
|
29
|
+
return config.clientOwners ?? {};
|
|
30
|
+
}
|
|
@@ -229,8 +229,8 @@ function emitEndpoint(ep: IrEndpoint): string {
|
|
|
229
229
|
}`;
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
function emitPrelude(target: "module" | "internal", module?: string, endpointNames: string[] = [], wirePrefix?: string): string {
|
|
233
|
-
|
|
232
|
+
function emitPrelude(target: "module" | "internal", module?: string, endpointNames: string[] = [], wirePrefix?: string, owner?: string): string {
|
|
233
|
+
let binding = target === "module"
|
|
234
234
|
? `let req: ScopedRequestLike | undefined;
|
|
235
235
|
|
|
236
236
|
/** 线上 API 前缀(ojm api 从契约抽取,唯一真源,勿手改):请求基址 + 前缀登记用 */
|
|
@@ -275,6 +275,20 @@ function ensureReq(): ScopedRequestLike {
|
|
|
275
275
|
return request;
|
|
276
276
|
}`;
|
|
277
277
|
|
|
278
|
+
// 多根聚合模块:client 落在归属模块目录下,认领护栏的对比基准随之改为归属模块名。
|
|
279
|
+
// 缺省(未配 clientOwners)owner === module,生成物与历史逐字一致(快照不变)。
|
|
280
|
+
if (target === "module" && owner && owner !== module) {
|
|
281
|
+
binding = binding
|
|
282
|
+
.replace(
|
|
283
|
+
`export const MODULE_PREFIX = ${JSON.stringify(`/${module}`)};`,
|
|
284
|
+
`export const MODULE_PREFIX = ${JSON.stringify(`/${module}`)};
|
|
285
|
+
|
|
286
|
+
/** 认领方模块(web.config clientOwners 指定):跨模块认领护栏对比基准 */
|
|
287
|
+
export const MODULE_OWNER = ${JSON.stringify(`/${owner}`)};`,
|
|
288
|
+
)
|
|
289
|
+
.replaceAll("MODULE_PREFIX.slice(1)", "MODULE_OWNER.slice(1)");
|
|
290
|
+
}
|
|
291
|
+
|
|
278
292
|
const ctxImport = target === "module"
|
|
279
293
|
? "import type { ModuleContext } from \"@oj-module/runtime\";\n"
|
|
280
294
|
: "";
|
|
@@ -344,7 +358,7 @@ ${entries.join("\n")}
|
|
|
344
358
|
}
|
|
345
359
|
|
|
346
360
|
/** 发射双产物:api.ts(类型 + 请求函数 + 工厂)与 api.schemas.ts(DEV 校验 schema) */
|
|
347
|
-
export function emitClient(ir: IrEndpoint[], opts: { target: "module" | "internal", module?: string }): { "api.ts": string, "api.schemas.ts": string } {
|
|
361
|
+
export function emitClient(ir: IrEndpoint[], opts: { target: "module" | "internal", module?: string, /** client 归属模块(多根聚合);缺省 = module */ owner?: string }): { "api.ts": string, "api.schemas.ts": string } {
|
|
348
362
|
if (ir.length === 0)
|
|
349
363
|
throw new Error("[ojm-api] client 发射失败:IR 为空——openapi.json 里没有任何端点(operationId + tags),无需生成;请检查 \`oj openapi\` 产物。");
|
|
350
364
|
if (opts.target === "module" && !opts.module) {
|
|
@@ -362,7 +376,7 @@ export function emitClient(ir: IrEndpoint[], opts: { target: "module" | "interna
|
|
|
362
376
|
+ "——register.apiPrefix 一模块一值,urlPrefix 映射须整模块一致(不同前缀请拆模块)。",
|
|
363
377
|
);
|
|
364
378
|
}
|
|
365
|
-
const sections: string[] = [emitPrelude(opts.target, opts.module, ir.map(ep => ep.name), wirePrefixes.size === 1 ? wirePrefixes.values().next().value : undefined)];
|
|
379
|
+
const sections: string[] = [emitPrelude(opts.target, opts.module, ir.map(ep => ep.name), wirePrefixes.size === 1 ? wirePrefixes.values().next().value : undefined, opts.owner)];
|
|
366
380
|
for (const ep of ir) {
|
|
367
381
|
const types = emitTypes(ep);
|
|
368
382
|
if (types.length)
|
|
@@ -161,7 +161,23 @@ function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLite
|
|
|
161
161
|
* 旧逻辑直接 dottedNameOf(call.expression) 对 CallExpression 返回 undefined → 扫成 0 路由,
|
|
162
162
|
* 致使 ojm api --check 把 market 全族端点判为「未实现 / stub 缺失」。此处穿透一层取被调函数名。
|
|
163
163
|
*/
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* 取函数体内 `const <name> = { … }` 的对象字面量初始化器(工厂 return 标识符时用)。
|
|
166
|
+
* 只认直接声明,不追闭包/别名——够用即可,追深了会把无关对象当路由组。
|
|
167
|
+
*/
|
|
168
|
+
function objectLiteralOf(body: ts.Block, name: string): ts.ObjectLiteralExpression | undefined {
|
|
169
|
+
for (const st of body.statements) {
|
|
170
|
+
if (!ts.isVariableStatement(st))
|
|
171
|
+
continue;
|
|
172
|
+
for (const d of st.declarationList.declarations) {
|
|
173
|
+
if (ts.isIdentifier(d.name) && d.name.text === name && d.initializer && ts.isObjectLiteralExpression(d.initializer))
|
|
174
|
+
return d.initializer;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function factoryReturnObjects(sf: ts.SourceFile, call: ts.CallExpression, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression[] {
|
|
165
181
|
// `export default createXxxApi(marketEnv())`:`call.expression` 为带实参的调用表达式,
|
|
166
182
|
// 函数名在其 `.expression`;`export default obj.createXxxApi(marketEnv())` 同样穿透。
|
|
167
183
|
const target = ts.isCallExpression(call.expression) ? call.expression.expression : call.expression;
|
|
@@ -171,10 +187,31 @@ function factoryReturnObjects(sf: ts.SourceFile, call: ts.CallExpression): ts.Ob
|
|
|
171
187
|
for (const stmt of sf.statements) {
|
|
172
188
|
if (!ts.isFunctionDeclaration(stmt) || stmt.name?.text !== callee || stmt.body === undefined)
|
|
173
189
|
continue;
|
|
190
|
+
// 工厂体内的局部对象声明(`const routes = {…}`)合并进声明表:
|
|
191
|
+
// return 的对象里用简写属性引用它们时,walk 才能解析到组。
|
|
192
|
+
for (const st of stmt.body.statements) {
|
|
193
|
+
if (!ts.isVariableStatement(st))
|
|
194
|
+
continue;
|
|
195
|
+
for (const d of st.declarationList.declarations) {
|
|
196
|
+
if (ts.isIdentifier(d.name) && d.initializer && ts.isObjectLiteralExpression(d.initializer))
|
|
197
|
+
decls.set(d.name.text, d.initializer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
174
200
|
const out: ts.ObjectLiteralExpression[] = [];
|
|
175
201
|
for (const st of stmt.body.statements) {
|
|
176
|
-
if (ts.isReturnStatement(st)
|
|
202
|
+
if (!ts.isReturnStatement(st) || st.expression === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
if (ts.isObjectLiteralExpression(st.expression)) {
|
|
177
205
|
out.push(st.expression);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// `const api = { routes, verify }; … return api;` 形态(聚合工厂):
|
|
209
|
+
// 返回标识符时回溯同函数体内的 object literal 初始化器。
|
|
210
|
+
if (ts.isIdentifier(st.expression)) {
|
|
211
|
+
const local = objectLiteralOf(stmt.body, st.expression.text);
|
|
212
|
+
if (local)
|
|
213
|
+
out.push(local);
|
|
214
|
+
}
|
|
178
215
|
}
|
|
179
216
|
return out;
|
|
180
217
|
}
|
|
@@ -317,7 +354,7 @@ export function scanHandlerFile(filePath: string, apiDir: string, moduleName = "
|
|
|
317
354
|
? expr
|
|
318
355
|
: (ts.isIdentifier(expr) ? callDecls.get(expr.text) : undefined);
|
|
319
356
|
if (callExpr) {
|
|
320
|
-
for (const obj of factoryReturnObjects(sf, callExpr))
|
|
357
|
+
for (const obj of factoryReturnObjects(sf, callExpr, decls))
|
|
321
358
|
walk(obj, "", "", 1);
|
|
322
359
|
}
|
|
323
360
|
}
|
package/src/contract/run.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { basename, dirname, join } from "node:path";
|
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { stringify as stringifyYaml } from "yaml";
|
|
11
|
-
import { apiModuleRoots, apiModulesSrc, apiSrcOf, legacyApiModuleRoots, scanModules, tryResolveLayout
|
|
11
|
+
import { apiModuleRoots, apiModulesSrc, apiSrcOf, legacyApiModuleRoots, scanModules, tryResolveLayout } from "../layout";
|
|
12
|
+
import { clientDirOf, loadClientOwners } from "./client-path";
|
|
12
13
|
import { emitClient } from "./emit-client";
|
|
13
14
|
import { applyStubWrites, planStubWrites } from "./emit-stub";
|
|
14
15
|
import { implementedRouteKeys } from "./handler-scan";
|
|
@@ -83,10 +84,11 @@ async function runFromOpenapi(openapiPath: string, cwd: string, result: RunResul
|
|
|
83
84
|
const ir = irFromOpenapi(doc, { rawPaths });
|
|
84
85
|
const byModule = groupIrByModule(ir);
|
|
85
86
|
const layout = tryResolveLayout(cwd);
|
|
87
|
+
const owners = await loadClientOwners(layout?.projectRoot ?? cwd);
|
|
86
88
|
result.contracts += byModule.size;
|
|
87
89
|
for (const [module, eps] of byModule) {
|
|
88
|
-
const client = emitClient(eps, { target: "module", module });
|
|
89
|
-
const clientDir =
|
|
90
|
+
const client = emitClient(eps, { target: "module", module, owner: owners[module] });
|
|
91
|
+
const clientDir = clientDirOf(layout, cwd, module, owners);
|
|
90
92
|
writeIfChanged(join(clientDir, "api.ts"), client["api.ts"], result);
|
|
91
93
|
writeIfChanged(join(clientDir, "api.schemas.ts"), client["api.schemas.ts"], result);
|
|
92
94
|
}
|
package/src/init.ts
CHANGED
|
@@ -240,6 +240,17 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
|
|
|
240
240
|
// 与 pnpm-workspace.yaml 同型,改由 init 现场生成
|
|
241
241
|
ensureGitignore(destDir, report);
|
|
242
242
|
|
|
243
|
+
// 5d) 旧布局冲突提示:0.2.54 起 home/login/personal-center/notification/system 的
|
|
244
|
+
// web 侧聚合成 src/app/web 多根模块。init 只补缺不删除——老工程重跑 init 会
|
|
245
|
+
// 同时留下旧目录,扫描后同名路由注册两遍(菜单重复、行为不一致)。
|
|
246
|
+
const mergedEntry = path.join(destDir, "src", "app", "web", "entry.ts");
|
|
247
|
+
if (fs.existsSync(mergedEntry)) {
|
|
248
|
+
const leftovers = ["home", "login", "personal-center", "notification", "system"]
|
|
249
|
+
.filter(name => fs.existsSync(path.join(destDir, "src", name, "web", "entry.ts")));
|
|
250
|
+
if (leftovers.length > 0)
|
|
251
|
+
report.warnings.push(`检测到旧布局的前端模块目录(${leftovers.map(n => `src/${n}/web`).join("、")})与新的聚合模块 src/app/web 并存——路由会重复注册。请手工删除这些旧 web 目录(其 api 侧保留)。`);
|
|
252
|
+
}
|
|
253
|
+
|
|
243
254
|
console.log(`[ojm] init 完成:${destDir}`);
|
|
244
255
|
console.log(`[ojm] 新增 ${report.created.length} 项,跳过(已存在)${report.skipped.length} 项`);
|
|
245
256
|
for (const item of report.skipped)
|
|
@@ -407,7 +418,10 @@ function pruneUnincludedApiPaths(destDir: string, include: Set<string>, report:
|
|
|
407
418
|
if (mods.size === 0)
|
|
408
419
|
return;
|
|
409
420
|
const openapiPath = path.join(destDir, "openapi.json");
|
|
410
|
-
const doc = JSON.parse(fs.readFileSync(openapiPath, "utf-8")) as {
|
|
421
|
+
const doc = JSON.parse(fs.readFileSync(openapiPath, "utf-8")) as {
|
|
422
|
+
paths?: Record<string, unknown>
|
|
423
|
+
tags?: { name?: string }[]
|
|
424
|
+
};
|
|
411
425
|
const paths = doc.paths ?? {};
|
|
412
426
|
const dropped = Object.keys(paths).filter((p) => {
|
|
413
427
|
const mod = /^\/api\/([^/]+)\//.exec(p)?.[1];
|
|
@@ -417,6 +431,10 @@ function pruneUnincludedApiPaths(destDir: string, include: Set<string>, report:
|
|
|
417
431
|
return;
|
|
418
432
|
for (const p of dropped)
|
|
419
433
|
delete paths[p];
|
|
434
|
+
// 顶层 tags 也要同步:只裁路径会留下指向不存在模块的 tag(契约自相矛盾,
|
|
435
|
+
// oj openapi 重产时又会消失,属 drift 噪声)。
|
|
436
|
+
if (Array.isArray(doc.tags))
|
|
437
|
+
doc.tags = doc.tags.filter(t => typeof t.name !== "string" || mods.has(t.name));
|
|
420
438
|
fs.writeFileSync(openapiPath, `${JSON.stringify(doc, null, "\t")}\n`);
|
|
421
439
|
report.created.push(`openapi.json 裁掉未收录 api 模块端点 ${dropped.length} 条`);
|
|
422
440
|
}
|