@oj-module/cli 0.1.15 → 0.1.17

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.
@@ -5,9 +5,9 @@
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-BbpdKqCvQY8/k+rypZmAnw=='; 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" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-cEEui3MttOAqCTHlBg5Xog=='; 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
9
 
10
- <script type="importmap" nonce="BbpdKqCvQY8/k+rypZmAnw==">{
10
+ <script type="importmap" nonce="cEEui3MttOAqCTHlBg5Xog==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -73,6 +73,8 @@
73
73
  "simplebar-react": "/assets/simplebar-react.js",
74
74
  "spin-delay": "/assets/spin-delay.js",
75
75
  "tailwind-merge": "/assets/tailwind-merge.js",
76
+ "vditor": "/assets/vditor.js",
77
+ "vditor/dist/index.css": "/assets/vditor-index-css.js",
76
78
  "antd/es/modal": "/assets/ojm-antd-es-modal.js",
77
79
  "antd/es/upload": "/assets/ojm-antd-es-upload.js",
78
80
  "antd/es/button": "/assets/ojm-antd-es-button.js",
@@ -7,11 +7,11 @@
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.1.15",
11
- "@oj-module/runtime/contract": "0.1.15",
12
- "@oj-module/runtime/contract/errors": "0.1.15",
13
- "@plane/kit": "0.1.15",
14
- "@plane/editor": "0.1.15",
10
+ "@oj-module/runtime": "0.1.17",
11
+ "@oj-module/runtime/contract": "0.1.17",
12
+ "@oj-module/runtime/contract/errors": "0.1.17",
13
+ "@plane/kit": "0.1.17",
14
+ "@plane/editor": "0.1.17",
15
15
  "antd": "6.6.1",
16
16
  "antd/locale/en_US": "6.6.1",
17
17
  "antd/locale/zh_CN": "6.6.1",
@@ -63,6 +63,8 @@
63
63
  "simplebar-react": "3.3.2",
64
64
  "spin-delay": "2.0.1",
65
65
  "tailwind-merge": "3.6.0",
66
+ "vditor": "4.0.0",
67
+ "vditor/dist/index.css": "4.0.0",
66
68
  "typescript": "6.0.3",
67
69
  "@types/react": "19.2.18"
68
70
  }
@@ -104,7 +104,13 @@ function matchExemptPath(routePath: string, p: string): boolean {
104
104
  return routePath === p || routePath.startsWith(`${p}/`);
105
105
  }
106
106
 
