@oj-module/cli 0.2.50 → 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.
Files changed (130) hide show
  1. package/devkit/SKILL.md +2 -2
  2. package/devkit/manual.md +32 -25
  3. package/devkit/wecom-internal-app.md +111 -4
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-FYt9CjFd.js → index-e_Mp2mb5.js} +5 -1
  6. package/shell-dist/assets/runtime.js +29 -5
  7. package/shell-dist/index.html +9 -12
  8. package/shell-dist/versions.json +3 -3
  9. package/src/__tests__/templates-tenant-scope.test.ts +3 -2
  10. package/src/build.ts +19 -2
  11. package/src/config.ts +9 -0
  12. package/src/contract/__tests__/client-path.test.ts +30 -0
  13. package/src/contract/__tests__/emit-client.test.ts +18 -0
  14. package/src/contract/__tests__/handler-scan.test.ts +23 -0
  15. package/src/contract/check.ts +5 -5
  16. package/src/contract/client-path.ts +30 -0
  17. package/src/contract/emit-client.ts +18 -4
  18. package/src/contract/handler-scan.ts +40 -3
  19. package/src/contract/run.ts +5 -3
  20. package/src/init.ts +19 -1
  21. package/src/shared-deps.ts +27 -9
  22. package/src/site-base.ts +1 -1
  23. package/templates/README.md +40 -44
  24. package/templates/init.include.yaml +15 -14
  25. package/templates/openapi.json +3422 -1955
  26. package/templates/src/_platform/api/_shared/service/domain/users.ts +40 -4
  27. package/templates/src/_platform/api/contacts/api.ts +29 -1
  28. package/templates/src/_platform/api/contacts/detail/api.ts +25 -1
  29. package/templates/src/_platform/api/solutions/api.ts +60 -1
  30. package/templates/src/_platform/web/__client/api.schemas.ts +99 -35
  31. package/templates/src/_platform/web/__client/api.ts +103 -59
  32. package/templates/src/app/web/entry.ts +77 -0
  33. package/templates/src/{home/web → app/web/home}/__client/api.ts +5 -2
  34. package/templates/src/app/web/home/routes.ts +41 -0
  35. package/templates/src/{login/web → app/web/login}/pages/login.tsx +54 -24
  36. package/templates/src/app/web/login/routes.ts +71 -0
  37. package/templates/src/{notification/web → app/web/notification}/__client/api.schemas.ts +14 -0
  38. package/templates/src/{notification/web → app/web/notification}/__client/api.ts +67 -20
  39. package/templates/src/app/web/notification/routes.ts +70 -0
  40. package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.ts +5 -2
  41. package/templates/src/app/web/personal-center/routes.ts +49 -0
  42. package/templates/src/{system/web → app/web/system}/__client/api.schemas.ts +28 -22
  43. package/templates/src/{system/web → app/web/system}/__client/api.ts +103 -58
  44. package/templates/src/{system/web → app/web/system}/locales/en-US.json +14 -0
  45. package/templates/src/{system/web → app/web/system}/locales/zh-CN.json +14 -0
  46. package/templates/src/{system/web → app/web/system}/pages/users/index.tsx +5 -2
  47. package/templates/src/{system/web → app/web/system}/pages/users/roles.tsx +14 -3
  48. package/templates/src/{system/web → app/web/system}/pages/wecom/index.tsx +139 -1
  49. package/templates/src/app/web/system/routes.ts +97 -0
  50. package/templates/src/auth/api/_shared/wecom/admin.ts +4 -0
  51. package/templates/src/auth/api/_shared/wecom/client.ts +9 -2
  52. package/templates/src/auth/api/_shared/wecom/events.ts +3 -0
  53. package/templates/src/auth/api/_shared/wecom/tenant-admin.ts +194 -0
  54. package/templates/src/auth/api/api.ts +59 -0
  55. package/templates/src/auth/api/ldap-login/api.ts +37 -1
  56. package/templates/src/auth/api/wecom/admin/tenant-admin/api.ts +41 -0
  57. package/templates/src/auth/api/wecom/admin/tenant-admin/password/api.ts +66 -0
  58. package/templates/src/auth/api/wecom/api.ts +23 -0
  59. package/templates/src/auth/web/__client/api.schemas.ts +133 -13
  60. package/templates/src/auth/web/__client/api.ts +129 -31
  61. package/templates/src/notification/api/api.ts +17 -0
  62. package/templates/src/notification/api/del/api.ts +2 -0
  63. package/templates/src/system/api/roles/api.ts +3 -0
  64. package/templates/src/system/api/roles/bindings/api.ts +3 -0
  65. package/templates/src/system/api/settings/api.ts +6 -3
  66. package/templates/src/system/api/users/forbidden/api.ts +3 -0
  67. package/templates/src/system/api/users/password/api.ts +3 -0
  68. package/templates/src/system/api/users/roles/api.ts +3 -0
  69. package/templates/src/web/api/get-async-routes/api.ts +16 -1
  70. package/templates/src/web/api/hello/api.ts +13 -1
  71. package/templates/src/web/api/user-info/api.ts +21 -1
  72. package/templates/src/web/web/__client/api.schemas.ts +28 -0
  73. package/templates/src/web/web/__client/api.ts +33 -9
  74. package/templates/tsconfig.paths.json +0 -1
  75. package/templates/web.config.ts +22 -17
  76. package/vendor/host-versions.json +3 -3
  77. package/shell-dist/assets/echarts-charts.js +0 -58571
  78. package/shell-dist/assets/echarts-features.js +0 -24057
  79. package/shell-dist/assets/echarts-for-react.js +0 -704
  80. package/shell-dist/assets/echarts.js +0 -82916
  81. package/shell-dist/assets/editor.js +0 -161457
  82. package/shell-dist/assets/vditor-index-css.js +0 -7
  83. package/shell-dist/assets/vditor.js +0 -15223
  84. package/templates/src/demo/api/manifest.yaml +0 -5
  85. package/templates/src/demo/api/migrations/0001__create_todos.sql +0 -9
  86. package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +0 -2
  87. package/templates/src/demo/api/migrations/0002__tenant.sql +0 -2
  88. package/templates/src/demo/api/schema.yaml +0 -12
  89. package/templates/src/demo/api/seed.sql +0 -5
  90. package/templates/src/demo/api/todos/api.ts +0 -59
  91. package/templates/src/demo/web/__client/api.schemas.ts +0 -20
  92. package/templates/src/demo/web/__client/api.ts +0 -89
  93. package/templates/src/demo/web/entry.ts +0 -42
  94. package/templates/src/demo/web/locales/en-US.json +0 -10
  95. package/templates/src/demo/web/locales/zh-CN.json +0 -10
  96. package/templates/src/demo/web/pages/index.tsx +0 -45
  97. package/templates/src/home/web/entry.ts +0 -49
  98. package/templates/src/login/web/entry.ts +0 -79
  99. package/templates/src/notification/web/entry.ts +0 -84
  100. package/templates/src/personal-center/web/entry.ts +0 -60
  101. package/templates/src/system/web/entry.ts +0 -108
  102. /package/templates/src/{home/web → app/web/home}/__client/api.schemas.ts +0 -0
  103. /package/templates/src/{home/web → app/web/home}/locales/en-US.json +0 -0
  104. /package/templates/src/{home/web → app/web/home}/locales/zh-CN.json +0 -0
  105. /package/templates/src/{home/web → app/web/home}/pages/components/bar-chart.tsx +0 -0
  106. /package/templates/src/{home/web → app/web/home}/pages/components/card-list.tsx +0 -0
  107. /package/templates/src/{home/web → app/web/home}/pages/components/line-chart.tsx +0 -0
  108. /package/templates/src/{home/web → app/web/home}/pages/components/pie-chart.tsx +0 -0
  109. /package/templates/src/{home/web → app/web/home}/pages/index.tsx +0 -0
  110. /package/templates/src/{login/web → app/web/login}/auth-provider.ts +0 -0
  111. /package/templates/src/{login/web → app/web/login}/locales/en-US.json +0 -0
  112. /package/templates/src/{login/web → app/web/login}/locales/zh-CN.json +0 -0
  113. /package/templates/src/{login/web → app/web/login}/pages/change-password.tsx +0 -0
  114. /package/templates/src/{login/web → app/web/login}/wecom.ts +0 -0
  115. /package/templates/src/{notification/web → app/web/notification}/locales/en-US.json +0 -0
  116. /package/templates/src/{notification/web → app/web/notification}/locales/zh-CN.json +0 -0
  117. /package/templates/src/{notification/web → app/web/notification}/pages/center/index.tsx +0 -0
  118. /package/templates/src/{personal-center/web → app/web/personal-center}/__client/api.schemas.ts +0 -0
  119. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/en-US.json +0 -0
  120. /package/templates/src/{personal-center/web → app/web/personal-center}/locales/zh-CN.json +0 -0
  121. /package/templates/src/{personal-center/web → app/web/personal-center}/pages/my-profile/index.tsx +0 -0
  122. /package/templates/src/{system/web → app/web/system}/pages/departments/index.tsx +0 -0
  123. /package/templates/src/{system/web → app/web/system}/pages/departments/sync-bar.tsx +0 -0
  124. /package/templates/src/{system/web → app/web/system}/pages/departments/tree.ts +0 -0
  125. /package/templates/src/{system/web → app/web/system}/pages/errors.ts +0 -0
  126. /package/templates/src/{system/web → app/web/system}/pages/forbidden.tsx +0 -0
  127. /package/templates/src/{system/web → app/web/system}/pages/login-methods/index.tsx +0 -0
  128. /package/templates/src/{system/web → app/web/system}/pages/oplog/index.tsx +0 -0
  129. /package/templates/src/{system/web → app/web/system}/pages/roles/index.tsx +0 -0
  130. /package/templates/src/{system/web → app/web/system}/pages/settings/index.tsx +0 -0
