@ai0x0/utils 0.8.0 → 0.10.0

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.
@@ -239,7 +239,6 @@ setState({ open: false, current: undefined });
239
239
 
240
240
  - `require-use-form`:受控表单字段必须交给 Form 管理。
241
241
  - `require-form-convention`:Form/onFinish/rules/Modal 提交/错误提示约定。
242
- - `require-pro-components`:ModalForm、ProForm 字段和 submitter 约定。
243
242
  - `no-hardcoded-style`:token 样式约定。
244
243
  - `no-antd-space`:Flex 替代 Space。
245
244
  - `no-use-request-run`:`runAsync` 替代 `run`。
@@ -235,9 +235,9 @@ ESLint 按行数强制分区数量:
235
235
  - `ai0x0/no-antd-space`:禁止 `<Space>` / `<Space.Compact>`,使用 `<Flex>`。
236
236
  - `ai0x0/require-use-form`:表单字段状态不能用 `useState` / `useSetState` 管。
237
237
  - `ai0x0/require-form-convention`:Form 必须 `onFinish`,字段必须 rules,禁止 Modal.onOk 提交。
238
- - `ai0x0/require-pro-components`:业务表单优先 ProComponents。
239
238
  - `ai0x0/no-use-request-run`:禁止解构 `run`,使用 `runAsync` 并在调用处 `await`。
240
239
  - `ai0x0/no-consecutive-setstate`:连续 `setState({ ... })` 要合并成一次。
240
+ - scoped(不进 recommended,项目自行按 files 开启):`ai0x0/api-route-via-nrf`、`ai0x0/no-direct-api-url`、`ai0x0/no-hardcoded-text`(i18n 裸文案闸,legacy 旧账名单经选项传入)。
241
241
 
242
242
  ## 常用命令
243
243
 