107
- /** oj 方法名 → HTTP 动词(emit-stub 的反向映射) */
107
+ /**
108
+ * oj 方法名 → HTTP 动词(emit-stub 的反向映射)。
109
+ * 注意**不含 options**:契约运行时故意不表达 OPTIONS(预检请求由 fetch/CORS 层处理,
110
+ * 见 runtime/contract 的方法白名单)——oj 树里的 options handler 因此永不进对账表
111
+ * (不会被误报「未登记」);routes.js 的 options 行经下方 parseRoutesJs 的
112
+ * 大写回落归一,同样不误报 drift。
113
+ */
108
114
  const VERB_OF: Record<string, IrEndpoint["method"]> = {
109
115
  get: "GET",
110
116
  post: "POST",
@@ -114,6 +120,9 @@ const VERB_OF: Record<string, IrEndpoint["method"]> = {
114
120
  head: "HEAD",
115
121
  };
116
122
 
123
+ /** oj v0.1.64 嵌套上限(与 oj 内省一致);超出即 oj 启动报错,checker 不深追 */
124
+ const MAX_GROUP_DEPTH = 4;
125
+
117
126
  interface HandlerRow {
118
127
  /** HTTP 动词 */
119
128
  method: IrEndpoint["method"]
@@ -121,15 +130,30 @@ interface HandlerRow {
121
130
  dir: string
122
131
  /** 语句起始 `.route = "..."` 字面量(无则目录镜像直达) */
123
132
  tail?: string
133
+ /** v0.1.64:点分 handler 链("cccc.get";顶层动词即方法名) */
134
+ chain: string
135
+ /** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导 /;绝对 .route 即其自身) */
136
+ full: string
124
137
  filePath: string
125
138
  }
126
139
 
127
- /** AST 扫单个 api.ts:default 导出对象的方法名 + 顶层 .route 字面量赋值 */
128
- export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow[] {
129
- const text = readFileSync(filePath, "utf8");
130
- const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
140
+ /** `a` / `a.b` 形式的属性访问 → 点分名;其余(调用/元素访问)→ undefined */
141
+ function dottedNameOf(node: ts.Expression): string | undefined {
142
+ if (ts.isIdentifier(node))
143
+ return node.text;
144
+ if (ts.isPropertyAccessExpression(node)) {
145
+ const base = dottedNameOf(node.expression);
146
+ return base ? `${base}.${node.name.text}` : undefined;
147
+ }
148
+ return undefined;
149
+ }
131
150
 
132
- // 顶层 <fn>.route = "字面量"(语句起始标准赋值——oj build 同款识别口径)
151
+ /**
152
+ * 语句起始的 `<路径>.route = "字面量"`(路径可为点分:`cccc.post.route`,运行时即
153
+ * `cccc.post` 函数对象的 `.route`——与 oj 内省读 `fn.route` 同源)。空串视同未挂
154
+ * (oj 口径)。oj build 只认顶层标准赋值,这里同口径且放宽到点分左值。
155
+ */
156
+ function collectRouteDecls(sf: ts.SourceFile): Map<string, string> {
133
157
  const routes = new Map<string, string>();
134
158
  for (const stmt of sf.statements) {
135
159
  if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression))
@@ -139,38 +163,150 @@ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow
139
163
  continue;
140
164
  if (!ts.isPropertyAccessExpression(expr.left) || expr.left.name.text !== "route")
141
165
  continue;
142
- if (!ts.isIdentifier(expr.left.expression) || !ts.isStringLiteral(expr.right))
166
+ if (!ts.isStringLiteral(expr.right) || expr.right.text === "")
143
167
  continue;
144
- routes.set(expr.left.expression.text, expr.right.text);
168
+ const path = dottedNameOf(expr.left.expression);
169
+ if (path)
170
+ routes.set(path, expr.right.text);
145
171
  }
172
+ return routes;
173
+ }
146
174
 
147
- // export default { get, post: detail, put() {} } → 方法名 → 实现函数名
148
- const methods = new Map<string, string>();
175
+ /** 顶层 `const x = { ... }` 对象字面量表:嵌套组经标识符引用时的解析依据 */
176
+ function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpression> {
177
+ const decls = new Map<string, ts.ObjectLiteralExpression>();
149
178
  for (const stmt of sf.statements) {
150
- if (!ts.isExportAssignment(stmt) || stmt.isExportEquals || !ts.isObjectLiteralExpression(stmt.expression))
179
+ if (!ts.isVariableStatement(stmt))
151
180
  continue;
152
- for (const prop of stmt.expression.properties) {
153
- if (ts.isShorthandPropertyAssignment(prop)) {
154
- methods.set(prop.name.text, prop.name.text);
155
- }
156
- else if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
157
- methods.set(prop.name.text, ts.isIdentifier(prop.initializer) ? prop.initializer.text : prop.name.text);
158
- }
159
- else if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name)) {
160
- methods.set(prop.name.text, prop.name.text);
161
- }
181
+ for (const decl of stmt.declarationList.declarations) {
182
+ if (ts.isIdentifier(decl.name) && decl.initializer && ts.isObjectLiteralExpression(decl.initializer))
183
+ decls.set(decl.name.text, decl.initializer);
162
184
  }
163
185
  }