@@ -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
+ });
@@ -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, webSrcOf } from "../layout";
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 clientDirOf = (module: string) =>
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
- const binding = target === "module"
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
- function factoryReturnObjects(sf: ts.SourceFile, call: ts.CallExpression): ts.ObjectLiteralExpression[] {
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) && st.expression !== undefined && ts.isObjectLiteralExpression(st.expression))
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
  }
@@ -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, webSrcOf } from "../layout";
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 = join(layout ? webSrcOf(layout, module) : join(cwd, "web/src", module), "__client");
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 { paths?: Record<string, unknown> };
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
  }
@@ -20,6 +20,18 @@ export interface SharedDepEntry {
20
20
  asset?: string
21
21
  /** 硬共享:模块不得自带 */
22
22
  hard: boolean
23
+ /**
24
+ * 软共享但「不进宿主首屏」:宿主 shell 不预构建该资产、importmap 不列该键,
25
+ * 模块若 import 它则自行打包进模块产物(设计文档 §4.3 的「软共享缺席→模块自带
26
+ * 副本兜底」语义)。用于**仅模块使用、宿主与共享 chunk 永不 import**的重型依赖
27
+ * (编辑器 / 图表 / Markdown / 拖拽 …),避免把它们 5~6MB 的体量无谓铺进
28
+ * shell-dist 并要求公开链路多传一堆文件。宿主侧有真实 import 的共享依赖
29
+ * (antd / pro-components / icons 内部依赖的 motion / react-jss / dayjs /
30
+ * zustand 等)绝不能标 bundle —— 否则会被 antd 等共享 chunk 经 importmap 解析
31
+ * 不到而 404。判定依据:宿主 host.tsx + runtime.js 的传递 import 图,以及
32
+ * antd/pro-components/icons 的传递依赖图,都不含这些说明符。
33
+ */
34
+ bundle?: boolean
23
35
  }
