@maiyunnet/kebab 9.19.0 → 9.20.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.
- package/doc/kebab-rag.md +792 -309
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/sql.d.ts +15 -0
- package/lib/sql.js +60 -1
- package/lib/text.d.ts +13 -0
- package/lib/text.js +36 -0
- package/package.json +1 -1
- package/sys/ctr.d.ts +28 -22
- package/sys/ctr.js +52 -57
- package/sys/mod.d.ts +32 -0
- package/sys/mod.js +83 -0
- package/sys/route.js +3 -1
- package/www/example/ctr/test.js +163 -1
package/index.d.ts
CHANGED
package/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* --- 本文件用来定义每个目录实体地址的常量 ---
|
|
7
7
|
*/
|
|
8
8
|
/** --- 当前系统版本号 --- */
|
|
9
|
-
export const VER = '9.
|
|
9
|
+
export const VER = '9.20.0';
|
|
10
10
|
// --- 服务端用的路径 ---
|
|
11
11
|
const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
|
|
12
12
|
/** --- /xxx/xxx --- */
|
package/lib/sql.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export declare enum ESERVICE {
|
|
|
10
10
|
'MYSQL' = 0,
|
|
11
11
|
'PGSQL' = 1
|
|
12
12
|
}
|
|
13
|
+
/** --- 非空游标值;大整数和日期请使用数据库可比较的字符串 --- */
|
|
14
|
+
export type TCursorValue = string | number;
|
|
13
15
|
/** --- JSON 查询操作符 --- */
|
|
14
16
|
export declare enum EJSON {
|
|
15
17
|
/** --- 包含值 (MySQL: JSON_CONTAINS, PG: @>) --- */
|
|
@@ -209,6 +211,15 @@ export declare class Sql {
|
|
|
209
211
|
* @param d 排序规则
|
|
210
212
|
*/
|
|
211
213
|
by(c: string | Array<string | string[]>, d?: 'DESC' | 'ASC'): this;
|
|
214
|
+
/**
|
|
215
|
+
* --- 按非空、同向排序字段定位游标并设置排序;字段组合须唯一且有匹配索引 ---
|
|
216
|
+
* --- 使用行比较,让 PostgreSQL 可以从复合索引边界开始扫描 ---
|
|
217
|
+
* @param c 排序字段,支持表别名;不支持表达式、GROUP BY 或 UNION
|
|
218
|
+
* @param values 上一页末行的字段值,省略时读取首批
|
|
219
|
+
* @param d 扫描方向;读取前一批时反转方向并在取回后反转列表
|
|
220
|
+
* @returns 当前 SQL 对象
|
|
221
|
+
*/
|
|
222
|
+
seek(c: string | string[], values?: TCursorValue[], d?: 'DESC' | 'ASC'): this;
|
|
212
223
|
/**
|
|
213
224
|
* --- GROUP BY ---
|
|
214
225
|
* @param c 字段字符串或数组
|
|
@@ -230,6 +241,10 @@ export declare class Sql {
|
|
|
230
241
|
*/
|
|
231
242
|
copy(f?: string | string[], opt?: {
|
|
232
243
|
'where'?: string | kebab.Json;
|
|
244
|
+
/** --- false 时移除本查询的排序,不修改子查询 --- */
|
|
245
|
+
'order'?: false;
|
|
246
|
+
/** --- false 时移除本查询的 LIMIT/OFFSET,不修改子查询 --- */
|
|
247
|
+
'limit'?: false;
|
|
233
248
|
}): Sql;
|
|
234
249
|
/**
|
|
235
250
|
* --- 获取 sql 语句 ---
|
package/lib/sql.js
CHANGED
|
@@ -55,6 +55,7 @@ export class Sql {
|
|
|
55
55
|
this._service = opt.service ?? ESERVICE.MYSQL;
|
|
56
56
|
if (opt.data) {
|
|
57
57
|
this._data = opt.data;
|
|
58
|
+
this._placeholderCounter = opt.data.length + 1;
|
|
58
59
|
}
|
|
59
60
|
if (opt.sql) {
|
|
60
61
|
this._sql = opt.sql;
|
|
@@ -770,6 +771,54 @@ export class Sql {
|
|
|
770
771
|
this._sql.push(sql);
|
|
771
772
|
return this;
|
|
772
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* --- 按非空、同向排序字段定位游标并设置排序;字段组合须唯一且有匹配索引 ---
|
|
776
|
+
* --- 使用行比较,让 PostgreSQL 可以从复合索引边界开始扫描 ---
|
|
777
|
+
* @param c 排序字段,支持表别名;不支持表达式、GROUP BY 或 UNION
|
|
778
|
+
* @param values 上一页末行的字段值,省略时读取首批
|
|
779
|
+
* @param d 扫描方向;读取前一批时反转方向并在取回后反转列表
|
|
780
|
+
* @returns 当前 SQL 对象
|
|
781
|
+
*/
|
|
782
|
+
seek(c, values, d = 'DESC') {
|
|
783
|
+
const fields = typeof c === 'string' ? [c] : c;
|
|
784
|
+
if (!fields.length || new Set(fields).size !== fields.length ||
|
|
785
|
+
fields.some(field => !/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(field)) ||
|
|
786
|
+
!['ASC', 'DESC'].includes(d)) {
|
|
787
|
+
throw new Error('Invalid cursor ordering');
|
|
788
|
+
}
|
|
789
|
+
if (this._sql.some(part => /^ (ORDER BY|LIMIT|GROUP BY|HAVING|UNION|FOR UPDATE)\b/i.test(part))) {
|
|
790
|
+
throw new Error('seek requires an ungrouped query without ordering or pagination');
|
|
791
|
+
}
|
|
792
|
+
if (values !== undefined) {
|
|
793
|
+
if (values.length !== fields.length || values.some(value => (typeof value !== 'string' && typeof value !== 'number') ||
|
|
794
|
+
(typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)))))) {
|
|
795
|
+
throw new Error('Invalid cursor values; use strings for large integers');
|
|
796
|
+
}
|
|
797
|
+
const columns = fields.map(field => this.field(field));
|
|
798
|
+
const placeholders = values.map(() => this._placeholder());
|
|
799
|
+
const left = columns.length === 1 ? columns[0] : `(${columns.join(', ')})`;
|
|
800
|
+
const right = placeholders.length === 1 ? placeholders[0] : `(${placeholders.join(', ')})`;
|
|
801
|
+
let condition = `${left} ${d === 'DESC' ? '<' : '>'} ${right}`;
|
|
802
|
+
if (columns.length > 1) {
|
|
803
|
+
// --- 显式收紧首列边界,避免已有较宽范围条件让 PG 在索引内遍历到行比较边界 ---
|
|
804
|
+
condition += ` AND ${columns[0]} ${d === 'DESC' ? '<=' : '>='} ${this._placeholder()}`;
|
|
805
|
+
}
|
|
806
|
+
const index = this._sql.findIndex(part => part.startsWith(' WHERE '));
|
|
807
|
+
if (index === -1) {
|
|
808
|
+
this._whereDataPosition[0] = this._data.length;
|
|
809
|
+
this._sql.push(' WHERE ' + condition);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
this._sql[index] = ` WHERE (${this._sql[index].slice(7)}) AND ${condition}`;
|
|
813
|
+
}
|
|
814
|
+
this._data.push(...values);
|
|
815
|
+
if (columns.length > 1) {
|
|
816
|
+
this._data.push(values[0]);
|
|
817
|
+
}
|
|
818
|
+
this._whereDataPosition[1] = this._data.length;
|
|
819
|
+
}
|
|
820
|
+
return this.by(fields, d);
|
|
821
|
+
}
|
|
773
822
|
/**
|
|
774
823
|
* --- GROUP BY ---
|
|
775
824
|
* @param c 字段字符串或数组
|
|
@@ -918,7 +967,13 @@ export class Sql {
|
|
|
918
967
|
sql[i] = sql[i].replace(/\$\d+/g, () => `$${counter++}`);
|
|
919
968
|
}
|
|
920
969
|
}
|
|
921
|
-
|
|
970
|
+
for (let i = sql.length - 1; i >= 0; --i) {
|
|
971
|
+
if ((opt.order === false && sql[i].startsWith(' ORDER BY ')) ||
|
|
972
|
+
(opt.limit === false && sql[i].startsWith(' LIMIT '))) {
|
|
973
|
+
sql.splice(i, 1);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const result = get({
|
|
922
977
|
'service': this._service,
|
|
923
978
|
'ctr': this._ctr,
|
|
924
979
|
'pre': this._pre,
|
|
@@ -926,6 +981,10 @@ export class Sql {
|
|
|
926
981
|
'sql': sql,
|
|
927
982
|
'alias': lCore.clone(this._alias),
|
|
928
983
|
}).hint(this._hint);
|
|
984
|
+
if (opt.where === undefined) {
|
|
985
|
+
result._whereDataPosition = [...this._whereDataPosition];
|
|
986
|
+
}
|
|
987
|
+
return result;
|
|
929
988
|
}
|
|
930
989
|
// --- 操作 ---
|
|
931
990
|
/**
|
package/lib/text.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import * as kebab from '#kebab/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* --- 解析三段正式发行版本 X.Y.Z,不接受前导零、预发行和构建后缀 ---
|
|
4
|
+
* @param value 待解析的版本
|
|
5
|
+
* @returns 三段安全整数,无效时返回 null
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseVersion(value: unknown): [number, number, number] | null;
|
|
8
|
+
/**
|
|
9
|
+
* --- 逐段比较三段正式发行版本 X.Y.Z,不按字符串或浮点数比较 ---
|
|
10
|
+
* @param version 待比较的版本
|
|
11
|
+
* @param target 目标版本
|
|
12
|
+
* @returns 小于返回 -1,等于返回 0,大于返回 1;任一版本无效时返回 null
|
|
13
|
+
*/
|
|
14
|
+
export declare function compareVersion(version: unknown, target: unknown): -1 | 0 | 1 | null;
|
|
2
15
|
/**
|
|
3
16
|
* --- 将文件大小格式化为带单位的字符串 ---
|
|
4
17
|
* @param size 文件大小
|
package/lib/text.js
CHANGED
|
@@ -7,6 +7,42 @@ import * as net from 'net';
|
|
|
7
7
|
import * as kebab from '#kebab/index.js';
|
|
8
8
|
import * as lFs from './fs.js';
|
|
9
9
|
import * as lCore from './core.js';
|
|
10
|
+
/**
|
|
11
|
+
* --- 解析三段正式发行版本 X.Y.Z,不接受前导零、预发行和构建后缀 ---
|
|
12
|
+
* @param value 待解析的版本
|
|
13
|
+
* @returns 三段安全整数,无效时返回 null
|
|
14
|
+
*/
|
|
15
|
+
export function parseVersion(value) {
|
|
16
|
+
if ((typeof value !== 'string') || (value.length > 64)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value);
|
|
20
|
+
// --- $ 可匹配末尾换行前的位置,必须额外确认匹配完整输入 ---
|
|
21
|
+
if (match?.[0] !== value) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
25
|
+
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* --- 逐段比较三段正式发行版本 X.Y.Z,不按字符串或浮点数比较 ---
|
|
29
|
+
* @param version 待比较的版本
|
|
30
|
+
* @param target 目标版本
|
|
31
|
+
* @returns 小于返回 -1,等于返回 0,大于返回 1;任一版本无效时返回 null
|
|
32
|
+
*/
|
|
33
|
+
export function compareVersion(version, target) {
|
|
34
|
+
const current = parseVersion(version);
|
|
35
|
+
const other = parseVersion(target);
|
|
36
|
+
if (!current || !other) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
for (let index = 0; index < current.length; index++) {
|
|
40
|
+
if (current[index] !== other[index]) {
|
|
41
|
+
return current[index] < other[index] ? -1 : 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
10
46
|
/**
|
|
11
47
|
* --- 将文件大小格式化为带单位的字符串 ---
|
|
12
48
|
* @param size 文件大小
|
package/package.json
CHANGED
package/sys/ctr.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export declare function clearLocaleData(): void;
|
|
|
19
19
|
export type TValibotResponse<TIssue extends v.BaseIssue<unknown>> = kebab.Json[] | ((issues: [TIssue, ...TIssue[]]) => kebab.Json[]);
|
|
20
20
|
/** --- 在 issue 路径完整后,根据 Kebab 生成的语言键翻译消息 --- */
|
|
21
21
|
export type TValibotTranslate = (key: string, issue: v.BaseIssue<unknown>) => string;
|
|
22
|
+
/** --- HTTP 重定向状态码 --- */
|
|
23
|
+
export type TRedirectHttpCode = 301 | 302 | 303 | 307 | 308;
|
|
22
24
|
/** --- Valibot 同步校验选项 --- */
|
|
23
25
|
export interface IValibotOptions<TIssue extends v.BaseIssue<unknown>> {
|
|
24
26
|
/** --- 传给 Valibot 的校验配置 --- */
|
|
@@ -58,6 +60,21 @@ export type TValibotResult<TSchema extends v.GenericSchema, TIssue extends v.Bas
|
|
|
58
60
|
readonly issues: [TIssue, ...TIssue[]];
|
|
59
61
|
readonly response: kebab.Json[];
|
|
60
62
|
};
|
|
63
|
+
/** --- React SSR 页面选项 --- */
|
|
64
|
+
export interface IReactPageOptions {
|
|
65
|
+
/** --- 是否注入客户端水合 bundle,默认 true;设为 false 时仅输出服务端渲染的 HTML --- */
|
|
66
|
+
'hydrate'?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* --- 路由模式,不传则不注入任何 Router,组件自行管理路由(如 MemoryRouter)或无路由 ---
|
|
69
|
+
* --- 'browser':服务端用 StaticRouter,客户端用 BrowserRouter,地址栏与路由联动 ---
|
|
70
|
+
* --- 组件本身只需使用 Routes/Route/Link 等,不要包含任何 Router 包裹层 ---
|
|
71
|
+
*/
|
|
72
|
+
'router'?: 'browser';
|
|
73
|
+
/** --- BrowserRouter 的 basename,相对于 urlBase,默认空字符串 --- */
|
|
74
|
+
'routerBase'?: string;
|
|
75
|
+
/** --- 静态资源基础路径,覆盖 config.set.staticPath(支持 CDN) --- */
|
|
76
|
+
'staticPath'?: string;
|
|
77
|
+
}
|
|
61
78
|
/**
|
|
62
79
|
* --- 判断当前 HTTP 请求是否仍可响应 ---
|
|
63
80
|
* @param req 请求对象
|
|
@@ -219,26 +236,14 @@ export declare class Ctr {
|
|
|
219
236
|
* @param props 传入组件的 props,框架常量自动合并,整体序列化为内联 JSON 供客户端水合复用
|
|
220
237
|
* @param opt 可选配置
|
|
221
238
|
*/
|
|
222
|
-
protected _loadReactPage(path: string, props?: Record<string, kebab.Json>, opt?:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
'router'?: 'browser';
|
|
231
|
-
/**
|
|
232
|
-
* --- BrowserRouter 的 basename,相对于 urlBase,默认空字符串 ---
|
|
233
|
-
* --- 例如组件挂载在 /test/react-router-page,则填 'test/react-router-page' ---
|
|
234
|
-
*/
|
|
235
|
-
'routerBase'?: string;
|
|
236
|
-
/**
|
|
237
|
-
* --- 静态资源基础路径,覆盖 config.set.staticPath,用于指定 CDN 或自定义路径 ---
|
|
238
|
-
* --- 影响 _staticPath prop 以及水合脚本中 JS 文件的 URL 前缀 ---
|
|
239
|
-
*/
|
|
240
|
-
'staticPath'?: string;
|
|
241
|
-
}): Promise<string>;
|
|
239
|
+
protected _loadReactPage(path: string, props?: Record<string, kebab.Json>, opt?: IReactPageOptions): Promise<string>;
|
|
240
|
+
/**
|
|
241
|
+
* --- 记录 React 页面错误并返回脱敏响应 ---
|
|
242
|
+
* @param message 调试信息
|
|
243
|
+
* @param detail 日志详情
|
|
244
|
+
* @returns 脱敏后的 500 页面
|
|
245
|
+
*/
|
|
246
|
+
private _reactPageError;
|
|
242
247
|
/**
|
|
243
248
|
* --- 设置校验错误返回值 ---
|
|
244
249
|
* @param rtn 返回值数组
|
|
@@ -333,10 +338,11 @@ export declare class Ctr {
|
|
|
333
338
|
*/
|
|
334
339
|
protected _loadData<T>(path: string): Promise<T | false | null>;
|
|
335
340
|
/**
|
|
336
|
-
* ---
|
|
341
|
+
* --- 跳转,支持相对本项目根路径的路径或绝对路径 ---
|
|
337
342
|
* @param location 相对或绝对网址
|
|
343
|
+
* @param httpCode HTTP 重定向状态码,默认 302
|
|
338
344
|
*/
|
|
339
|
-
protected _location(location: string): false;
|
|
345
|
+
protected _location(location: string, httpCode?: TRedirectHttpCode): false;
|
|
340
346
|
/**
|
|
341
347
|
* --- 开启 Session ---
|
|
342
348
|
* @param link Kv 或 Db 实例
|
package/sys/ctr.js
CHANGED
|
@@ -271,35 +271,28 @@ export class Ctr {
|
|
|
271
271
|
* @param opt 可选配置
|
|
272
272
|
*/
|
|
273
273
|
async _loadReactPage(path, props = {}, opt = {}) {
|
|
274
|
-
// ---
|
|
275
|
-
|
|
276
|
-
const
|
|
274
|
+
// --- 校验服务端组件与客户端水合产物 ---
|
|
275
|
+
const hydrate = opt.hydrate !== false;
|
|
276
|
+
const pagePath = `${this._config.const.rootPath}stc/${path}.page`;
|
|
277
|
+
const componentPath = `${pagePath}.js`;
|
|
277
278
|
if (!await lFs.isFile(componentPath)) {
|
|
278
|
-
|
|
279
|
-
lCore.log(this, `[CTR][_loadReactPage] componentPath ${componentPath} is not a file`, '-error');
|
|
280
|
-
return '';
|
|
279
|
+
return this._reactPageError(`componentPath ${componentPath} is not a file`);
|
|
281
280
|
}
|
|
282
|
-
const bundlePath =
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
const message = `[CTR][_loadReactPage] bundlePath ${bundlePath} is not a file; ` +
|
|
286
|
-
`run "npx kebab build -d ${this._config.const.rootPath}stc"`;
|
|
287
|
-
lCore.debug(message);
|
|
288
|
-
lCore.log(this, message, '-error');
|
|
289
|
-
this._httpCode = 500;
|
|
290
|
-
return '<h1>500 Server Error</h1><hr>Kebab';
|
|
281
|
+
const bundlePath = `${pagePath}.bundle.js`;
|
|
282
|
+
if (hydrate && !await lFs.isFile(bundlePath)) {
|
|
283
|
+
return this._reactPageError(`bundlePath ${bundlePath} is not a file; run "npx kebab build -d ${this._config.const.rootPath}stc"`);
|
|
291
284
|
}
|
|
292
285
|
try {
|
|
293
|
-
|
|
286
|
+
// --- 并行加载 SSR 依赖与页面组件 ---
|
|
294
287
|
const importPath = componentPath.startsWith('/')
|
|
295
288
|
? componentPath
|
|
296
289
|
: `file:///${componentPath.replace(/\\/g, '/')}`;
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// ---
|
|
290
|
+
const [react, reactDomServer, page] = await Promise.all([
|
|
291
|
+
import('react'),
|
|
292
|
+
import('react-dom/server'),
|
|
293
|
+
import(importPath),
|
|
294
|
+
]);
|
|
295
|
+
// --- 合并框架 Props 与路由配置 ---
|
|
303
296
|
const staticPath = opt.staticPath ?? this._config.set.staticPath;
|
|
304
297
|
const fullProps = {
|
|
305
298
|
...props,
|
|
@@ -310,50 +303,50 @@ export class Ctr {
|
|
|
310
303
|
'_staticPath': staticPath,
|
|
311
304
|
'_staticPathFull': this._config.set.staticPathFull,
|
|
312
305
|
'_locale': this._locale,
|
|
313
|
-
'_localeData':
|
|
306
|
+
'_localeData': { ...(this._localeData[this._locale] ?? {}) },
|
|
314
307
|
};
|
|
315
308
|
if (opt.router === 'browser') {
|
|
316
|
-
|
|
317
|
-
const base = opt.routerBase ?? '';
|
|
318
|
-
const routerBase = this._config.const.urlBase + base.replace(/^\//, '');
|
|
309
|
+
const routerBase = `${this._config.const.urlBase}${(opt.routerBase ?? '').replace(/^\//, '')}`;
|
|
319
310
|
fullProps['_routerBase'] = routerBase.replace(/\/$/, '');
|
|
320
311
|
}
|
|
321
|
-
// ---
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const propsJson = lText.stringifyJson(fullProps).replace(/<\/script>/gi, '<\\/script>');
|
|
326
|
-
const clientUrl = `${staticPath}${path}.page.bundle.js?v=${this._config.set.staticVer}`;
|
|
327
|
-
// --- props JSON + 水合脚本注入到 </body> 前 ---
|
|
328
|
-
bodyInject =
|
|
329
|
-
`<script id="__kebab_props__" type="application/json">${propsJson}</script>` +
|
|
330
|
-
`<script type="module">import'${clientUrl}';</script>`;
|
|
331
|
-
}
|
|
332
|
-
// --- BrowserRouter 模式:服务端用 StaticRouter 渲染,与客户端的 BrowserRouter 等价 ---
|
|
333
|
-
// --- component 来自动态 import,TypeScript 无法精确推断,需要明确限定 element 类型 ---
|
|
334
|
-
let element = react.createElement(component, fullProps);
|
|
312
|
+
// --- 创建页面元素,BrowserRouter 模式下由 StaticRouter 包裹 ---
|
|
313
|
+
const component = page.default;
|
|
314
|
+
const pageElement = react.createElement(component, fullProps);
|
|
315
|
+
let element = pageElement;
|
|
335
316
|
if (opt.router === 'browser') {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
element = react.createElement(lReactRouter.StaticRouter, {
|
|
340
|
-
'location': reqUrl,
|
|
317
|
+
const reactRouter = await import('react-router-dom');
|
|
318
|
+
element = react.createElement(reactRouter.StaticRouter, {
|
|
319
|
+
'location': this._req.url ?? '/',
|
|
341
320
|
'basename': fullProps['_routerBase'],
|
|
342
|
-
},
|
|
321
|
+
}, pageElement);
|
|
343
322
|
}
|
|
344
|
-
// ---
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
|
|
323
|
+
// --- 渲染完整 HTML,并按需注入客户端水合数据与 bundle ---
|
|
324
|
+
const html = `<!DOCTYPE html>${reactDomServer.renderToString(element)}`;
|
|
325
|
+
if (!hydrate) {
|
|
326
|
+
return html;
|
|
348
327
|
}
|
|
349
|
-
|
|
328
|
+
const propsJson = lText.stringifyJson(fullProps).replace(/<\/script>/gi, '<\\/script>');
|
|
329
|
+
const clientUrl = `${staticPath}${path}.page.bundle.js?v=${this._config.set.staticVer}`;
|
|
330
|
+
const propsScript = `<script id="__kebab_props__" type="application/json">${propsJson}</script>`;
|
|
331
|
+
const hydrateScript = `<script type="module">import'${clientUrl}';</script>`;
|
|
332
|
+
return html.replace('</body>', `${propsScript}${hydrateScript}</body>`);
|
|
350
333
|
}
|
|
351
334
|
catch (e) {
|
|
352
|
-
|
|
353
|
-
lCore.log(this, '[CTR][_loadReactPage] ' + lText.stringifyError(e), '-error');
|
|
354
|
-
return '';
|
|
335
|
+
return this._reactPageError(e.message ?? '', lText.stringifyError(e));
|
|
355
336
|
}
|
|
356
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* --- 记录 React 页面错误并返回脱敏响应 ---
|
|
340
|
+
* @param message 调试信息
|
|
341
|
+
* @param detail 日志详情
|
|
342
|
+
* @returns 脱敏后的 500 页面
|
|
343
|
+
*/
|
|
344
|
+
_reactPageError(message, detail = message) {
|
|
345
|
+
lCore.debug(`[CTR][_loadReactPage] ${message}`);
|
|
346
|
+
lCore.log(this, `[CTR][_loadReactPage] ${detail}`, '-error');
|
|
347
|
+
this._httpCode = 500;
|
|
348
|
+
return '<h1>500 Server Error</h1><hr>Kebab';
|
|
349
|
+
}
|
|
357
350
|
/**
|
|
358
351
|
* --- 设置校验错误返回值 ---
|
|
359
352
|
* @param rtn 返回值数组
|
|
@@ -783,13 +776,15 @@ export class Ctr {
|
|
|
783
776
|
return json;
|
|
784
777
|
}
|
|
785
778
|
/**
|
|
786
|
-
* ---
|
|
779
|
+
* --- 跳转,支持相对本项目根路径的路径或绝对路径 ---
|
|
787
780
|
* @param location 相对或绝对网址
|
|
781
|
+
* @param httpCode HTTP 重定向状态码,默认 302
|
|
788
782
|
*/
|
|
789
|
-
_location(location) {
|
|
783
|
+
_location(location, httpCode = 302) {
|
|
784
|
+
this._httpCode = httpCode;
|
|
790
785
|
if (this._res) {
|
|
791
786
|
this._res.setHeader('location', lText.urlResolve(this._config.const.urlBase, location));
|
|
792
|
-
// this._res.writeHead(
|
|
787
|
+
// this._res.writeHead(httpCode); Kebab 中要在最后设置,否则会报错:ERR_HTTP_HEADERS_SENT
|
|
793
788
|
}
|
|
794
789
|
return false;
|
|
795
790
|
}
|
package/sys/mod.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ import * as kebab from '#kebab/index.js';
|
|
|
11
11
|
export type TOnlyProperties<T> = {
|
|
12
12
|
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
|
|
13
13
|
};
|
|
14
|
+
/** --- 一批游标查询结果,hasMore 表示扫描方向上还有记录 --- */
|
|
15
|
+
export interface ICursorPage<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
16
|
+
'list': T[];
|
|
17
|
+
'hasMore': boolean;
|
|
18
|
+
'next': lSql.TCursorValue[] | null;
|
|
19
|
+
'previous': lSql.TCursorValue[] | null;
|
|
20
|
+
}
|
|
14
21
|
/** --- 条数列表 --- */
|
|
15
22
|
export declare class Rows<T extends Mod> implements IRows<T> {
|
|
16
23
|
private readonly _items;
|
|
@@ -375,6 +382,31 @@ export default class Mod {
|
|
|
375
382
|
* --- 获取总条数,自动抛弃 LIMIT,仅用于获取数据的情况(select) ---
|
|
376
383
|
*/
|
|
377
384
|
total(f?: string): Promise<number>;
|
|
385
|
+
/**
|
|
386
|
+
* --- 从 PostgreSQL 执行计划估算查询总行数,移除外层排序和分页,不执行数据扫描 ---
|
|
387
|
+
* --- 请在应用游标条件前调用;非 PostgreSQL、框架分表联查或查询失败返回 false,绝不回退到 COUNT ---
|
|
388
|
+
* @returns 近似行数或 false;估算可能偏差很大,不能用于判断有无下一批
|
|
389
|
+
*/
|
|
390
|
+
totalEstimate(): Promise<number | false>;
|
|
391
|
+
/**
|
|
392
|
+
* --- 读取一批游标数据,多取一条判断后续记录,不使用 OFFSET,不修改原查询 ---
|
|
393
|
+
* --- 排序字段必须全部非空、同向,且组合唯一;SELECT 中须包含对应字段(同名或指定 keys) ---
|
|
394
|
+
* @param count 本批数量
|
|
395
|
+
* @param opt 排序、边界和方向;before 为 true 时读取前一批并恢复展示顺序
|
|
396
|
+
* @param opt.by 排序字段,支持表别名
|
|
397
|
+
* @param opt.keys SELECT 结果中对应的字段名,默认去掉 by 的表别名
|
|
398
|
+
* @param opt.order 展示顺序,默认 DESC
|
|
399
|
+
* @param opt.cursor 边界行的字段值,首批省略
|
|
400
|
+
* @param opt.before 是否读取边界前一批,须提供 cursor
|
|
401
|
+
* @returns 列表、首末行游标和扫描方向上的 hasMore,数据库错误返回 false
|
|
402
|
+
*/
|
|
403
|
+
allCursor<T extends Record<string, unknown> = Record<string, unknown>>(count: number, opt: {
|
|
404
|
+
'by': string | string[];
|
|
405
|
+
'keys'?: string[];
|
|
406
|
+
'order'?: 'DESC' | 'ASC';
|
|
407
|
+
'cursor'?: lSql.TCursorValue[];
|
|
408
|
+
'before'?: boolean;
|
|
409
|
+
}): Promise<ICursorPage<T> | false>;
|
|
378
410
|
/**
|
|
379
411
|
* --- 根据当前条件,筛选出当前条目该有的数据条数 ---
|
|
380
412
|
*/
|
package/sys/mod.js
CHANGED
|
@@ -1262,6 +1262,89 @@ export default class Mod {
|
|
|
1262
1262
|
}
|
|
1263
1263
|
return count;
|
|
1264
1264
|
}
|
|
1265
|
+
/**
|
|
1266
|
+
* --- 从 PostgreSQL 执行计划估算查询总行数,移除外层排序和分页,不执行数据扫描 ---
|
|
1267
|
+
* --- 请在应用游标条件前调用;非 PostgreSQL、框架分表联查或查询失败返回 false,绝不回退到 COUNT ---
|
|
1268
|
+
* @returns 近似行数或 false;估算可能偏差很大,不能用于判断有无下一批
|
|
1269
|
+
*/
|
|
1270
|
+
async totalEstimate() {
|
|
1271
|
+
if (this._db.getService() !== lDb.ESERVICE.PGSQL || this._index !== null || this._contain) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
const sql = this._sql.copy(undefined, { 'order': false, 'limit': false });
|
|
1275
|
+
const result = await this._db.query('EXPLAIN (FORMAT JSON) ' + sql.getSql(), sql.getData());
|
|
1276
|
+
if (result.rows === null) {
|
|
1277
|
+
lCore.log(this._ctr ?? {}, '[MOD][totalEstimate] Query failed', '-error');
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
const plan = result.rows[0]?.['QUERY PLAN'];
|
|
1281
|
+
const parsed = typeof plan === 'string' ? lText.parseJson(plan) : plan;
|
|
1282
|
+
if (!Array.isArray(parsed)) {
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
const root = parsed[0];
|
|
1286
|
+
if (!root || typeof root !== 'object') {
|
|
1287
|
+
return false;
|
|
1288
|
+
}
|
|
1289
|
+
const node = root['Plan'];
|
|
1290
|
+
if (!node || typeof node !== 'object') {
|
|
1291
|
+
return false;
|
|
1292
|
+
}
|
|
1293
|
+
const rows = node['Plan Rows'];
|
|
1294
|
+
return typeof rows === 'number' && Number.isFinite(rows) && rows >= 0 ? Math.round(rows) : false;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* --- 读取一批游标数据,多取一条判断后续记录,不使用 OFFSET,不修改原查询 ---
|
|
1298
|
+
* --- 排序字段必须全部非空、同向,且组合唯一;SELECT 中须包含对应字段(同名或指定 keys) ---
|
|
1299
|
+
* @param count 本批数量
|
|
1300
|
+
* @param opt 排序、边界和方向;before 为 true 时读取前一批并恢复展示顺序
|
|
1301
|
+
* @param opt.by 排序字段,支持表别名
|
|
1302
|
+
* @param opt.keys SELECT 结果中对应的字段名,默认去掉 by 的表别名
|
|
1303
|
+
* @param opt.order 展示顺序,默认 DESC
|
|
1304
|
+
* @param opt.cursor 边界行的字段值,首批省略
|
|
1305
|
+
* @param opt.before 是否读取边界前一批,须提供 cursor
|
|
1306
|
+
* @returns 列表、首末行游标和扫描方向上的 hasMore,数据库错误返回 false
|
|
1307
|
+
*/
|
|
1308
|
+
async allCursor(count, opt) {
|
|
1309
|
+
if (!Number.isSafeInteger(count) || count < 1 || count >= Number.MAX_SAFE_INTEGER ||
|
|
1310
|
+
this._index !== null || this._contain || (opt.before && !opt.cursor) ||
|
|
1311
|
+
(opt.order !== undefined && !['ASC', 'DESC'].includes(opt.order))) {
|
|
1312
|
+
throw new Error('Invalid cursor page options; framework sharding and contain are unsupported');
|
|
1313
|
+
}
|
|
1314
|
+
const fields = typeof opt.by === 'string' ? [opt.by] : opt.by;
|
|
1315
|
+
const keys = opt.keys ?? fields.map(field => field.split('.').pop());
|
|
1316
|
+
if (keys.length !== fields.length || new Set(keys).size !== keys.length) {
|
|
1317
|
+
throw new Error('Cursor result keys must match ordering fields');
|
|
1318
|
+
}
|
|
1319
|
+
const order = opt.order ?? 'DESC';
|
|
1320
|
+
const direction = opt.before ? (order === 'DESC' ? 'ASC' : 'DESC') : order;
|
|
1321
|
+
const sql = this._sql.copy(undefined, { 'order': false, 'limit': false });
|
|
1322
|
+
sql.seek(fields, opt.cursor, direction).limit(count + 1);
|
|
1323
|
+
const result = await this._db.query(sql.getSql(), sql.getData());
|
|
1324
|
+
if (result.rows === null) {
|
|
1325
|
+
lCore.log(this._ctr ?? {}, '[MOD][allCursor] Query failed', '-error');
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
const hasMore = result.rows.length > count;
|
|
1329
|
+
const list = result.rows.slice(0, count);
|
|
1330
|
+
if (opt.before) {
|
|
1331
|
+
list.reverse();
|
|
1332
|
+
}
|
|
1333
|
+
const boundary = (row) => keys.map(key => {
|
|
1334
|
+
const value = row[key];
|
|
1335
|
+
if ((typeof value !== 'string' && typeof value !== 'number') ||
|
|
1336
|
+
(typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) {
|
|
1337
|
+
throw new Error('Cursor columns must be non-null strings or safe numbers');
|
|
1338
|
+
}
|
|
1339
|
+
return value;
|
|
1340
|
+
});
|
|
1341
|
+
return {
|
|
1342
|
+
'list': list,
|
|
1343
|
+
'hasMore': hasMore,
|
|
1344
|
+
'next': list.length ? boundary(list[list.length - 1]) : null,
|
|
1345
|
+
'previous': list.length ? boundary(list[0]) : null,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1265
1348
|
/**
|
|
1266
1349
|
* --- 根据当前条件,筛选出当前条目该有的数据条数 ---
|
|
1267
1350
|
*/
|
package/sys/route.js
CHANGED
|
@@ -581,6 +581,8 @@ export async function run(data) {
|
|
|
581
581
|
data.res.setHeader('expires', new Date(Date.now() + cacheTTL * 1_000).toUTCString());
|
|
582
582
|
data.res.setHeader('cache-control', 'max-age=' + cacheTTL.toString());
|
|
583
583
|
}
|
|
584
|
+
// --- 记录控制器是否显式设置状态码;未设置时,带 Location 的空响应仍兼容为 302 ---
|
|
585
|
+
const hasCustomHttpCode = httpCode !== 0;
|
|
584
586
|
// --- 设置自定义 hcode ---
|
|
585
587
|
if (httpCode === 0) {
|
|
586
588
|
httpCode = 200;
|
|
@@ -591,7 +593,7 @@ export async function run(data) {
|
|
|
591
593
|
// --- 已经自行输出过 writeHead,可能自行处理了内容,如 pipe,则不再 writeHead ---
|
|
592
594
|
}
|
|
593
595
|
else {
|
|
594
|
-
lCore.writeHead(data.res, data.res.getHeader('location') ? 302 : httpCode);
|
|
596
|
+
lCore.writeHead(data.res, data.res.getHeader('location') && !hasCustomHttpCode ? 302 : httpCode);
|
|
595
597
|
}
|
|
596
598
|
if (!data.res.writableEnded) {
|
|
597
599
|
// --- 如果当前还没结束,则强制关闭连接,一切 pipe 请自行在方法中 await,否则会被中断 ---
|