186
+ return decls;
187
+ }
188
+
189
+ /** verb 键 → 实现函数名(供 `.route` 语句按函数名查找;内联写法回落键名) */
190
+ function implNameOf(prop: ts.ObjectLiteralElementLike): string | undefined {
191
+ if (ts.isShorthandPropertyAssignment(prop))
192
+ return prop.name.text;
193
+ if (ts.isMethodDeclaration(prop) && prop.name)
194
+ return prop.name.getText();
195
+ if (ts.isPropertyAssignment(prop)) {
196
+ if (ts.isIdentifier(prop.name) && ts.isIdentifier(prop.initializer))
197
+ return prop.initializer.text;
198
+ if (prop.name)
199
+ return prop.name.getText();
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ /** 非 verb 键 → 子组对象字面量(内联或经标识符解析);函数/数组/基元 → undefined */
205
+ function groupValueOf(prop: ts.ObjectLiteralElementLike, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
206
+ if (ts.isPropertyAssignment(prop)) {
207
+ if (ts.isObjectLiteralExpression(prop.initializer))
208
+ return prop.initializer;
209
+ if (ts.isIdentifier(prop.initializer))
210
+ return decls.get(prop.initializer.text);
211
+ return undefined;
212
+ }
213
+ if (ts.isShorthandPropertyAssignment(prop))
214
+ return decls.get(prop.name.text);
215
+ return undefined;
216
+ }
217
+
218
+ /** default 导出对象:内联字面量或标识符(`export default api`) */
219
+ function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
220
+ for (const stmt of sf.statements) {
221
+ if (!ts.isExportAssignment(stmt) || stmt.isExportEquals)
222
+ continue;
223
+ if (ts.isObjectLiteralExpression(stmt.expression))
224
+ return stmt.expression;
225
+ if (ts.isIdentifier(stmt.expression))
226
+ return decls.get(stmt.expression.text);
227
+ }
228
+ return undefined;
229
+ }
164
230
 
165
- // 目录镜像:api/src/order/item/api.ts → order/item
231
+ /** 相对路由按层前缀解析(绝对 `/` 开头即根级);与 oj 内省 resolve 同规则 */
232
+ function resolveRoute(own: string, prefix: string): string {
233
+ return own.startsWith("/") ? own : (prefix ? `${prefix}/${own}` : own);
234
+ }
235
+
236
+ /** 有效路由全路径(base 相对、无前导 /):绝对 .route 取其自身,否则目录镜像 + 相对尾 */
237
+ function fullPathOf(dir: string, tail: string | undefined): string {
238
+ if (tail?.startsWith("/"))
239
+ return tail.replace(/^\//, "");
240
+ return [dir, tail].filter(Boolean).join("/");
241
+ }
242
+
243
+ /**
244
+ * AST 扫单个 api.ts(v0.1.64 起支持嵌套 handler 组):
245
+ * default 导出对象的键——verb 键(函数)即端点;非 verb 键(普通对象)即子组,
246
+ * 递归展开(上限 4 层,路径环守卫)。组级 `.route` 作整组默认前缀,动词自身
247
+ * `.route` 优先覆盖;文件层(`const api = {...}; api.route = ...`)同为默认前缀。
248
+ * 非 verb 键挂函数/数组/基元是 oj 判别红线(启动即报错),此处按「不当路由」跳过。
249
+ */
250
+ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow[] {
251
+ const text = readFileSync(filePath, "utf8");
252
+ const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
253
+ const routes = collectRouteDecls(sf);
254
+ const decls = collectObjectDecls(sf);
166
255
  const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
167
256
  const rows: HandlerRow[] = [];
168
- for (const [name, fn] of methods) {
169
- const method = VERB_OF[name];
170
- if (!method)
171
- continue; // 非动词属性(helper 挂载等)不当路由
172
- const tail = routes.get(fn);
173
- rows.push({ method, dir: relDir, tail: tail === "" ? undefined : tail, filePath });
257
+ const seen = new Set<ts.Node>();
258
+
259
+ const walk = (obj: ts.ObjectLiteralExpression, chainPrefix: string, routePrefix: string, depth: number): void => {
260
+ if (depth > MAX_GROUP_DEPTH || seen.has(obj))
261
+ return;
262
+ seen.add(obj);
263
+ for (const prop of obj.properties) {
264
+ const name = (prop.name && ts.isIdentifier(prop.name)) ? prop.name.text : undefined;
265
+ if (!name)
266
+ continue;
267
+ const chain = chainPrefix ? `${chainPrefix}.${name}` : name;
268
+ if (VERB_OF[name]) {
269
+ const fnName = implNameOf(prop) ?? name;
270
+ const own = routes.get(chain) ?? routes.get(fnName);
271
+ // 无自身 .route → 继承本层前缀(根层即目录镜像);与 oj 内省同规则
272
+ const tail = own === undefined
273
+ ? (routePrefix || undefined)
274
+ : resolveRoute(own, routePrefix);
275
+ rows.push({
276
+ method: VERB_OF[name],
277
+ dir: relDir,
278
+ tail,
279
+ chain,
280
+ full: fullPathOf(relDir, tail),
281
+ filePath,
282
+ });
283
+ }
284
+ else {
285
+ const child = groupValueOf(prop, decls);
286
+ if (!child)
287
+ continue; // 函数/数组/基元:oj 红线,checker 不当路由
288
+ const own = routes.get(chain);
289
+ const childPrefix = own === undefined
290
+ ? (routePrefix ? `${routePrefix}/${name}` : name)
291
+ : resolveRoute(own, routePrefix);
292
+ walk(child, chain, childPrefix, depth + 1);
293
+ }
294
+ }
295
+ seen.delete(obj);
296
+ };
297
+
298
+ const root = defaultExportObject(sf, decls);
299
+ if (root) {
300
+ // 文件层 .route:default 导出为标识符时,该标识符的 .route 语句即整文件默认前缀
301
+ let filePrefix = "";
302
+ for (const stmt of sf.statements) {
303
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
304
+ const own = routes.get(stmt.expression.text);
305
+ if (own !== undefined)
306
+ filePrefix = resolveRoute(own, "");
307
+ }
308
+ }
309
+ walk(root, "", filePrefix, 1);
174
310
  }
175
311
  return rows;
176
312
  }
@@ -230,74 +366,119 @@ function checkWsAnonymous(wsFiles: string[], apiSrcDir: string, configPath: stri
230
366
  }));