24
36
 
25
37
  export const SHARED_DEPS: SharedDepEntry[] = [
@@ -58,7 +70,7 @@ export const SHARED_DEPS: SharedDepEntry[] = [
58
70
  * scopes 多版本共存兜底(C7)」——缺席时模块可自带副本而不硬失败。
59
71
  * 代价:多副本会重复约 5.4 MB 资产,故 scopes 只作兜底、默认仍走宿主。
60
72
  */
61
- { specifier: "@plane/editor", asset: "editor", hard: false },
73
+ { specifier: "@plane/editor", asset: "editor", hard: false, bundle: true },
62
74
  // 注:@rc-component/form(antd 6 Form 底层)曾尝试单例化以修 my-profile
63
75
  // 崩溃(React #130),假说未证实——多副本并非充分根因,已回退;见
64
76
  // docs/prd/202609010056-playground-full-modules-plan.md 差异项 D2。
@@ -108,10 +120,10 @@ export const SHARED_DEPS: SharedDepEntry[] = [
108
120
  { specifier: "dayjs/plugin/weekday", asset: "dayjs-plugin-weekday", hard: false },
109
121
  { specifier: "dayjs/plugin/weekOfYear", asset: "dayjs-plugin-weekOfYear", hard: false },
110
122
  { specifier: "dayjs/plugin/weekYear", asset: "dayjs-plugin-weekYear", hard: false },
111
- { specifier: "echarts", asset: "echarts", hard: false },
112
- { specifier: "echarts/charts", asset: "echarts-charts", hard: false },
113
- { specifier: "echarts/features.js", asset: "echarts-features", hard: false },
114
- { specifier: "echarts-for-react", asset: "echarts-for-react", hard: false },
123
+ { specifier: "echarts", asset: "echarts", hard: false, bundle: true },
124
+ { specifier: "echarts/charts", asset: "echarts-charts", hard: false, bundle: true },
125
+ { specifier: "echarts/features.js", asset: "echarts-features", hard: false, bundle: true },
126
+ { specifier: "echarts-for-react", asset: "echarts-for-react", hard: false, bundle: true },
115
127
  { specifier: "motion", asset: "motion", hard: false },
116
128
  { specifier: "motion/react", asset: "motion-react", hard: false },
117
129
  { specifier: "@dnd-kit/core", asset: "dnd-kit-core", hard: false },
@@ -127,6 +139,8 @@ export const SHARED_DEPS: SharedDepEntry[] = [
127
139
  { specifier: "react-error-boundary", asset: "react-error-boundary", hard: false },
128
140
  { specifier: "react-jss", asset: "react-jss", hard: false },
129
141
  { specifier: "clsx", asset: "clsx", hard: false },
142
+ // react-countup 只有 CJS 入口(main: build),其 require("react") 在模块自打包时会退化成
143
+ // 浏览器不可用的 require 桩 → 必须走宿主共享资产(shell 侧有 CJS→ESM 垫片)
130
144
  { specifier: "react-countup", asset: "react-countup", hard: false },
131
145
  { specifier: "simplebar-react", asset: "simplebar-react", hard: false },
132
146
  { specifier: "spin-delay", asset: "spin-delay", hard: false },
@@ -147,8 +161,8 @@ export const SHARED_DEPS: SharedDepEntry[] = [
147
161
  * 直接 import 它们,会命中前缀 external 但无 importmap 键 → 构建期深路径报错,
148
162
  * 属预期拦截(与 dayjs/plugin/* 同规则)。
149
163
  */
150
- { specifier: "vditor", asset: "vditor", hard: false },
151
- { specifier: "vditor/dist/index.css", asset: "vditor-index-css", hard: false },
164
+ { specifier: "vditor", asset: "vditor", hard: false, bundle: true },
165
+ { specifier: "vditor/dist/index.css", asset: "vditor-index-css", hard: false, bundle: true },
152
166
  ];
153
167
 
154
168
  /** 判断一个裸说明符是否命中共享表(命中主包前缀的深路径也按共享处理) */
@@ -161,7 +175,7 @@ export function isSharedDep(id: string): boolean {
161
175
  */
162
176
  export function generateShellEntries(): { name: string, pkg: string }[] {
163
177
  return SHARED_DEPS
164
- .filter(dep => dep.asset && dep.specifier !== "@oj-module/runtime")
178
+ .filter(dep => dep.asset && !dep.bundle && dep.specifier !== "@oj-module/runtime")
165
179
  .map(dep => ({ name: dep.asset!, pkg: dep.specifier }));
166
180
  }
167
181
 
@@ -172,9 +186,13 @@ export function generateShellEntries(): { name: string, pkg: string }[] {
172
186
  export function generateImportmap(baseUrl = ""): Record<string, string> {
173
187
  const map: Record<string, string> = {};
174
188
  for (const dep of SHARED_DEPS) {
175
- if (dep.asset) {
189
+ if (dep.asset && !dep.bundle) {
176
190
  map[dep.specifier] = `${baseUrl}/assets/${dep.asset}.js`;
177
191
  }
178
192
  }
179
193
  return map;
180
194
  }
195
+
196
+ export function isPrebuiltDep(id: string): boolean {
197
+ return SHARED_DEPS.some(dep => !dep.bundle && (id === dep.specifier || id.startsWith(`${dep.specifier}/`)));
198
+ }
package/src/site-base.ts CHANGED
@@ -100,7 +100,7 @@ export function rewriteIndexHtml(html: string, siteBase: string | undefined, api
100
100
  out = rewriteImportmap(out, base);
101
101
  // 属性值:只要以单个 "/" 开头(排除 "//host")。importmap 是脚本正文、非属性,
102
102
  // 上一步已处理,二者不重叠。
103
- out = out.replace(/(\b(?:href|src)=")(\/[^/"][^"]*|\/")/g, (_m, attr: string, url: string) =>
103
+ out = out.replace(/(\b(?:href|src|data-ojm-entry)=")(\/[^/"][^"]*|\/")/g, (_m, attr: string, url: string) =>
104
104
  `${attr}${withSiteBase(url, base)}`);
105
105
  globals.push(`window.__OJ_BASE__=${JSON.stringify(base)};`);
106
106
  }
@@ -6,82 +6,78 @@
6
6
 
7
7
  ```bash
8
8
  pnpm install
9
- pnpm dev # 开发:前端 devServer + oj 后端(api/src 保存即热更)
9
+ pnpm dev # 开发:前端 devServer + oj 后端(src/*/api 保存即热更)
10
10
  pnpm build # 构建:oj build + 前端全站合并到 dist/web
11
11
  pnpm preview # 预览:oj migrate + server(release 产物)
12
12
  ```
13
13
 
14
- 登录账号:`admin` / `aA123456`(见 `api/src/_platform/seed.sql`)。
14
+ 登录账号:`admin` / `aA123456`(见 `src/_platform/api/seed.sql`)。
15
15
 
16
- ## 系统管理模块
16
+ ## 目录(一目录 = 一个模块)
17
17
 
18
- `web/src/system/`(用户管理 / 成员资格 / 角色权限 / 操作日志 / 站点设置 / 登录方式)
19
- 的前端 API 客户端 `__client/` **已随模板分发**(与 auth/notification 等一致),`pnpm dev`
20
- 开箱即用、「系统管理」菜单直接可见。
21
-
22
- `__client` 是从 `openapi.json`(模板自带,由 `api/src/system` 契约产出)生成的产物;
23
- 契约变更后刷新一次即可:
24
-
25
- ```bash
26
- bin/oj openapi -c config.yaml -d api/src -b /api -o openapi.json # 重产契约
27
- pnpm exec ojm api # 重新生成 web/src/*/__client(dev 检测到 openapi.json 变化也会自动重生成)
18
+ ```
19
+ config.yaml 站点/数据库/租户配置(init 现场签发证书到 config/)
20
+ src/<name>/api/ 后端模块(目录镜像路由:src/<name>/api/<路径>/api.ts)
21
+ src/<name>/web/ 前端模块(entry.ts 是入口;页面 / locales / __client 同目录)
22
+ src/app/web/ 框架内置页面聚合模块(多根:/home /login /personal-center /notification /system)
23
+ src/oj-market/web/ 解决方案市场(第一方能力,独立模块,删目录即下线)
24
+ dist/api/ oj build 后端产物
25
+ dist/web/ 站点产物(shell + modules + modules.json)
26
+ bin/ oj 二进制(init 解压,不入库)
28
27
  ```
29
28
 
30
- 成员资格页仅 superadmin 可见。改了 `api/src` 的 handler `.schema`/`.response` 后重跑这两条保持同步
31
- (`ojm api --check` 可对账,零漂移为发布门禁)。
29
+ `src/app/web` 是一个**多根模块**:一个 `entry.ts` 里挂五个顶层路由,各根按
30
+ `handle.order` 排菜单(与拆分时逐项一致),页面按根分目录
31
+ (`<root>/routes.ts` + `pages/` + `__client/`)。新增业务页面时建议单独建模块
32
+ (`pnpm exec ojm new <name>`),不要往聚合模块里塞。
32
33
 
33
- ## 目录
34
+ ## 契约与 `__client`
34
35
 
35
- ```
36
- bin/ oj 二进制(init 解压,不入库)
37
- api/src/ 后端模块源码(目录镜像路由:api/src/<模块>/<路径>/api.ts)
38
- dist/api/ oj build 产物
39
- web/src/ 前端模块源码
40
- dist/web/ 构建产物(完整站点)
36
+ 前端调后端一律走 `ojm api` 从 `openapi.json` 生成的 `__client/`(随模板分发,开箱即用)。
37
+ 改了 handler 的 `.schema`(入参)/ `.response`(出参)后刷新:
38
+
39
+ ```bash
40
+ bin/oj openapi -c config.yaml --module-root src -b /api -o openapi.json # 重产契约
41
+ pnpm exec ojm api # 重生成 */web/*/__client
41
42
  ```
42
43
 
44
+ 多根聚合模块的 client 归属由 `web.config.ts` 的 `clientOwners` 决定(`system → app`),
45
+ 生成物落 `src/app/web/system/__client`,与页面同级——页面相对导入 `../../__client` 不用改。
46
+ `ojm api --check` 可对账漂移(零漂移为发布门禁)。
47
+
43
48
  ## 注意
44
49
 
45
- - 改 `api/src` 下的 api.ts 保存即生效;**新增/删除后端模块目录、改
50
+ - 改 `src/*/api` 下的 api.ts 保存即生效;**新增/删除模块目录、改
46
51
  schema/migrations/config.yaml 需重启 `ojm dev`**(oj 热更边界)。
47
- - 后端开发请用 `/oj-api-dev` skill(`.claude/skills/oj-api-dev/`),
48
- 手册按章节按需读。
49
- - `api/config.yaml` 的证书为 init 现场签发的本地 dev 证书,不构成信任边界;
52
+ - 后端开发请用 `/oj-api-dev` skill(`.claude/skills/oj-api-dev/`),手册按章节按需读。
53
+ - `config/` 下的证书为 init 现场签发的本地 dev 证书,不构成信任边界;
50
54
  生产环境用 oj 仓库 `oj-cert gen` 全新签发并替换公钥。
51
55
 
52
56
  ## 排障 / 常见问题
53
57
 
54
- - **`pnpm dev` / `pnpm build` 报 `Could not resolve "./__client/api"`**
55
- 某前端模块的 `entry.ts` 引用了 `web/src/<模块>/__client/api`,但该目录不存在——它是
56
- `ojm api` 从 `openapi.json` 生成的产物。在工程根跑一次即可补齐:
58
+ - **`pnpm dev` / `pnpm build` 报 `Could not resolve "../../__client/api"`**
59
+ 对应根的 `__client` 缺失——它是 `ojm api` 的生成物。在工程根跑一次即可补齐:
57
60
 
58
61
  ```bash
59
- pnpm exec ojm api # 生成 web/src/*/__client(dev 检测到 openapi.json 变化也会自动重生成)
62
+ pnpm exec ojm api # dev 检测到 openapi.json 变化也会自动重生成
60
63
  ```
61
64
 
62
- 改完后端 `api/src` 的契约后,重产契约再刷新:
65
+ 改完后端契约后先重产契约再刷新:
63
66
 
64
67
  ```bash
65
- bin/oj openapi -c config.yaml -d api/src -b /api -o openapi.json
68
+ bin/oj openapi -c config.yaml --module-root src -b /api -o openapi.json
66
69
  pnpm exec ojm api
67
70
  ```
68
71
 
69
72
  - **`__client/` 下的文件能直接改吗?**
70
- 不能手改、也不入库——它是生成物,下次 `ojm api` 会覆盖你的手改。需要改接口就改
71
- `api/src` 的 handler `.schema`/`.response`,再跑上面的命令刷新。
72
- `ojm api --check` 可对账契约与产物的漂移(零漂移为发布门禁)。
73
+ 不能手改、也不入库——它是生成物,下次 `ojm api` 会覆盖。改接口就改 handler 的
74
+ `.schema`/`.response`,再跑上面的命令刷新。
73
75
 
74
76
  - **`pnpm dlx @oj-module/cli@latest init` 生成的脚手架缺模块 / 看起来很旧**
75
- pnpm dlx 会按首次解析到的版本**缓存** CLI 可执行文件。若某次 `@latest` 指向的是旧版
76
- (例如早于系统管理模块的 `0.1.x`),之后重跑仍可能命中旧缓存,生成的脚手架就会缺新模块、
77
- 模板偏旧(典型表现:`web/src` 下没有 `system` 目录)。
78
- 排查:`ls web/src` 看是否缺预期模块。
79
- 解决:清掉 dlx 缓存后重跑——
77
+ pnpm dlx 会按首次解析到的版本**缓存** CLI 可执行文件。排查:`ls src` 看是否缺预期模块。
78
+ 解决:清掉 dlx 缓存后显式指定版本重跑——
80
79
 
81
80
  ```bash
82
81
  rm -rf ~/Library/Caches/pnpm/dlx # macOS;其它平台见 pnpm 文档的 dlx-store 路径
83
- # 或:pnpm dlx clear @oj-module/cli
84
- pnpm dlx @oj-module/cli@<明确版本> init <工程名> --yes # 显式指定版本最稳
82
+ pnpm dlx @oj-module/cli@<明确版本> init <工程名> --yes
85
83
  ```
86
-
87
- 养成显式带版本号(如 `@oj-module/cli@0.2.4`)的习惯,可避免 `@latest` 缓存歧义。
@@ -20,9 +20,9 @@
20
20
  # `pnpm dev` 时忽略产物,不进发布包、也不进用户工程。守卫:
21
21
  # tests/cli/release-manifest.test.ts 逐条核对清单条目落在 tarball 内。
22
22
  #
23
- # 注意 web/src/{demo,home}/__client 不在清单:其 api 模块(api/src/demo、
24
- # api/src/home)是 demo-only 不进脚手架,client 生成物留着会指向不存在的端点,
25
- # 故 demo/home 只列页面源码(entry/locales/pages),不列整棵模块目录。
23
+ # 注意 web/src/{home}/__client 不在清单:其 api 模块(api/src/home)是 demo-only
24
+ # 不进脚手架,client 生成物留着会指向不存在的端点,故 home 只列页面源码
25
+ # (entry/locales/pages),不列整棵模块目录。
26
26
  include:
27
27
  # —— 工程级文件 ——
28
28
  - README.md
@@ -101,17 +101,20 @@ include:
101
101
  - src/personal-center/api
102
102
  - src/system/api
103
103
  - src/web/api
104
- # —— web 前端模块(demo/home 只列页面源码,__client 见顶部说明)——
104
+ # —— web 前端模块 ——
105
105
  - src/_platform/web
106
106
  - src/auth/web
107
- - src/demo/web/entry.ts
108
- - src/demo/web/locales
109
- - src/demo/web/pages
110
- - src/home/web/entry.ts
111
- - src/home/web/locales
112
- - src/home/web/pages
113
- - src/login/web
114
- - src/notification/web
107
+ # 框架内置页面聚合为一个多根模块(首页/登录/个人中心/通知/系统管理)。
108
+ # home 只列 routes/locales/pages:其 api 模块是 demo-only 不进脚手架,__client
109
+ # 留着会指向不存在的端点(见顶部说明),故逐子路径收录而不是整棵 src/app/web。
110
+ - src/app/web/entry.ts
111
+ - src/app/web/home/routes.ts
112
+ - src/app/web/home/locales
113
+ - src/app/web/home/pages
114
+ - src/app/web/login
115
+ - src/app/web/notification
116
+ - src/app/web/personal-center
117
+ - src/app/web/system
115
118
  # 解决方案市场:默认第一方能力。逐子路径收录——__tests__ 不进脚手架
116
119
  # (用户工程无 web 侧 vitest 基建,拷了是死文件)
117
120
  - src/oj-market/web/entry.ts
@@ -122,6 +125,4 @@ include:
122
125
  - src/oj-market/web/locales
123
126
  - src/oj-market/web/pages
124
127
  - src/oj-market/web/utils
125
- - src/personal-center/web
126
- - src/system/web
127
128
  - src/web/web