package/README.md CHANGED
@@ -110,7 +110,6 @@ export default [
110
110
  | `no-one-letter-vars` | Forbid single-letter variable names |
111
111
  | `no-then` | Prefer `async/await` over `.then()` |
112
112
  | `no-antd-space` | Use `<Flex>` instead of `<Space>` |
113
- | `require-pro-components` | Require ProComponents usage |
114
113
  | `require-section-divider` | Large files need `===` comment dividers |
115
114
  | `no-consecutive-setstate` | Merge consecutive `setState` calls |
116
115
  | `no-use-request-run` | Forbid `useRequest.run()` |
@@ -118,6 +117,8 @@ export default [
118
117
  | `require-form-convention` | Form naming conventions |
119
118
  | `max-lines` | Limit each file to 500 code lines and prompt splitting into child files |
120
119
 
120
+ Scoped rules (not in `recommended`; enable per-file-glob in your own config): `api-route-via-nrf`, `no-direct-api-url`, `no-hardcoded-text` (i18n hardcoded-text gate with a `legacy` exemption list).
121
+
121
122
  ### Shared restrictions (`eslint-config/`)
122
123
 
123
124
  Pre-defined `no-restricted-syntax` selectors for antd + ahooks convention enforcement:
@@ -147,7 +148,7 @@ export default [
147
148
  ];
148
149
  ```
149
150
 
150
- Restrictions cover: `TryStatement`, `as` (non-const), `useState`, `useEffect`, `useMemo`, `useCallback`, native `div/span/p/h` tags, hardcoded colors, `fetch` and `new WebSocket()`.
151
+ Restrictions cover: `TryStatement`, `as` (non-const), `useState`, `useEffect`, `useMemo`, `useCallback`, `memo()`, native `div/span/p/h` tags, hardcoded colors, `fetch` and `new WebSocket()`.
151
152
 
152
153
  ## Installation
153
154
 
package/SKILL.md CHANGED
@@ -317,6 +317,8 @@ export const { GET } = route({
317
317
  - **Fail closed**: if the session is missing → 401; if `scope.value` returns empty → 403. A scope that cannot be resolved is **never** downgraded to "no filter". PUT/DELETE put the scope straight into the `UPDATE`/`DELETE` `where` (one statement, no read-then-write window) and return 404 when it affects zero rows — "does not exist" and "not yours" are deliberately indistinguishable, otherwise the endpoint becomes an existence oracle.
318
318
  - **Auto injection**: POST stamps the scope column with the scope value (by default that is `creatorId = session.userId`); PUT writes `editorId = session.userId`; GET/GET_LIST apply the scope as a mandatory `where`.
319
319
  - **Session shape** is yours: `getSession` is generic, so resolve "who + which space + what role" once and let `scope.value` / `can` read the same object instead of each querying again.
320
+ - **Built-in error copy** (`未登录` / `没有权限` / `缺少 id` / `未找到编辑(删除)对象,或没有权限` / scope-unresolved 403): thrown from inside the library, so the app's own i18n throw-sites never see them. Register `setHttpErrorMessageResolver(() => ({ unauthenticated: t("..."), ... }))` once at app startup to translate them per request — the resolver may be `async` (e.g. resolving the locale from `next/headers`), is called lazily at each throw site, and falls back to the Chinese defaults when absent or when it throws. Deliberately string-based, not error-code-based: CLI and agent consumers read these messages too and have no translation layer.
321
+
320
322
  - **Date coercion**: any body field ending in `At` is passed through `dayjs(value).toDate()` before write — accept ISO strings from clients.
321
323
  - **List filtering** (all querystring values are strings). **A column is filterable only if it appears in `schemas.response`** — the filter surface is the readable surface, so a list that returns `id / title` cannot be probed with `?secret=…` (unknown or unreadable keys are ignored, not rejected). Anything you want filterable belongs in the response schema.
322
324
  - `key=a,b,c` → `IN (...)` when the key is `id` or ends with `Id`; otherwise `OR ILIKE %a% OR ILIKE %b%`
@@ -26,7 +26,7 @@ function _asyncToGenerator(fn) { return function () { var self = this, args = ar
26
26
 
27
27
  import { and, eq } from "drizzle-orm";
28
28
  import { NO_SCOPE, scopeCondition } from "../scope.js";
29
- import { HttpError } from "../errors.js";
29
+ import { HttpError, httpErrorMessages } from "../errors.js";
30
30
  import { transformBody } from "./transform-body.js";
31
31
 
32
32
  /** 核心用得到表的哪一部分:只有 id。dialect 各自的 Table 类型不进来。 */
@@ -49,25 +49,35 @@ export function createPutActionCore(_ref) {
49
49
  scope = _ref2.scope;
50
50
  id = body.id;
51
51
  if (id) {
52
- _context.next = 4;
52
+ _context.next = 8;
53
53
  break;
54
54
  }
55
- throw new HttpError(400, "缺少 id");
56
- case 4:
55
+ _context.t0 = HttpError;
57
56
  _context.next = 6;
58
- return db.update(table).set(transformBody(body)).where(byId(table, id, scope)).returning();
57
+ return httpErrorMessages();
59
58
  case 6:
59
+ _context.t1 = _context.sent.missingId;
60
+ throw new _context.t0(400, _context.t1);
61
+ case 8:
62
+ _context.next = 10;
63
+ return db.update(table).set(transformBody(body)).where(byId(table, id, scope)).returning();
64
+ case 10:
60
65
  _yield$db$update$set$ = _context.sent;
61
66
  _yield$db$update$set$2 = _slicedToArray(_yield$db$update$set$, 1);
62
67
  data = _yield$db$update$set$2[0];
63
68
  if (!(!data && scope !== NO_SCOPE)) {
64
- _context.next = 11;
69
+ _context.next = 19;
65
70
  break;
66
71
  }
67
- throw new HttpError(404, "未找到编辑对象,或没有权限");
68
- case 11:
72
+ _context.t2 = HttpError;
73
+ _context.next = 17;
74
+ return httpErrorMessages();
75
+ case 17:
76
+ _context.t3 = _context.sent.editNotFound;
77
+ throw new _context.t2(404, _context.t3);
78
+ case 19:
69
79
  return _context.abrupt("return", data);
70
- case 12:
80
+ case 20:
71
81
  case "end":
72
82
  return _context.stop();
73
83
  }
@@ -90,23 +100,33 @@ export function createDeleteActionCore(_ref4) {
90
100
  id = _ref5.id;
91
101
  scope = _ref6.scope;
92
102
  if (id) {
93
- _context2.next = 4;
103
+ _context2.next = 8;
94
104
  break;
95
105
  }
96
- throw new HttpError(400, "缺少 id");
97
- case 4:
106
+ _context2.t0 = HttpError;
98
107
  _context2.next = 6;
99
- return db.delete(table).where(byId(table, id, scope)).returning();
108
+ return httpErrorMessages();
100
109
  case 6:
110
+ _context2.t1 = _context2.sent.missingId;
111
+ throw new _context2.t0(400, _context2.t1);
112
+ case 8:
113
+ _context2.next = 10;
114
+ return db.delete(table).where(byId(table, id, scope)).returning();
115
+ case 10:
101
116
  rows = _context2.sent;
102
117
  if (!(rows.length === 0 && scope !== NO_SCOPE)) {
103
- _context2.next = 9;
118
+ _context2.next = 17;
104
119
  break;
105
120
  }
106
- throw new HttpError(404, "未找到删除对象,或没有权限");
107
- case 9:
121
+ _context2.t2 = HttpError;
122
+ _context2.next = 15;
123
+ return httpErrorMessages();
124
+ case 15:
125
+ _context2.t3 = _context2.sent.deleteNotFound;
126
+ throw new _context2.t2(404, _context2.t3);
127
+ case 17:
108
128
  return _context2.abrupt("return", rows);
109
- case 10:
129
+ case 18:
110
130
  case "end":
111
131
  return _context2.stop();
112
132
  }
@@ -2,3 +2,33 @@ export declare class HttpError extends Error {
2
2
  status: number;
3
3
  constructor(status: number, message: string);
4
4
  }
5
+ export interface HttpErrorMessages {
6
+ /** 401:会话不存在。 */
7
+ unauthenticated: string;
8
+ /** 403:access.can 拒绝且没给自定义文案。 */
9
+ forbidden: string;
10
+ /** 400:PUT / DELETE 请求体缺 id。 */
11
+ missingId: string;
12
+ /** 404:PUT 影响 0 行(不存在或不属于调用者)。 */
13
+ editNotFound: string;
14
+ /** 404:DELETE 影响 0 行。 */
15
+ deleteNotFound: string;
16
+ /** 403:作用域值解析不出来 —— 多半是配错了列名,但请求照样要被拒绝。 */
17
+ scopeUnresolved: (column: string) => string;
18
+ }
19
+ export declare const defaultHttpErrorMessages: HttpErrorMessages;
20
+ type HttpErrorMessageResolver = () => Partial<HttpErrorMessages> | Promise<Partial<HttpErrorMessages>>;
21
+ /**
22
+ * 注册文案解析器,应用启动时调一次(再次调用覆盖;传 undefined 恢复默认)。
23
+ * 不注册就一直是 defaultHttpErrorMessages 那几句中文 —— 没做国际化的项目零感知。
24
+ */
25
+ export declare function setHttpErrorMessageResolver(next: HttpErrorMessageResolver | undefined): void;
26
+ /**
27
+ * 取出当前生效的整份文案。**在抛出点现调**,别在模块顶层缓存 —— 解析器是按请求判
28
+ * 语言的,缓存住就把上一个请求的语言漏给下一个。
29
+ *
30
+ * 解析器自己抛了错(比如语言包没加载到)就回落默认中文:报错文案出不来不该把
31
+ * 原本那个 401/403 也吞掉。
32
+ */
33
+ export declare function httpErrorMessages(): Promise<HttpErrorMessages>;
34
+ export {};
@@ -1,4 +1,9 @@
1
+ function _regeneratorRuntime() { "use strict"; /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ _regeneratorRuntime = function _regeneratorRuntime() { return e; }; var t, e = {}, r = Object.prototype, n = r.hasOwnProperty, o = Object.defineProperty || function (t, e, r) { t[e] = r.value; }, i = "function" == typeof Symbol ? Symbol : {}, a = i.iterator || "@@iterator", c = i.asyncIterator || "@@asyncIterator", u = i.toStringTag || "@@toStringTag"; function define(t, e, r) { return Object.defineProperty(t, e, { value: r, enumerable: !0, configurable: !0, writable: !0 }), t[e]; } try { define({}, ""); } catch (t) { define = function define(t, e, r) { return t[e] = r; }; } function wrap(t, e, r, n) { var i = e && e.prototype instanceof Generator ? e : Generator, a = Object.create(i.prototype), c = new Context(n || []); return o(a, "_invoke", { value: makeInvokeMethod(t, r, c) }), a; } function tryCatch(t, e, r) { try { return { type: "normal", arg: t.call(e, r) }; } catch (t) { return { type: "throw", arg: t }; } } e.wrap = wrap; var h = "suspendedStart", l = "suspendedYield", f = "executing", s = "completed", y = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} var p = {}; define(p, a, function () { return this; }); var d = Object.getPrototypeOf, v = d && d(d(values([]))); v && v !== r && n.call(v, a) && (p = v); var g = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(p); function defineIteratorMethods(t) { ["next", "throw", "return"].forEach(function (e) { define(t, e, function (t) { return this._invoke(e, t); }); }); } function AsyncIterator(t, e) { function invoke(r, o, i, a) { var c = tryCatch(t[r], t, o); if ("throw" !== c.type) { var u = c.arg, h = u.value; return h && "object" == _typeof(h) && n.call(h, "__await") ? e.resolve(h.__await).then(function (t) { invoke("next", t, i, a); }, function (t) { invoke("throw", t, i, a); }) : e.resolve(h).then(function (t) { u.value = t, i(u); }, function (t) { return invoke("throw", t, i, a); }); } a(c.arg); } var r; o(this, "_invoke", { value: function value(t, n) { function callInvokeWithMethodAndArg() { return new e(function (e, r) { invoke(t, n, e, r); }); } return r = r ? r.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); } }); } function makeInvokeMethod(e, r, n) { var o = h; return function (i, a) { if (o === f) throw new Error("Generator is already running"); if (o === s) { if ("throw" === i) throw a; return { value: t, done: !0 }; } for (n.method = i, n.arg = a;;) { var c = n.delegate; if (c) { var u = maybeInvokeDelegate(c, n); if (u) { if (u === y) continue; return u; } } if ("next" === n.method) n.sent = n._sent = n.arg;else if ("throw" === n.method) { if (o === h) throw o = s, n.arg; n.dispatchException(n.arg); } else "return" === n.method && n.abrupt("return", n.arg); o = f; var p = tryCatch(e, r, n); if ("normal" === p.type) { if (o = n.done ? s : l, p.arg === y) continue; return { value: p.arg, done: n.done }; } "throw" === p.type && (o = s, n.method = "throw", n.arg = p.arg); } }; } function maybeInvokeDelegate(e, r) { var n = r.method, o = e.iterator[n]; if (o === t) return r.delegate = null, "throw" === n && e.iterator.return && (r.method = "return", r.arg = t, maybeInvokeDelegate(e, r), "throw" === r.method) || "return" !== n && (r.method = "throw", r.arg = new TypeError("The iterator does not provide a '" + n + "' method")), y; var i = tryCatch(o, e.iterator, r.arg); if ("throw" === i.type) return r.method = "throw", r.arg = i.arg, r.delegate = null, y; var a = i.arg; return a ? a.done ? (r[e.resultName] = a.value, r.next = e.nextLoc, "return" !== r.method && (r.method = "next", r.arg = t), r.delegate = null, y) : a : (r.method = "throw", r.arg = new TypeError("iterator result is not an object"), r.delegate = null, y); } function pushTryEntry(t) { var e = { tryLoc: t[0] }; 1 in t && (e.catchLoc = t[1]), 2 in t && (e.finallyLoc = t[2], e.afterLoc = t[3]), this.tryEntries.push(e); } function resetTryEntry(t) { var e = t.completion || {}; e.type = "normal", delete e.arg, t.completion = e; } function Context(t) { this.tryEntries = [{ tryLoc: "root" }], t.forEach(pushTryEntry, this), this.reset(!0); } function values(e) { if (e || "" === e) { var r = e[a]; if (r) return r.call(e); if ("function" == typeof e.next) return e; if (!isNaN(e.length)) { var o = -1, i = function next() { for (; ++o < e.length;) if (n.call(e, o)) return next.value = e[o], next.done = !1, next; return next.value = t, next.done = !0, next; }; return i.next = i; } } throw new TypeError(_typeof(e) + " is not iterable"); } return GeneratorFunction.prototype = GeneratorFunctionPrototype, o(g, "constructor", { value: GeneratorFunctionPrototype, configurable: !0 }), o(GeneratorFunctionPrototype, "constructor", { value: GeneratorFunction, configurable: !0 }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, u, "GeneratorFunction"), e.isGeneratorFunction = function (t) { var e = "function" == typeof t && t.constructor; return !!e && (e === GeneratorFunction || "GeneratorFunction" === (e.displayName || e.name)); }, e.mark = function (t) { return Object.setPrototypeOf ? Object.setPrototypeOf(t, GeneratorFunctionPrototype) : (t.__proto__ = GeneratorFunctionPrototype, define(t, u, "GeneratorFunction")), t.prototype = Object.create(g), t; }, e.awrap = function (t) { return { __await: t }; }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, c, function () { return this; }), e.AsyncIterator = AsyncIterator, e.async = function (t, r, n, o, i) { void 0 === i && (i = Promise); var a = new AsyncIterator(wrap(t, r, n, o), i); return e.isGeneratorFunction(r) ? a : a.next().then(function (t) { return t.done ? t.value : a.next(); }); }, defineIteratorMethods(g), define(g, u, "Generator"), define(g, a, function () { return this; }), define(g, "toString", function () { return "[object Generator]"; }), e.keys = function (t) { var e = Object(t), r = []; for (var n in e) r.push(n); return r.reverse(), function next() { for (; r.length;) { var t = r.pop(); if (t in e) return next.value = t, next.done = !1, next; } return next.done = !0, next; }; }, e.values = values, Context.prototype = { constructor: Context, reset: function reset(e) { if (this.prev = 0, this.next = 0, this.sent = this._sent = t, this.done = !1, this.delegate = null, this.method = "next", this.arg = t, this.tryEntries.forEach(resetTryEntry), !e) for (var r in this) "t" === r.charAt(0) && n.call(this, r) && !isNaN(+r.slice(1)) && (this[r] = t); }, stop: function stop() { this.done = !0; var t = this.tryEntries[0].completion; if ("throw" === t.type) throw t.arg; return this.rval; }, dispatchException: function dispatchException(e) { if (this.done) throw e; var r = this; function handle(n, o) { return a.type = "throw", a.arg = e, r.next = n, o && (r.method = "next", r.arg = t), !!o; } for (var o = this.tryEntries.length - 1; o >= 0; --o) { var i = this.tryEntries[o], a = i.completion; if ("root" === i.tryLoc) return handle("end"); if (i.tryLoc <= this.prev) { var c = n.call(i, "catchLoc"), u = n.call(i, "finallyLoc"); if (c && u) { if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); if (this.prev < i.finallyLoc) return handle(i.finallyLoc); } else if (c) { if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); } else { if (!u) throw new Error("try statement without catch or finally"); if (this.prev < i.finallyLoc) return handle(i.finallyLoc); } } } }, abrupt: function abrupt(t, e) { for (var r = this.tryEntries.length - 1; r >= 0; --r) { var o = this.tryEntries[r]; if (o.tryLoc <= this.prev && n.call(o, "finallyLoc") && this.prev < o.finallyLoc) { var i = o; break; } } i && ("break" === t || "continue" === t) && i.tryLoc <= e && e <= i.finallyLoc && (i = null); var a = i ? i.completion : {}; return a.type = t, a.arg = e, i ? (this.method = "next", this.next = i.finallyLoc, y) : this.complete(a); }, complete: function complete(t, e) { if ("throw" === t.type) throw t.arg; return "break" === t.type || "continue" === t.type ? this.next = t.arg : "return" === t.type ? (this.rval = this.arg = t.arg, this.method = "return", this.next = "end") : "normal" === t.type && e && (this.next = e), y; }, finish: function finish(t) { for (var e = this.tryEntries.length - 1; e >= 0; --e) { var r = this.tryEntries[e]; if (r.finallyLoc === t) return this.complete(r.completion, r.afterLoc), resetTryEntry(r), y; } }, catch: function _catch(t) { for (var e = this.tryEntries.length - 1; e >= 0; --e) { var r = this.tryEntries[e]; if (r.tryLoc === t) { var n = r.completion; if ("throw" === n.type) { var o = n.arg; resetTryEntry(r); } return o; } } throw new Error("illegal catch attempt"); }, delegateYield: function delegateYield(e, r, n) { return this.delegate = { iterator: values(e), resultName: r, nextLoc: n }, "next" === this.method && (this.arg = t), y; } }, e; }
2
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
3
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
1
4
  function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
5
+ function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
6
+ function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
2
7
  function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); } }
3
8
  function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
4
9
  function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
@@ -28,4 +33,78 @@ export var HttpError = /*#__PURE__*/function (_Error) {
28
33
  return _this;
29
34
  }
30
35
  return _createClass(HttpError);
31
- }( /*#__PURE__*/_wrapNativeSuper(Error));
36
+ }( /*#__PURE__*/_wrapNativeSuper(Error));
37
+
38
+ // =============================================================================
39
+ // 框架内建报错的文案 —— 默认中文,调用方可以按请求语言换掉
40
+ // =============================================================================
41
+ // 这几句是从**库内部**抛出去的(准入判定、写动作的兜底),业务自己的报错在业务代码里
42
+ // 渲染(do-tv 是 (backend)/utils/i18n 的 serverT,抛出点就地翻成请求的语言)。库不知道
43
+ // 也不该知道各项目的 i18n 栈(next-intl、react-i18next、自研…),所以不给库塞字典,
44
+ // 只留一个「抛出前问一次」的钩子:谁做国际化,谁在应用启动时注册一个解析器。
45
+ //
46
+ // 为什么不回错误码让前端翻:这些 message 的读者不只是浏览器 —— CLI 与 agent 也读它,
47
+ // 它们没有前端那套翻译层,回个码等于让它们打印一串 `errors.UNAUTHENTICATED`。在服务端
48
+ // 渲染成字符串,所有读者拿到的都是人话。
49
+ //
50
+ // 解析器按**请求**解析(语言藏在 cookie / Accept-Language 里),所以它是每次抛出时现调
51
+ // 的函数,而不是启动时钉死的一份字符串。允许 async —— 各抛出点本来就在 async 函数里,
52
+ // 而「按当前请求取翻译」在 Next 里就得 await(do-tv 的 requestText() 走 next/headers)。
53
+
54
+ export var defaultHttpErrorMessages = {
55
+ unauthenticated: "未登录",
56
+ forbidden: "没有权限",
57
+ missingId: "缺少 id",
58
+ editNotFound: "未找到编辑对象,或没有权限",
59
+ deleteNotFound: "未找到删除对象,或没有权限",
60
+ scopeUnresolved: function scopeUnresolved(column) {
61
+ return "\u65E0\u6CD5\u786E\u5B9A\u8FD9\u6B21\u8BF7\u6C42\u7684\u5F52\u5C5E\uFF08".concat(column, "\uFF09\u3002\u8FD9\u4E0D\u662F\u300C\u4E0D\u505A\u9694\u79BB\u300D\u2014\u2014\u8BF7\u6C42\u88AB\u62D2\u7EDD\u3002");
62
+ }
63
+ };
64
+ var resolver;
65
+
66
+ /**
67
+ * 注册文案解析器,应用启动时调一次(再次调用覆盖;传 undefined 恢复默认)。
68
+ * 不注册就一直是 defaultHttpErrorMessages 那几句中文 —— 没做国际化的项目零感知。
69
+ */
70
+ export function setHttpErrorMessageResolver(next) {
71
+ resolver = next;
72
+ }
73
+
74
+ /**
75
+ * 取出当前生效的整份文案。**在抛出点现调**,别在模块顶层缓存 —— 解析器是按请求判
76
+ * 语言的,缓存住就把上一个请求的语言漏给下一个。
77
+ *
78
+ * 解析器自己抛了错(比如语言包没加载到)就回落默认中文:报错文案出不来不该把
79
+ * 原本那个 401/403 也吞掉。
80
+ */
81
+ export function httpErrorMessages() {
82
+ return _httpErrorMessages.apply(this, arguments);
83
+ }
84
+ function _httpErrorMessages() {
85
+ _httpErrorMessages = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee() {
86
+ var overrides, _resolver;
87
+ return _regeneratorRuntime().wrap(function _callee$(_context) {
88
+ while (1) switch (_context.prev = _context.next) {
89
+ case 0:
90
+ _context.prev = 0;
91
+ _context.next = 3;
92
+ return (_resolver = resolver) === null || _resolver === void 0 ? void 0 : _resolver();
93
+ case 3:
94
+ overrides = _context.sent;
95
+ _context.next = 9;
96
+ break;
97
+ case 6:
98
+ _context.prev = 6;
99
+ _context.t0 = _context["catch"](0);
100
+ overrides = undefined;
101
+ case 9:
102
+ return _context.abrupt("return", _objectSpread(_objectSpread({}, defaultHttpErrorMessages), overrides));
103
+ case 10:
104
+ case "end":
105
+ return _context.stop();
106
+ }
107
+ }, _callee, null, [[0, 6]]);
108
+ }));
109
+ return _httpErrorMessages.apply(this, arguments);
110
+ }
@@ -8,7 +8,7 @@ function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" ==
8
8
  function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
9
9
  function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
10
10
  import { TypedNextResponse } from "@ai0x0/next-rest-framework";
11
- import { HttpError } from "../errors.js";
11
+ import { HttpError, httpErrorMessages } from "../errors.js";
12
12
  import { isEmptyScopeValue, NO_SCOPE } from "../scope.js";
13
13
 
14
14
  // ==============================================================================
@@ -100,52 +100,83 @@ function _resolveAccess() {
100
100
  case 9:
101
101
  session = _context.t0;
102
102
  if (!(access !== null && access !== void 0 && access.can)) {
103
- _context.next = 19;
103
+ _context.next = 33;
104
104
  break;
105
105
  }
106
106
  if (session) {
107
- _context.next = 13;
107
+ _context.next = 17;
108
108
  break;
109
109
  }
110
- throw new HttpError(401, "未登录");
111
- case 13:
110
+ _context.t1 = HttpError;
112
111
  _context.next = 15;
112
+ return httpErrorMessages();
113
+ case 15:
114
+ _context.t2 = _context.sent.unauthenticated;
115
+ throw new _context.t1(401, _context.t2);
116
+ case 17:
117
+ _context.next = 19;
113
118
  return access.can({
114
119
  method: method,
115
120
  session: session
116
121
  });
117
- case 15:
122
+ case 19:
118
123
  verdict = _context.sent;
119
124
  if (!(verdict !== true)) {
120
- _context.next = 19;
125
+ _context.next = 33;
121
126
  break;
122
127
  }
123
128
  denial = _typeof(verdict) === "object" ? verdict : {};
124
- throw new HttpError((_denial$status = denial.status) !== null && _denial$status !== void 0 ? _denial$status : 403, (_denial$message = denial.message) !== null && _denial$message !== void 0 ? _denial$message : "没有权限");
125
- case 19:
129
+ _context.t3 = HttpError;
130
+ _context.t4 = (_denial$status = denial.status) !== null && _denial$status !== void 0 ? _denial$status : 403;
131
+ if (!((_denial$message = denial.message) !== null && _denial$message !== void 0)) {
132
+ _context.next = 28;
133
+ break;
134
+ }
135
+ _context.t5 = _denial$message;
136
+ _context.next = 31;
137
+ break;
138
+ case 28:
139
+ _context.next = 30;
140
+ return httpErrorMessages();
141
+ case 30:
142
+ _context.t5 = _context.sent.forbidden;
143
+ case 31:
144
+ _context.t6 = _context.t5;
145
+ throw new _context.t3(_context.t4, _context.t6);
146
+ case 33:
126
147
  if (wantsScope) {
127
- _context.next = 21;
148
+ _context.next = 35;
128
149
  break;
129
150
  }
130
151
  return _context.abrupt("return", {
131
152
  scope: NO_SCOPE,
132
153
  session: session
133
154
  });
134
- case 21:
155
+ case 35:
135
156
  if (session) {
136
- _context.next = 23;
157
+ _context.next = 41;
137
158
  break;
138
159
  }
139
- throw new HttpError(401, "未登录");
140
- case 23:
160
+ _context.t7 = HttpError;
161
+ _context.next = 39;
162
+ return httpErrorMessages();
163
+ case 39:
164
+ _context.t8 = _context.sent.unauthenticated;
165
+ throw new _context.t7(401, _context.t8);
166
+ case 41:
141
167
  column = (_access$scope$column = access === null || access === void 0 || (_access$scope = access.scope) === null || _access$scope === void 0 ? void 0 : _access$scope.column) !== null && _access$scope$column !== void 0 ? _access$scope$column : DEFAULT_SCOPE_COLUMN;
142
168
  value = access !== null && access !== void 0 && access.scope ? access.scope.value(session) : session.userId;
143
169
  if (!isEmptyScopeValue(value)) {
144
- _context.next = 27;
170
+ _context.next = 49;
145
171
  break;
146
172
  }
147
- throw new HttpError(403, "\u65E0\u6CD5\u786E\u5B9A\u8FD9\u6B21\u8BF7\u6C42\u7684\u5F52\u5C5E\uFF08".concat(column, "\uFF09\u3002\u8FD9\u4E0D\u662F\u300C\u4E0D\u505A\u9694\u79BB\u300D\u2014\u2014\u8BF7\u6C42\u88AB\u62D2\u7EDD\u3002"));
148
- case 27:
173
+ _context.t9 = HttpError;
174
+ _context.next = 47;
175
+ return httpErrorMessages();
176
+ case 47:
177
+ _context.t10 = _context.sent.scopeUnresolved(column);
178
+ throw new _context.t9(403, _context.t10);
179
+ case 49:
149
180
  return _context.abrupt("return", {
150
181
  scope: {
151
182
  column: column,
@@ -153,7 +184,7 @@ function _resolveAccess() {
153
184
  },
154
185
  session: session
155
186
  });
156
- case 28:
187
+ case 50:
157
188
  case "end":
158
189
  return _context.stop();
159
190
  }
@@ -6,10 +6,15 @@
6
6
  * import { ai0x0, allRestrictions, allRestrictionsWithNetworking }
7
7
  * from "@ai0x0/utils/eslint-config/index.js";
8
8
  */
9
- import localPlugin from "../eslint-rules/index.js";
9
+ import localPlugin, { SCOPED_RULES } from "../eslint-rules/index.js";
10
10
 
11
11
  export { localPlugin as ai0x0 };
12
12
 
13
+ // 不进 recommended、需要调用方按 files 自行开启的规则名单。消费方按规则名全量铺 recommended
14
+ // 之外的规则时(比如自己组装 rules 表),用这个集合过滤,别在调用方手抄一份 —— 手抄的
15
+ // 那份在新增 scoped 规则时不会跟着更新。
16
+ export { SCOPED_RULES };
17
+
13
18
  export const allRestrictions = [
14
19
  {
15
20
  selector: "TryStatement",
@@ -40,6 +45,12 @@ export const allRestrictions = [
40
45
  selector: "CallExpression[callee.name='useCallback']",
41
46
  message: "禁止 useCallback。React Compiler 会自动记忆化,无需手动优化。",
42
47
  },
48
+ {
49
+ selector:
50
+ ":matches(CallExpression[callee.name='memo'], CallExpression[callee.object.name='React'][callee.property.name='memo'])",
51
+ message:
52
+ "禁止 memo()。React Compiler 会自动记忆化;手写 memo 只会多一套独立的 props 浅比较,还容易把热点修在组件边界上、掩盖真正不稳定的 props。",
53
+ },
43
54
  {
44
55
  selector: "JSXOpeningElement[name.name=/^(div|span|p|h[1-6])$/]",
45
56
  message:
@@ -5,7 +5,6 @@ import requireSectionDivider from "./require-section-divider.js";
5
5
  import noHardcodedStyle from "./no-hardcoded-style.js";
6
6
  import requireUseForm from "./require-use-form.js";
7
7
  import requireFormConvention from "./require-form-convention.js";
8
- import requireProComponents from "./require-pro-components.js";
9
8
  import noAntdSpace from "./no-antd-space.js";
10
9
  import noUseRequestRun from "./no-use-request-run.js";
11
10
  import noThen from "./no-then.js";
@@ -14,13 +13,13 @@ import noConsecutiveSetState from "./no-consecutive-setstate.js";
14
13
  import maxLines from "./max-lines.js";
15
14
  import apiRouteViaNrf from "./api-route-via-nrf.js";
16
15
  import noDirectApiUrl from "./no-direct-api-url.js";
16
+ import noHardcodedText from "./no-hardcoded-text.js";
17
17
 
18
18
  const rules = {
19
19
  "require-section-divider": requireSectionDivider,
20
20
  "no-hardcoded-style": noHardcodedStyle,
21
21
  "require-use-form": requireUseForm,
22
22
  "require-form-convention": requireFormConvention,
23
- "require-pro-components": requireProComponents,
24
23
  "no-antd-space": noAntdSpace,
25
24
  "no-use-request-run": noUseRequestRun,
26
25
  "no-then": noThen,
@@ -29,14 +28,21 @@ const rules = {
29
28
  "max-lines": maxLines,
30
29
  "api-route-via-nrf": apiRouteViaNrf,
31
30
  "no-direct-api-url": noDirectApiUrl,
31
+ "no-hardcoded-text": noHardcodedText,
32
32
  };
33
33
 
34
- // 这两条只管「特定位置」的代码,不能跟着 recommended 全局开:
34
+ // 这三条只管「特定位置」的代码,不能跟着 recommended 全局开:
35
35
  // · api-route-via-nrf 只该挂在 API 路由文件上(别的文件 export 一个叫 GET 的函数是自由的);
36
- // · no-direct-api-url 只该挂在前端目录上(后端与中间件里出现 "/api/..." 字面量是本分)。
36
+ // · no-direct-api-url 只该挂在前端目录上(后端与中间件里出现 "/api/..." 字面量是本分);
37
+ // · no-hardcoded-text 只该挂在做了国际化的项目上(没接语言包的项目开了就是满屏误报),
38
+ // 且旧账豁免名单由调用方经 legacy 选项传入。
37
39
  // 调用方在 flat config 里用 files 圈定范围自行开启,豁免文件用 files override 关掉,
38
- // 用法写在两条规则各自的文件头。
39
- const SCOPED_RULES = new Set(["api-route-via-nrf", "no-direct-api-url"]);
40
+ // 用法写在各条规则的文件头。
41
+ export const SCOPED_RULES = new Set([
42
+ "api-route-via-nrf",
43
+ "no-direct-api-url",
44
+ "no-hardcoded-text",
45
+ ]);
40
46
 
41
47
  const plugin = {
42
48
  rules,
@@ -62,7 +62,7 @@ const rule = {
62
62
  ],
63
63
  messages: {
64
64
  tooManyLines:
65
- "单文件代码行数过多({{ lineCount }} 行,最多 {{ max }} 行),请拆分为子文件。",
65
+ "单文件代码行数过多({{ lineCount }} 行,最多 {{ max }} 行{{ skipped }}),请拆分为子文件。",
66
66
  },
67
67
  },
68
68
  create(context) {
@@ -97,6 +97,18 @@ const rule = {
97
97
  return;
98
98
  }
99
99
 
100
+ // 行数口径要写进消息里:默认注释与空行都不算,不说明的话看消息的人会拿
101
+ // 编辑器底部的总行数去对,对不上。
102
+ const skippedParts = [];
103
+ if (options.skipComments) {
104
+ skippedParts.push("注释");
105
+ }
106
+ if (options.skipBlankLines) {
107
+ skippedParts.push("空行");
108
+ }
109
+ const skipped =
110
+ skippedParts.length > 0 ? `,${skippedParts.join("与")}不计入` : "";
111
+
100
112
  context.report({
101
113
  node,
102
114
  loc: {
@@ -107,6 +119,7 @@ const rule = {
107
119
  data: {
108
120
  lineCount,
109
121
  max: options.max,
122
+ skipped,
110
123
  },
111
124
  });
112
125
  },
@@ -450,7 +450,7 @@ const rule = {
450
450
  schema: [],
451
451
  messages: {
452
452
  noHardcoded:
453
- "禁止硬编码 `{{ {{key}}: {{value}} }}`。请使用 antd token(如 `token.marginSM`、`token.colorPrimary`)。可参考 node_modules/@ai0x0/utils/.agents/skills/antd/SKILL.md 了解 token 体系。",
453
+ "禁止硬编码 `{{ {{key}}: {{value}} }}`。请用 antd token:间距用 `token.marginSM` / `token.paddingSM`,颜色用 `token.colorPrimary` / `token.colorText`,圆角用 `token.borderRadius`(经 `theme.useToken()` 或 createStyles 的 token 参数取)。",
454
454
  },
455
455
  },
456
456
  create(context) {
@@ -0,0 +1,157 @@
1
+ // =============================================================================
2
+ // 旧账名单之外的文件里,JSX 正文与文案属性不许写裸中文
3
+ // =============================================================================
4
+ // 国际化迁移要跨好几周,期间新代码还在往里加中文 —— 不拦的话分母是个移动靶,做得再快也
5
+ // 追不上。但把整仓库一起开成 error 是不现实的(当场几千个报错,只会被人整条关掉)。
6
+ //
7
+ // 所以这条规则带一份**只减不增的旧账名单**(由各项目的生成脚本维护,经 options.legacy
8
+ // 传入):名单里的文件一个字都不报,名单外的一条都不许有。**新建的文件天然在名单外**,
9
+ // 从第一行起就受管;一个文件迁完了就从名单里划掉,划掉之后它再也回不去。
10
+ //
11
+ // 名单是「文件还剩多少没迁」的唯一判据,不能拿「这个文件有没有 import i18n 库」代替 ——
12
+ // 迁移是分批的(先抽共用词表、再按模块走),一个文件很可能刚接上语言包、里头还剩二十条
13
+ // 没动。按 import 判的话,那二十条会在文件刚接上的当天集体变成 error。
14
+ //
15
+ // 只认两个位置 —— JSX 正文与文案属性。这不是偷懒:正是这两处不会与「中文当值用」相混
16
+ //(.describe() 的 API 文档、枚举值、css`` 里的注释都长不到这两个位置上),所以这条规则
17
+ // 不需要那一整套语法位置判断,也就不会误报。
18
+ //
19
+ // **这条规则不进 recommended**(见 index.js 的 SCOPED_RULES):没做国际化的项目不该被它
20
+ // 扫到。要用的项目在自己的 flat config 里按 files 圈定范围、把自己的旧账名单传给
21
+ // legacy 选项。
22
+
23
+ const CJK = /[一-鿿]/;
24
+
25
+ /** 摆文案的属性名。调用方若有配套的文案扫描脚本,两份名单要保持同源。 */
26
+ const TEXT_PROPS = new Set([
27
+ "label",
28
+ "title",
29
+ "placeholder",
30
+ "tooltip",
31
+ "desc",
32
+ "description",
33
+ "okText",
34
+ "cancelText",
35
+ "emptyText",
36
+ "extra",
37
+ "header",
38
+ "footer",
39
+ "alt",
40
+ "tip",
41
+ "subTitle",
42
+ "content",
43
+ "message",
44
+ "hint",
45
+ "heading",
46
+ "caption",
47
+ ]);
48
+
49
+ // =============================================================================
50
+ // 取值
51
+ // =============================================================================
52
+
53
+ /** 一个节点里的中文文本,没有就返回空串。模板串把静态片段拼起来看。 */
54
+ function chineseIn(node) {
55
+ if (!node) {
56
+ return "";
57
+ }
58
+ if (node.type === "Literal" && typeof node.value === "string") {
59
+ return CJK.test(node.value) ? node.value : "";
60
+ }
61
+ if (node.type === "TemplateLiteral" && node.quasis) {
62
+ const text = node.quasis.map((part) => part.value.raw).join("${…}");
63
+ return CJK.test(text) ? text : "";
64
+ }
65
+ // JSX 属性里的 `{...}`:真正的值在里头。
66
+ if (node.type === "JSXExpressionContainer") {
67
+ return chineseIn(node.expression);
68
+ }
69
+ return "";
70
+ }
71
+
72
+ /** 属性名。标识符(`label:`、`label=`)读 name,字符串键(`"label":`)读 value。 */
73
+ function nameOf(node) {
74
+ if (!node) {
75
+ return "";
76
+ }
77
+ if (typeof node.name === "string") {
78
+ return node.name;
79
+ }
80
+ if (node.type === "Literal" && typeof node.value === "string") {
81
+ return node.value;
82
+ }
83
+ return "";
84
+ }
85
+
86
+ // =============================================================================
87
+ // 规则
88
+ // =============================================================================
89
+
90
+ /** @type {import('eslint').Rule.RuleModule} */
91
+ const rule = {
92
+ meta: {
93
+ type: "problem",
94
+ docs: {
95
+ description: "旧账名单之外的文件里,JSX 正文与文案属性不许写裸中文。",
96
+ },
97
+ schema: [
98
+ {
99
+ type: "object",
100
+ properties: {
101
+ /** 还没迁完的文件(仓库相对路径),这些文件里一个字都不报。 */
102
+ legacy: { type: "array", items: { type: "string" } },
103
+ },
104
+ additionalProperties: false,
105
+ },
106
+ ],
107
+ messages: {
108
+ hardcoded:
109
+ "界面文案要走语言包:把「{{text}}」搬进语言包文件,用 i18n 取值函数读取。这个文件不在旧账豁免名单里。",
110
+ },
111
+ },
112
+ create(context) {
113
+ const options = context.options[0] ?? {};
114
+ // eslint 给的是绝对路径,名单里存的是仓库相对路径 —— 按后缀比,免得把 cwd 也写进名单
115
+ //(那样名单换台机器就全失效,而失效的样子是「突然多出几千个报错」)。
116
+ const here = context.filename.replaceAll("\\", "/");
117
+ // 测试文件里的中文是**夹具**不是界面文案:被测的输入本身就该是中文。名单是「只减不增」
118
+ // 的,把测试文件塞进去等于把它变成垃圾桶。
119
+ const isTest = /\.test\.tsx?$/.test(here);
120
+ const skipped =
121
+ isTest || (options.legacy ?? []).some((file) => here.endsWith(file));
122
+
123
+ const report = (node, text) => {
124
+ if (skipped || !text) {
125
+ return;
126
+ }
127
+ context.report({
128
+ node,
129
+ messageId: "hardcoded",
130
+ data: { text: text.replace(/\s+/g, " ").trim().slice(0, 20) },
131
+ });
132
+ };
133
+
134
+ return {
135
+ JSXText(node) {
136
+ const text = node.value ?? "";
137
+ if (CJK.test(text)) {
138
+ report(node, text);
139
+ }
140
+ },
141
+ JSXAttribute(node) {
142
+ if (!TEXT_PROPS.has(nameOf(node.name))) {
143
+ return;
144
+ }
145
+ report(node, chineseIn(node.value));
146
+ },
147
+ Property(node) {
148
+ if (!TEXT_PROPS.has(nameOf(node.key))) {
149
+ return;
150
+ }
151
+ report(node, chineseIn(node.value));
152
+ },
153
+ };
154
+ },
155
+ };
156
+
157
+ export default rule;
@@ -23,7 +23,7 @@ const rule = {
23
23
  schema: [],
24
24
  messages: {
25
25
  useFormRequired:
26
- "检测到表单场景使用了 useState/useSetState 管理字段值。请改用 Form.useForm() 或 rc-field-form 的 useForm。详见 node_modules/@ai0x0/utils/.agents/skills/antd/SKILL.md。",
26
+ "检测到表单场景使用了 useState/useSetState 管理字段值。请改用 Form.useForm()(ProForm 场景直接交给 ProForm 字段),不要手写受控状态。",
27
27
  },
28
28
  },
29
29
  create(context) {
@@ -39,11 +39,11 @@ function createPutActionCore({
39
39
  return async (body, { scope }) => {
40
40
  const id = body.id;
41
41
  if (!id) {
42
- throw new import_errors.HttpError(400, "缺少 id");
42
+ throw new import_errors.HttpError(400, (await (0, import_errors.httpErrorMessages)()).missingId);
43
43
  }
44
44
  const [data] = await db.update(table).set((0, import_transform_body.transformBody)(body)).where(byId(table, id, scope)).returning();
45
45
  if (!data && scope !== import_scope.NO_SCOPE) {
46
- throw new import_errors.HttpError(404, "未找到编辑对象,或没有权限");
46
+ throw new import_errors.HttpError(404, (await (0, import_errors.httpErrorMessages)()).editNotFound);
47
47
  }
48
48
  return data;
49
49
  };
@@ -54,11 +54,11 @@ function createDeleteActionCore({
54
54
  }) {
55
55
  return async ({ id }, { scope }) => {
56
56
  if (!id) {
57
- throw new import_errors.HttpError(400, "缺少 id");
57
+ throw new import_errors.HttpError(400, (await (0, import_errors.httpErrorMessages)()).missingId);
58
58
  }
59
59
  const rows = await db.delete(table).where(byId(table, id, scope)).returning();
60
60
  if (rows.length === 0 && scope !== import_scope.NO_SCOPE) {
61
- throw new import_errors.HttpError(404, "未找到删除对象,或没有权限");
61
+ throw new import_errors.HttpError(404, (await (0, import_errors.httpErrorMessages)()).deleteNotFound);
62
62
  }
63
63
  return rows;
64
64
  };
@@ -2,3 +2,33 @@ export declare class HttpError extends Error {
2
2
  status: number;
3
3
  constructor(status: number, message: string);
4
4
  }
5
+ export interface HttpErrorMessages {
6
+ /** 401:会话不存在。 */
7
+ unauthenticated: string;
8
+ /** 403:access.can 拒绝且没给自定义文案。 */
9
+ forbidden: string;
10
+ /** 400:PUT / DELETE 请求体缺 id。 */
11
+ missingId: string;
12
+ /** 404:PUT 影响 0 行(不存在或不属于调用者)。 */
13
+ editNotFound: string;
14
+ /** 404:DELETE 影响 0 行。 */
15
+ deleteNotFound: string;
16
+ /** 403:作用域值解析不出来 —— 多半是配错了列名,但请求照样要被拒绝。 */
17
+ scopeUnresolved: (column: string) => string;
18
+ }
19
+ export declare const defaultHttpErrorMessages: HttpErrorMessages;
20
+ type HttpErrorMessageResolver = () => Partial<HttpErrorMessages> | Promise<Partial<HttpErrorMessages>>;
21
+ /**
22
+ * 注册文案解析器,应用启动时调一次(再次调用覆盖;传 undefined 恢复默认)。
23
+ * 不注册就一直是 defaultHttpErrorMessages 那几句中文 —— 没做国际化的项目零感知。
24
+ */
25
+ export declare function setHttpErrorMessageResolver(next: HttpErrorMessageResolver | undefined): void;
26
+ /**
27
+ * 取出当前生效的整份文案。**在抛出点现调**,别在模块顶层缓存 —— 解析器是按请求判
28
+ * 语言的,缓存住就把上一个请求的语言漏给下一个。
29
+ *
30
+ * 解析器自己抛了错(比如语言包没加载到)就回落默认中文:报错文案出不来不该把
31
+ * 原本那个 401/403 也吞掉。
32
+ */
33
+ export declare function httpErrorMessages(): Promise<HttpErrorMessages>;
34
+ export {};
@@ -19,7 +19,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/backend/errors.ts
20
20
  var errors_exports = {};
21
21
  __export(errors_exports, {
22
- HttpError: () => HttpError
22
+ HttpError: () => HttpError,
23
+ defaultHttpErrorMessages: () => defaultHttpErrorMessages,
24
+ httpErrorMessages: () => httpErrorMessages,
25
+ setHttpErrorMessageResolver: () => setHttpErrorMessageResolver
23
26
  });
24
27
  module.exports = __toCommonJS(errors_exports);
25
28
  var HttpError = class extends Error {
@@ -29,7 +32,31 @@ var HttpError = class extends Error {
29
32
  this.status = status;
30
33
  }
31
34
  };
35
+ var defaultHttpErrorMessages = {
36
+ unauthenticated: "未登录",
37
+ forbidden: "没有权限",
38
+ missingId: "缺少 id",
39
+ editNotFound: "未找到编辑对象,或没有权限",
40
+ deleteNotFound: "未找到删除对象,或没有权限",
41
+ scopeUnresolved: (column) => `无法确定这次请求的归属(${column})。这不是「不做隔离」——请求被拒绝。`
42
+ };
43
+ var resolver;
44
+ function setHttpErrorMessageResolver(next) {
45
+ resolver = next;
46
+ }
47
+ async function httpErrorMessages() {
48
+ let overrides;
49
+ try {
50
+ overrides = await (resolver == null ? void 0 : resolver());
51
+ } catch {
52
+ overrides = void 0;
53
+ }
54
+ return { ...defaultHttpErrorMessages, ...overrides };
55
+ }
32
56
  // Annotate the CommonJS export names for ESM import in node:
33
57
  0 && (module.exports = {
34
- HttpError
58
+ HttpError,
59
+ defaultHttpErrorMessages,
60
+ httpErrorMessages,
61
+ setHttpErrorMessageResolver
35
62
  });
@@ -49,26 +49,29 @@ async function resolveAccess({
49
49
  const session = wantsScope || (access == null ? void 0 : access.can) || (access == null ? void 0 : access.stamp) ? await loadSession() : void 0;
50
50
  if (access == null ? void 0 : access.can) {
51
51
  if (!session) {
52
- throw new import_errors.HttpError(401, "未登录");
52
+ throw new import_errors.HttpError(401, (await (0, import_errors.httpErrorMessages)()).unauthenticated);
53
53
  }
54
54
  const verdict = await access.can({ method, session });
55
55
  if (verdict !== true) {
56
56
  const denial = typeof verdict === "object" ? verdict : {};
57
- throw new import_errors.HttpError(denial.status ?? 403, denial.message ?? "没有权限");
57
+ throw new import_errors.HttpError(
58
+ denial.status ?? 403,
59
+ denial.message ?? (await (0, import_errors.httpErrorMessages)()).forbidden
60
+ );
58
61
  }
59
62
  }
60
63
  if (!wantsScope) {
61
64
  return { scope: import_scope.NO_SCOPE, session };
62
65
  }
63
66
  if (!session) {
64
- throw new import_errors.HttpError(401, "未登录");
67
+ throw new import_errors.HttpError(401, (await (0, import_errors.httpErrorMessages)()).unauthenticated);
65
68
  }
66
69
  const column = ((_a = access == null ? void 0 : access.scope) == null ? void 0 : _a.column) ?? DEFAULT_SCOPE_COLUMN;
67
70
  const value = (access == null ? void 0 : access.scope) ? access.scope.value(session) : session.userId;
68
71
  if ((0, import_scope.isEmptyScopeValue)(value)) {
69
72
  throw new import_errors.HttpError(
70
73
  403,
71
- `无法确定这次请求的归属(${column})。这不是「不做隔离」——请求被拒绝。`
74
+ (await (0, import_errors.httpErrorMessages)()).scopeUnresolved(column)
72
75
  );
73
76
  }
74
77
  return { scope: { column, value }, session };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai0x0/utils",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "AI0x0 utils",
5
5
  "keywords": [
6
6
  "ai0x0"
@@ -1,131 +0,0 @@
1
- /**
2
- * require-pro-components
3
- * ----------------------
4
- * 约束前端业务代码:
5
- * 1. 禁止直接使用 antd <Modal>,必须用 @ant-design/pro-components 的 <ModalForm>
6
- * 2. 禁止直接使用 <Form.Item>,必须用 @ant-design/pro-components 的 ProForm* 组件
7
- * 3. ProForm* 组件必须设置 rules 进行校验,否则需要 eslint-disable 注明原因
8
- *
9
- * 例外场景:
10
- * - noStyle 的 Form.Item(仅作布局容器)
11
- * - ProForm 容器本身(不是字段组件)
12
- * - ProFormDependency、ProFormFieldSet、ProFormList 等非字段组件
13
- */
14
-
15
- const PROFORM_CONTAINER_COMPONENTS = new Set([
16
- "ProForm",
17
- "ProFormDependency",
18
- "ProFormFieldSet",
19
- "ProFormList",
20
- "ProFormGrid",
21
- "ProFormGroup",
22
- ]);
23
-
24
- /** @type {import('eslint').Rule.RuleModule} */
25
- const rule = {
26
- meta: {
27
- type: "suggestion",
28
- docs: {
29
- description:
30
- "禁止直接使用 antd <Modal>(用 ModalForm 代替),禁止直接使用 <Form.Item>(用 ProForm* 代替),ProForm* 必须设置 rules",
31
- },
32
- schema: [],
33
- messages: {
34
- useModalForm:
35
- "禁止直接使用 antd <Modal>,请使用 @ant-design/pro-components 的 <ModalForm> 代替。",
36
- useProFormItem:
37
- "禁止直接使用 <Form.Item>,请使用 @ant-design/pro-components 的 ProFormText / ProFormSelect / ProFormItem 等组件代替。",
38
- needProFormRules:
39
- "<{{tag}}> 必须设置 rules 进行校验。如果确实不需要校验,请用 // eslint-disable-next-line 注明原因。",
40
- noSubmitHtmlType:
41
- '禁止使用 <Button htmlType="submit">,请使用 ProForm 的 submitter 属性提交表单。',
42
- },
43
- },
44
- create(context) {
45
- const getTagName = (name) => {
46
- if (name.type === "JSXIdentifier") return name.name;
47
- if (
48
- name.type === "JSXMemberExpression" &&
49
- name.object.type === "JSXIdentifier" &&
50
- name.property.type === "JSXIdentifier"
51
- ) {
52
- return `${name.object.name}.${name.property.name}`;
53
- }
54
- return "";
55
- };
56
-
57
- return {
58
- JSXOpeningElement(node) {
59
- const tagName = getTagName(node.name);
60
-
61
- // <Modal>
62
- if (tagName === "Modal") {
63
- context.report({
64
- node: node,
65
- messageId: "useModalForm",
66
- });
67
- }
68
-
69
- // <Form.Item>
70
- if (tagName === "Form.Item") {
71
- let isNoStyle = false;
72
- for (const attr of node.attributes) {
73
- if (attr.type !== "JSXAttribute" || !attr.name) continue;
74
- if (attr.name.name === "noStyle") {
75
- isNoStyle = true;
76
- break;
77
- }
78
- }
79
- if (!isNoStyle) {
80
- context.report({
81
- node: node,
82
- messageId: "useProFormItem",
83
- });
84
- }
85
- }
86
-
87
- // <Button htmlType="submit">
88
- if (tagName === "Button") {
89
- for (const attr of node.attributes) {
90
- if (attr.type !== "JSXAttribute" || !attr.name) continue;
91
- if (
92
- attr.name.name === "htmlType" &&
93
- attr.value &&
94
- attr.value.type === "Literal" &&
95
- attr.value.value === "submit"
96
- ) {
97
- context.report({
98
- node: attr,
99
- messageId: "noSubmitHtmlType",
100
- });
101
- }
102
- }
103
- }
104
-
105
- // ProForm* field components must have rules
106
- if (
107
- tagName.startsWith("ProForm") &&
108
- !PROFORM_CONTAINER_COMPONENTS.has(tagName)
109
- ) {
110
- let hasRules = false;
111
- for (const attr of node.attributes) {
112
- if (attr.type !== "JSXAttribute" || !attr.name) continue;
113
- if (attr.name.name === "rules") {
114
- hasRules = true;
115
- break;
116
- }
117
- }
118
- if (!hasRules) {
119
- context.report({
120
- node: node,
121
- messageId: "needProFormRules",
122
- data: { tag: tagName },
123
- });
124
- }
125
- }
126
- },
127
- };
128
- },
129
- };
130
-
131
- export default rule;