231
367
  }
232
368
 
233
- function routeLabel(dir: string, tail?: string): string {
234
- return tail ? `${dir}/${tail}` : dir;
369
+ /** 对账键(方法 + 全路径) */
370
+ const pathKey = (method: string, path: string) => `${method} ${path}`;
371
+
372
+ /** 路径段(去空串,忽略前导 /) */
373
+ const pathSegs = (p: string) => p.split("/").filter(Boolean);
374
+ /** 静态前缀:首个参数段({...})之前的路径段 */
375
+ function staticPrefixOf(p: string): string {
376
+ const segs = pathSegs(p);
377
+ const i = segs.findIndex(s => s.startsWith("{"));
378
+ return segs.slice(0, i === -1 ? segs.length : i).join("/");
235
379
  }
236
380
 
237
- /** ② route 双向对账:契约路由表 vs AST 扫描的 handler 表(D12/F19:豁免降级) */
381
+ /**
382
+ * handler 的有效 wire 全路径(带前导 /):绝对 `.route` 即其自身;相对尾巴 = `/` + full。
383
+ * 用于与契约 contractAbs(wirePrefix + route)对齐比对。
384
+ */
385
+ function handlerAbs(h: HandlerRow): string {
386
+ return h.tail?.startsWith("/") ? h.tail : `/${h.full}`;
387
+ }
388
+
389
+ /**
390
+ * ② route 双向对账:契约路由表 vs AST 扫描的 handler 表(D12/F19:豁免降级)。
391
+ *
392
+ * 定位分两层,先精确后模糊:
393
+ * ① **精确全路径匹配**(方法 + ojFull,或方法 + wire 全路径 contractAbs)——覆盖
394
+ * v0.1.64 嵌套组(组名 = 虚拟路径段,全路径唯一)与绝对 `.route` 精确命中(D-M8
395
+ * urlPrefix 映射)。
396
+ * ② **同位置参数段比对**(模糊兜底)——未精确命中时,在全 handler 里找「同方法、
397
+ * 静态前缀相同、段数相同、但参数名不同」的唯一一条:即「同一端点、参数段写错」,
398
+ * 报 route-params-mismatch(旧行为)。静态前缀/段数不同而非同位置 → 视为不同端点,
399
+ * 报未实现 + 未登记(避免误判 mismatch)。嵌套组(虚拟段)与绝对形态都经此口径。
400
+ */
238
401
  function reconcileRoutes(contractIr: IrEndpoint[], handlers: HandlerRow[], exemption: ApiExemption): CheckViolation[] {
239
402
  const violations: CheckViolation[] = [];
240
- const key = (method: string, dir: string) => `${method} ${dir}`;
241
403
 
242
- const contractMap = new Map<string, IrEndpoint & { dir: string, tail?: string }>();
404
+ // ① 精确索引:handler 全路径(绝对 .route 另按 wire 全路径索引)。
405
+ const byPath = new Map<string, HandlerRow>();
406
+ for (const h of handlers) {
407
+ byPath.set(pathKey(h.method, h.full), h);
408
+ if (h.tail?.startsWith("/"))
409
+ byPath.set(pathKey(h.method, h.tail), h);
410
+ }
411
+
412
+ const claimed = new Set<HandlerRow>();
243
413
  for (const ep of contractIr) {
244
- const { dir, tail } = splitRoute(ep.route);
414
+ const { dir } = splitRoute(ep.route);
245
415
  const dirFull = `${ep.apiPrefix.replace(/^\//, "")}${dir ? `/${dir}` : ""}`;
246
- contractMap.set(key(ep.method, dirFull), { ...ep, dir: dirFull, tail });
247
- }
248
- const handlerMap = new Map<string, HandlerRow>();
249
- for (const h of handlers)
250
- handlerMap.set(key(h.method, h.dir), h);
416
+ const ojFull = `${ep.apiPrefix.replace(/^\//, "")}${ep.route}`.replace(/\/+/g, "/");
417
+ const contractAbs = `${wirePrefixOf(ep)}${ep.route}`;
251
418
 
252
- for (const [k, ep] of contractMap) {
253
- const handler = handlerMap.get(k);
254
- if (!handler) {
255
- violations.push({
256
- level: "warn",
257
- kind: "route-not-implemented",
258
- message: `[ojm-api] 契约端点 "${ep.name}"(${ep.method} ${routeLabel(ep.dir, ep.tail)})未实现——oj 树缺少 handler;重跑 ojm api 生成 stub 或手动实现。`,
259
- });
419
+ // ① 精确全路径:嵌套组 / 绝对精确命中
420
+ const exact = byPath.get(pathKey(ep.method, ojFull)) ?? byPath.get(pathKey(ep.method, contractAbs));
421
+ if (exact) {
422
+ claimed.add(exact);
260
423
  continue;
261
424
  }
262
- // D-M8:handler .route 两种形态皆合法——相对尾巴(oj 原生,与契约 route 尾巴比对)
263
- // 与绝对挂载(uni-dev 存量:.route 含命名空间段)。绝对形态与契约 wirePrefix+route
264
- // 全路径直接比对(urlPrefix 映射时即线上前缀)。
265
- const contractAbs = `${wirePrefixOf(ep)}${ep.route}`;
266
- const tailMatch = (handler.tail ?? "").startsWith("/")
267
- ? handler.tail === contractAbs
268
- : (ep.tail ?? "") === (handler.tail ?? "");
269
- if (!tailMatch) {
425
+
426
+ // ② 模糊兜底:同位置(静态前缀 + 段数相同)但参数名不同的唯一 handler → mismatch
427
+ const candidates = handlers.filter(h =>
428
+ !claimed.has(h)
429
+ && h.method === ep.method
430
+ && handlerAbs(h) !== contractAbs
431
+ && staticPrefixOf(handlerAbs(h)) === staticPrefixOf(contractAbs)
432
+ && pathSegs(handlerAbs(h)).length === pathSegs(contractAbs).length,
433
+ );
434
+ if (candidates.length === 1) {
435
+ const h = candidates[0];
436
+ claimed.add(h);
270
437
  violations.push({
271
438
  level: "error",
272
439
  kind: "route-params-mismatch",
273
- message: `[ojm-api] 参数段不一致(${ep.method} ${ep.dir}):契约 route 尾巴 "${ep.tail ?? ""}" vs handler .route "${handler.tail ?? ""}"——契约 route 是唯一手写事实源(AC-D10),请改 handler 的 .route 字面量对齐。`,
274
- filePath: handler.filePath,
275
- });
276
- }
277
- }
278
- for (const [k, h] of handlerMap) {
279
- if (!contractMap.has(k)) {
280
- // D12/F19:整模块豁免,或路径命中豁免前缀 → 视为已覆盖,跳过未登记报错
281
- const mod = moduleOf(h.dir);
282
- if (exemption.modules.includes(mod))
283
- continue;
284
- if (exemption.paths.some(p => matchExemptPath(`/${h.dir}`, p)))
285
- continue;
286
- violations.push({
287
- level: "error",
288
- kind: "route-unregistered",
289
- message: `[ojm-api] handler 未登记(${h.method} ${routeLabel(h.dir, h.tail)})——oj 路由表存在但契约没有对应端点,请补契约或删除该 handler。`,
440
+ message: `[ojm-api] 参数段不一致(${ep.method} ${dirFull}):契约 route "${ep.route}" vs handler .route "${h.tail ?? h.full}"——契约 route 是唯一手写事实源(AC-D10),请改 handler 的 .route 字面量对齐。`,
290
441
  filePath: h.filePath,
291
442
  });
443
+ continue;
292
444
  }
445
+ violations.push({
446
+ level: "warn",
447
+ kind: "route-not-implemented",
448
+ message: `[ojm-api] 契约端点 "${ep.name}"(${ep.method} ${ojFull})未实现——oj 树缺少 handler;重跑 ojm api 生成 stub 或手动实现(嵌套组形态:组名即路径段,见 oj v0.1.64)。`,
449
+ });
450
+ }
451
+ for (const h of handlers) {
452
+ if (claimed.has(h))
453
+ continue;
454
+ // D12/F19:整模块豁免,或路径命中豁免前缀 → 视为已覆盖,跳过未登记报错。
455
+ // 豁免路径同时按文件目录(旧口径)与有效路由全路径(v0.1.64 嵌套口径)判定。
456
+ const mod = moduleOf(h.dir);
457
+ if (exemption.modules.includes(mod))
458
+ continue;
459
+ if (exemption.paths.some(p => matchExemptPath(`/${h.dir}`, p) || matchExemptPath(`/${h.full}`, p)))
460
+ continue;
461
+ const nested = h.chain.includes(".") ? `,handler 链 ${h.chain}` : "";
462
+ violations.push({
463
+ level: "error",
464
+ kind: "route-unregistered",
465
+ message: `[ojm-api] handler 未登记(${h.method} ${h.full}${nested})——oj 路由表存在但契约没有对应端点,请补契约或删除该 handler。`,
466
+ filePath: h.filePath,
467
+ });
293
468
  }
294
469
  return violations;
295
470
  }
296
471
 
297
- /** routes.js(oj build 产物)行解析:`{ method: "get", pattern: "order/list", ... }` */
472
+ /**
473
+ * routes.js(oj build 产物)行解析:`{ method: "get", pattern: "order/list", ... }`。
474
+ * `options` 行直接跳过——契约不表达 OPTIONS(runtime 白名单拒绝),oj 树里合法的
475
+ * options handler 进了 routes.js 也不该判「dist 多」(跨仓同步:oj v0.1.64)。
476
+ */
298
477
  function parseRoutesJs(text: string): { method: string, pattern: string }[] {
299
478
  const rows: { method: string, pattern: string }[] = [];
300
479
  for (const m of text.matchAll(/\{\s*method:\s*"(\w+)",\s*pattern:\s*"([^"]+)"/g)) {
480
+ if (m[1].toLowerCase() === "options")
481
+ continue;
301
482
  // oj 的路由表用短方法名(DELETE → "del");与契约 IR 的 HTTP 名对齐(同 VERB_OF 口径),
302
483
  // 否则 uni-dev 工程里任何 DELETE 端点都会被误报 routes-js-drift。
303
484
  const method = VERB_OF[m[1].toLowerCase()] ?? m[1].toUpperCase() as string;
@@ -42,7 +42,7 @@ export interface PlanStubOptions {
42
42
  diag?: OjSchemaDiag
43
43
  }
44
44
 
45
- /** oj 方法名映射(DELETE → del) */
45
+ /** oj 方法名映射(DELETE → del)。OPTIONS 不在契约表达范围(runtime 白名单拒绝) */
46
46
  const OJ_METHOD: Record<string, string> = {
47
47
  GET: "get",
48
48
  POST: "post",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { initProject } from "./init";
9
9
  import { previewServer } from "./preview";
10
10
  import { usageText } from "./usage";
11
11
  import { vendorCommand } from "./vendor";
12
+ import { readCliVersion } from "./versions";
12
13
 
13
14
  /** ojm → oj 透传子命令(其余参数原样转发,见 src/oj-cmd.ts) */
14
15
  const OJ_PASSTHROUGH = new Set(["test", "exec", "openapi", "migrate", "schema"]);
@@ -23,6 +24,12 @@ function usage(): never {
23
24
  async function main() {
24
25
  const projectRoot = process.cwd();
25
26
 
27
+ // 顶层 --version / -V:打印 cli 包版本(与 `ojm info` 的 cli 版本同源)
28
+ if (command === "--version" || command === "-V") {
29
+ console.log(readCliVersion());
30
+ return;
31
+ }
32
+
26
33
  switch (command) {
27
34
  case "build":
28
35
  // 设计 §5:后端 oj build(零 DB 副作用)→ 前端全站合并(仅 build 清场合并)
@@ -131,6 +131,24 @@ export const SHARED_DEPS: SharedDepEntry[] = [
131
131
  { specifier: "simplebar-react", asset: "simplebar-react", hard: false },
132
132
  { specifier: "spin-delay", asset: "spin-delay", hard: false },
133
133
  { specifier: "tailwind-merge", asset: "tailwind-merge", hard: false },
134
+ /**
135
+ * Markdown 编辑器(Vditor v4,M5 回升自下游 oj-wiki):npm 包是 webpack **UMD**
136
+ * 单产物(main=dist/index.js,无 ESM 出口、仅 default 导出),走 starShim + esbuild
137
+ * 的既有 CJS/UMD interop 链路打包成 ESM 资产(与 react 家族同路,无需新机制)。
138
+ *
139
+ * 软共享:无单例语义(多副本只是重复体积,不拆 context),且不是每个模块都用。
140
+ * 体积大(gzip ≈700 KiB),故**惰性生效**:importmap 只做映射,宿主 shell 自身
141
+ * 不 import vditor ⇒ /assets/vditor.js 仅在模块产物真正 import 它时才被拉取,
142
+ * 不进宿主首屏。
143
+ *
144
+ * 深路径只登记 CSS 主样式(nprogress 同款样式垫片)。vditor 其余 dist 深路径
145
+ * (lute / i18n / icons / 主题 CSS…)是**运行期经 cdn 选项按需拉取的资源**而非
146
+ * import 说明符,由模块侧自行处理(自注入或指向宿主可服务的路径);若模块代码
147
+ * 直接 import 它们,会命中前缀 external 但无 importmap 键 → 构建期深路径报错,
148
+ * 属预期拦截(与 dayjs/plugin/* 同规则)。
149
+ */
150
+ { specifier: "vditor", asset: "vditor", hard: false },
151
+ { specifier: "vditor/dist/index.css", asset: "vditor-index-css", hard: false },
134
152
  ];
135
153
 
136
154
  /** 判断一个裸说明符是否命中共享表(命中主包前缀的深路径也按共享处理) */
package/src/usage.ts CHANGED
@@ -6,6 +6,8 @@ export function usageText(): string {
6
6
  return `@oj-module/cli
7
7
 
8
8
  用法:
9
+ ojm --version | -V
10
+ 显示 cli 包版本
9
11
  ojm init [dir] [--yes]
10
12
  前后端一体化工程脚手架(幂等补缺)
11
13
  ojm dev [port]
package/src/versions.ts CHANGED
@@ -17,6 +17,16 @@ import { SHARED_DEPS } from "./shared-deps";
17
17
  /** cli 包根(本文件位于 <cliRoot>/src/) */
18
18
  const CLI_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
19
19
 
20
+ /** 读取 cli 包自身版本(package.json 的 version 字段);不可读回退 unknown */
21
+ export function readCliVersion(): string {
22
+ try {
23
+ return JSON.parse(fs.readFileSync(path.join(CLI_ROOT, "package.json"), "utf-8")).version ?? "unknown";
24
+ }
25
+ catch {
26
+ return "unknown";
27
+ }
28
+ }
29
+
20
30
  /** 读取宿主 shell 产物中的共享依赖实际版本表 */
21
31
  export function readHostVersions(shellDist: string): Record<string, string> {
22
32
  return JSON.parse(fs.readFileSync(path.join(shellDist, "versions.json"), "utf-8"));
@@ -3,17 +3,20 @@ import { defineApi, z } from "@oj-module/runtime/contract";
3
3
  /**
4
4
  * notification 模块契约(uni-dev 形态,apiPrefix 字面等于 "/notification",AC-D9)。
5
5
  * 通知读写端点——前端经 ctx.register.notificationsApi 全量接管内置通知 API(D9 + G4):
6
- * 读 + 单条已读 + 全部已读 + 清空,四端点全必填。
6
+ * 读 + 单条已读 + 全部已读 + 清空,四端点全必填;另含发射原语端点 push/push-broadcast/del。
7
7
  */
8
8
 
9
- /** 通知项 */
9
+ /** 通知项(id 为命名空间串:"u:<id>" 按人 / "b:<id>" 广播) */
10
10
  const notificationItem = z.object({
11
- id: z.number(),
11
+ id: z.string(),
12
+ scope: z.enum(["user", "broadcast"]),
12
13
  avatar: z.string(),
13
14
  date: z.string(),
14
15
  isRead: z.boolean().optional(),
15
16
  message: z.string(),
16
17
  title: z.string(),
18
+ link: z.string().nullable().optional(),
19
+ category: z.enum(["notice", "pm", "mention"]).optional(),
17
20
  });
18
21
 
19
22
  /* 通知列表 */
@@ -21,7 +24,7 @@ export const fetchNotifications = defineApi({
21
24
  apiPrefix: "/notification",
22
25
  route: "/notifications",
23
26
  data: z.array(notificationItem),
24
- description: "通知列表",
27
+ description: "通知列表(按人 + 广播合并)",
25
28
  });
26
29
 
27
30
  /* 单条已读 */
@@ -30,7 +33,7 @@ export const markRead = defineApi({
30
33
  route: "/notifications/read",
31
34
  method: "POST",
32
35
  body: z.object({
33
- id: z.number(),
36
+ id: z.string(),
34
37
  }),
35
38
  description: "单条通知已读",
36
39
  });
@@ -50,3 +53,46 @@ export const clearAll = defineApi({
50
53
  method: "POST",
51
54
  description: "清空通知",
52
55
  });
56
+
57
+ /* 推按人通知(发射原语 HTTP 入口) */
58
+ export const push = defineApi({
59
+ apiPrefix: "/notification",
60
+ route: "/push",
61
+ method: "POST",
62
+ body: z.object({
63
+ userId: z.union([z.string(), z.number()]).optional(),
64
+ title: z.string(),
65
+ message: z.string(),
66
+ link: z.string().optional(),
67
+ avatar: z.string().optional(),
68
+ category: z.enum(["notice", "pm", "mention"]).optional(),
69
+ }),
70
+ description: "推按人通知",
71
+ });
72
+
73
+ /* 广播通知(全员可见,一行存储) */
74
+ export const pushBroadcast = defineApi({
75
+ apiPrefix: "/notification",
76
+ route: "/push-broadcast",
77
+ method: "POST",
78
+ body: z.object({
79
+ title: z.string(),
80
+ message: z.string(),
81
+ link: z.string().optional(),
82
+ avatar: z.string().optional(),
83
+ category: z.enum(["notice", "pm", "mention"]).optional(),
84
+ }),
85
+ description: "广播通知",
86
+ });
87
+
88
+ /* 删除通知(按人项校验归属,非本人 403;广播项仅删本人回执) */
89
+ export const del = defineApi({
90
+ apiPrefix: "/notification",
91
+ route: "/del",
92
+ method: "POST",
93
+ body: z.object({
94
+ id: z.string(),
95
+ userId: z.union([z.string(), z.number()]).optional(),
96
+ }),
97
+ description: "删除通知",
98
+ });