@pylonts/dsl 1.1.15 → 1.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.
- package/dist/convert.d.ts +4 -5
- package/dist/dto.d.ts +13 -0
- package/dist/dto.js +28 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mysql-driver.js +3 -3
- package/dist/service.js +3 -0
- package/dist/third-service.d.ts +58 -2
- package/dist/third-service.js +28 -0
- package/dist/token.d.ts +39 -0
- package/dist/token.js +96 -0
- package/dist/typebox-driver.d.ts +13 -0
- package/dist/typebox-driver.js +214 -33
- package/docs/curd.md +150 -146
- package/docs/gen-login.md +136 -0
- package/docs/third-service.md +201 -151
- package/docs/token-migration.md +60 -0
- package/docs/token.md +341 -327
- package/docs/wechat.md +235 -0
- package/package.json +2 -2
- package/src/convert.ts +9 -9
- package/src/dto.ts +364 -331
- package/src/index.ts +1 -0
- package/src/mysql-driver.ts +108 -108
- package/src/service.ts +5 -0
- package/src/third-service.ts +86 -2
- package/src/token.ts +139 -0
- package/src/typebox-driver.ts +425 -234
package/dist/typebox-driver.js
CHANGED
|
@@ -8,11 +8,35 @@ function renderDefault(v) {
|
|
|
8
8
|
return renderString(v);
|
|
9
9
|
return JSON.stringify(v);
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/** BaseField-level description: description takes priority over label. */
|
|
12
|
+
function fieldDescription(field) {
|
|
13
|
+
return field.description ?? field.label;
|
|
14
|
+
}
|
|
15
|
+
/** DtoField-level description: own description > referenced field description/label. */
|
|
16
|
+
function dtoFieldDescription(f) {
|
|
17
|
+
return f.description ?? fieldDescription(f.field);
|
|
18
|
+
}
|
|
19
|
+
/** Resolve a ref chain to its terminal field (the one without .ref).
|
|
20
|
+
* Cycles are a DSL definition error — fail loudly at render time. */
|
|
21
|
+
function resolveRefChain(f) {
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
let cur = f;
|
|
24
|
+
while (cur.ref !== undefined) {
|
|
25
|
+
if (seen.has(cur.ref)) {
|
|
26
|
+
throw new Error(`dto field ${cur.name}: circular ref chain (field references itself)`);
|
|
27
|
+
}
|
|
28
|
+
seen.add(cur.ref);
|
|
29
|
+
cur = cur.ref;
|
|
30
|
+
}
|
|
31
|
+
return cur;
|
|
32
|
+
}
|
|
33
|
+
function renderBasic(field, pattern, defaultValue, resolver, indent = 0, description) {
|
|
12
34
|
if (pattern !== undefined && field.type !== 'string') {
|
|
13
35
|
throw new Error(`pattern is only supported on string fields, got ${field.type} (${field.name})`);
|
|
14
36
|
}
|
|
15
37
|
const def = defaultValue !== undefined ? `default: ${renderDefault(defaultValue)}` : undefined;
|
|
38
|
+
const desc = description !== undefined ? `description: ${renderString(description)}` : undefined;
|
|
39
|
+
const withOpts = (base, opts) => opts.length > 0 ? `${base}({ ${opts.join(', ')} })` : `${base}()`;
|
|
16
40
|
switch (field.type) {
|
|
17
41
|
case 'string': {
|
|
18
42
|
const opts = [];
|
|
@@ -24,10 +48,18 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
|
|
|
24
48
|
opts.push(`pattern: ${renderString(pattern)}`);
|
|
25
49
|
if (def !== undefined)
|
|
26
50
|
opts.push(def);
|
|
27
|
-
|
|
51
|
+
if (desc !== undefined)
|
|
52
|
+
opts.push(desc);
|
|
53
|
+
return withOpts('Type.String', opts);
|
|
54
|
+
}
|
|
55
|
+
case 'text': {
|
|
56
|
+
const opts = [];
|
|
57
|
+
if (def !== undefined)
|
|
58
|
+
opts.push(def);
|
|
59
|
+
if (desc !== undefined)
|
|
60
|
+
opts.push(desc);
|
|
61
|
+
return withOpts('Type.String', opts);
|
|
28
62
|
}
|
|
29
|
-
case 'text':
|
|
30
|
-
return def !== undefined ? `Type.String({ ${def} })` : 'Type.String()';
|
|
31
63
|
case 'integer': {
|
|
32
64
|
const opts = [];
|
|
33
65
|
if (field.min !== undefined)
|
|
@@ -36,21 +68,41 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
|
|
|
36
68
|
opts.push(`maximum: ${field.max}`);
|
|
37
69
|
if (def !== undefined)
|
|
38
70
|
opts.push(def);
|
|
39
|
-
|
|
71
|
+
if (desc !== undefined)
|
|
72
|
+
opts.push(desc);
|
|
73
|
+
return withOpts('Type.Integer', opts);
|
|
40
74
|
}
|
|
41
75
|
case 'bigint':
|
|
42
76
|
case 'decimal':
|
|
43
77
|
case 'rate':
|
|
44
78
|
case 'time':
|
|
45
79
|
case 'date':
|
|
46
|
-
case 'datetime':
|
|
80
|
+
case 'datetime': {
|
|
47
81
|
// Transmitted as string over HTTP: bigint/decimal/rate keep full
|
|
48
82
|
// precision, date/time serialize to string.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
const opts = [];
|
|
84
|
+
if (def !== undefined)
|
|
85
|
+
opts.push(def);
|
|
86
|
+
if (desc !== undefined)
|
|
87
|
+
opts.push(desc);
|
|
88
|
+
return withOpts('Type.String', opts);
|
|
89
|
+
}
|
|
90
|
+
case 'boolean': {
|
|
91
|
+
const opts = [];
|
|
92
|
+
if (def !== undefined)
|
|
93
|
+
opts.push(def);
|
|
94
|
+
if (desc !== undefined)
|
|
95
|
+
opts.push(desc);
|
|
96
|
+
return withOpts('Type.Boolean', opts);
|
|
97
|
+
}
|
|
98
|
+
case 'json': {
|
|
99
|
+
const opts = [];
|
|
100
|
+
if (def !== undefined)
|
|
101
|
+
opts.push(def);
|
|
102
|
+
if (desc !== undefined)
|
|
103
|
+
opts.push(desc);
|
|
104
|
+
return withOpts('Type.Unknown', opts);
|
|
105
|
+
}
|
|
54
106
|
case 'enum': {
|
|
55
107
|
const ref = resolver?.(field.enum.jsName);
|
|
56
108
|
if (!ref)
|
|
@@ -60,18 +112,29 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
|
|
|
60
112
|
if (!member) {
|
|
61
113
|
throw new Error(`enum field ${field.name}: default ${renderDefault(defaultValue)} is not a member of ${field.enum.jsName}`);
|
|
62
114
|
}
|
|
63
|
-
|
|
115
|
+
const opts = [];
|
|
116
|
+
if (desc !== undefined)
|
|
117
|
+
opts.push(desc);
|
|
118
|
+
return `Type.Enum(${ref.name}, { default: ${ref.name}.${member.symbol}${opts.length > 0 ? `, ${opts.join(', ')}` : ''} })`;
|
|
64
119
|
}
|
|
120
|
+
if (desc !== undefined)
|
|
121
|
+
return `Type.Enum(${ref.name}, { ${desc} })`;
|
|
65
122
|
return `Type.Enum(${ref.name})`;
|
|
66
123
|
}
|
|
67
|
-
case 'aggregate':
|
|
124
|
+
case 'aggregate': {
|
|
68
125
|
// Aggregate query outputs: count/sum(int) are numbers, everything else
|
|
69
126
|
// arrives as a precision string.
|
|
70
|
-
|
|
127
|
+
const opts = [];
|
|
128
|
+
if (desc !== undefined)
|
|
129
|
+
opts.push(desc);
|
|
130
|
+
return field.jsType === 'number' ? withOpts('Type.Number', opts) : withOpts('Type.String', opts);
|
|
131
|
+
}
|
|
71
132
|
case 'array':
|
|
72
|
-
return
|
|
133
|
+
return desc !== undefined
|
|
134
|
+
? `Type.Array(${renderFieldValue(field.items, indent, resolver)}, { ${desc} })`
|
|
135
|
+
: `Type.Array(${renderFieldValue(field.items, indent, resolver)})`;
|
|
73
136
|
case 'object':
|
|
74
|
-
return renderFieldObject(field.properties, indent + 1, resolver);
|
|
137
|
+
return renderFieldObject(field.properties, indent + 1, resolver, description);
|
|
75
138
|
default:
|
|
76
139
|
// Field union is exhaustive; this branch is unreachable at runtime.
|
|
77
140
|
throw new Error(`unsupported field type: ${String(field.type)}`);
|
|
@@ -79,46 +142,92 @@ function renderBasic(field, pattern, defaultValue, resolver, indent = 0) {
|
|
|
79
142
|
}
|
|
80
143
|
/** Render a plain Field value (wire-format nested fields), wrapping optional. */
|
|
81
144
|
function renderFieldValue(field, indent, resolver) {
|
|
82
|
-
const base = renderBasic(field, undefined, undefined, resolver, indent);
|
|
145
|
+
const base = renderBasic(field, undefined, undefined, resolver, indent, fieldDescription(field));
|
|
83
146
|
return field.optional ? `Type.Optional(${base})` : base;
|
|
84
147
|
}
|
|
85
148
|
/** Render a plain Field object (wire-format nested object). */
|
|
86
|
-
function renderFieldObject(properties, indent, resolver) {
|
|
149
|
+
function renderFieldObject(properties, indent, resolver, description) {
|
|
87
150
|
const pad = ' '.repeat(indent);
|
|
88
151
|
const entries = Object.entries(properties).map(([name, f]) => `${pad}${name}: ${renderFieldValue(f, indent, resolver)}`);
|
|
89
|
-
|
|
152
|
+
const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
|
|
153
|
+
return description !== undefined
|
|
154
|
+
? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
|
|
155
|
+
: obj;
|
|
90
156
|
}
|
|
91
|
-
function renderObject(fields, indent, resolver) {
|
|
157
|
+
function renderObject(fields, indent, resolver, description) {
|
|
92
158
|
const pad = ' '.repeat(indent);
|
|
93
159
|
const entries = Object.entries(fields).map(([name, f]) => {
|
|
94
160
|
const rendered = isDtoField(f) ? renderField(f, indent, resolver) : renderFieldValue(f, indent, resolver);
|
|
95
161
|
return `${pad}${name}: ${rendered}`;
|
|
96
162
|
});
|
|
97
|
-
|
|
163
|
+
const obj = `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}})`;
|
|
164
|
+
return description !== undefined
|
|
165
|
+
? `Type.Object({\n${entries.join(',\n')}\n${' '.repeat(indent - 1)}}, { description: ${renderString(description)} })`
|
|
166
|
+
: obj;
|
|
98
167
|
}
|
|
99
168
|
function renderField(f, indent, resolver) {
|
|
169
|
+
// Ref branch renders its own optional (referencing overrides + chain fallback).
|
|
170
|
+
if (f.ref !== undefined)
|
|
171
|
+
return renderValue(f, indent, resolver);
|
|
100
172
|
const base = renderValue(f, indent, resolver);
|
|
101
173
|
return f.isOptional() ? `Type.Optional(${base})` : base;
|
|
102
174
|
}
|
|
175
|
+
/** Render a ref-carrying field's bare type (no optional wrapper): resolve the
|
|
176
|
+
* chain, inherit the terminal field's type/constraints, keep the referencing
|
|
177
|
+
* field's own overrides (pattern / default / description). */
|
|
178
|
+
function renderRefBase(f, indent, resolver) {
|
|
179
|
+
const target = resolveRefChain(f);
|
|
180
|
+
const targetField = target.field;
|
|
181
|
+
const pattern = f.pattern ?? target.pattern;
|
|
182
|
+
const defaultValue = f.default ?? target.default;
|
|
183
|
+
const desc = f.description ?? dtoFieldDescription(target);
|
|
184
|
+
return renderBasic(targetField, pattern, defaultValue, resolver, indent, desc);
|
|
185
|
+
}
|
|
186
|
+
/** Render a ref-carrying field with its optional wrapper: referencing
|
|
187
|
+
* override first, then the chain's DtoField-level optional, then the bare
|
|
188
|
+
* column optionality. */
|
|
189
|
+
function renderRefField(f, indent, resolver) {
|
|
190
|
+
const target = resolveRefChain(f);
|
|
191
|
+
const optional = f.optional ?? target.optional ?? target.field.optional ?? false;
|
|
192
|
+
const base = renderRefBase(f, indent, resolver);
|
|
193
|
+
return optional ? `Type.Optional(${base})` : base;
|
|
194
|
+
}
|
|
103
195
|
function renderValue(f, indent, resolver) {
|
|
196
|
+
if (f.ref !== undefined)
|
|
197
|
+
return renderRefField(f, indent, resolver);
|
|
104
198
|
if (f.field.type === 'array') {
|
|
105
199
|
const items = f.field.items;
|
|
200
|
+
const desc = dtoFieldDescription(f);
|
|
106
201
|
// Referenced DTO element — render by name (same-file export), not expanded.
|
|
107
|
-
if (isDtoMessage(items))
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
202
|
+
if (isDtoMessage(items)) {
|
|
203
|
+
return desc !== undefined
|
|
204
|
+
? `Type.Array(${items.name}, { description: ${renderString(desc)} })`
|
|
205
|
+
: `Type.Array(${items.name})`;
|
|
206
|
+
}
|
|
207
|
+
if (isDtoField(items)) {
|
|
208
|
+
const rendered = `Type.Array(${renderField(items, indent + 1, resolver)})`;
|
|
209
|
+
return desc !== undefined
|
|
210
|
+
? `Type.Array(${renderField(items, indent + 1, resolver)}, { description: ${renderString(desc)} })`
|
|
211
|
+
: rendered;
|
|
212
|
+
}
|
|
213
|
+
const rendered = `Type.Array(${renderFieldValue(items, indent, resolver)})`;
|
|
214
|
+
return desc !== undefined
|
|
215
|
+
? `Type.Array(${renderFieldValue(items, indent, resolver)}, { description: ${renderString(desc)} })`
|
|
216
|
+
: rendered;
|
|
112
217
|
}
|
|
113
218
|
if (f.field.type === 'object') {
|
|
114
|
-
return renderObject(f.field.properties, indent + 1, resolver);
|
|
219
|
+
return renderObject(f.field.properties, indent + 1, resolver, dtoFieldDescription(f));
|
|
115
220
|
}
|
|
116
221
|
// DtoField only wraps a database Field; array/object defs live in the subclasses.
|
|
117
222
|
// Only DTO-level defaults (setDefault) are emitted as TypeBox default
|
|
118
223
|
// annotations; DB field defaults are not carried into the API contract.
|
|
119
|
-
return renderBasic(f.field, f.pattern, f.default, resolver, indent);
|
|
224
|
+
return renderBasic(f.field, f.pattern, f.default, resolver, indent, dtoFieldDescription(f));
|
|
120
225
|
}
|
|
121
226
|
function collectEnumImports(f, resolver, out) {
|
|
227
|
+
if (f.ref !== undefined) {
|
|
228
|
+
collectEnumImports(resolveRefChain(f), resolver, out);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
122
231
|
if (f.field.type === 'array') {
|
|
123
232
|
const items = f.field.items;
|
|
124
233
|
if (isDtoMessage(items))
|
|
@@ -161,19 +270,91 @@ export function collectDtoImports(schema, resolver, out) {
|
|
|
161
270
|
for (const f of Object.values(schema.fields))
|
|
162
271
|
collectEnumImports(f, resolver, out);
|
|
163
272
|
}
|
|
273
|
+
/** Render the server-injection base: token-injected fields as Optional
|
|
274
|
+
* properties of a TypeBox object, plus a non-enumerable __inject adapter
|
|
275
|
+
* (same mechanism as hand-written bases, see pylon __inject docs) that fills
|
|
276
|
+
* each field from the token at runtime. */
|
|
277
|
+
function renderInjectBase(fields, resolver) {
|
|
278
|
+
const entries = Object.entries(fields).map(([name, f]) => {
|
|
279
|
+
const base = f.ref !== undefined ? renderRefBase(f, 1, resolver) : renderValue(f, 1, resolver);
|
|
280
|
+
return ` ${name}: Type.Optional(${base})`;
|
|
281
|
+
});
|
|
282
|
+
const inner = `Type.Object({\n${entries.join(',\n')}\n})`;
|
|
283
|
+
const assigns = Object.keys(fields)
|
|
284
|
+
.map((k) => `body.${k} = token.${k};`)
|
|
285
|
+
.join(' ');
|
|
286
|
+
return [
|
|
287
|
+
`Object.defineProperty(`,
|
|
288
|
+
` ${inner},`,
|
|
289
|
+
` '__inject',`,
|
|
290
|
+
` { value: (body: Record<string, unknown>, token: Record<string, unknown>): void => { ${assigns} }, enumerable: false },`,
|
|
291
|
+
`)`,
|
|
292
|
+
].join('\n');
|
|
293
|
+
}
|
|
164
294
|
/** Render one DTO export (const + type) — no file header, for file-level generation. */
|
|
165
295
|
export function renderDtoExport(schema, resolver) {
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
296
|
+
const injectFields = {};
|
|
297
|
+
const normalFields = {};
|
|
298
|
+
for (const [key, f] of Object.entries(schema.fields)) {
|
|
299
|
+
if (f.injectFrom !== undefined)
|
|
300
|
+
injectFields[key] = f;
|
|
301
|
+
else
|
|
302
|
+
normalFields[key] = f;
|
|
303
|
+
}
|
|
304
|
+
const parts = [];
|
|
305
|
+
if (Object.keys(injectFields).length > 0)
|
|
306
|
+
parts.push(renderInjectBase(injectFields, resolver));
|
|
307
|
+
parts.push(renderObject(normalFields, 1, resolver, schema.description));
|
|
308
|
+
for (const base of schema.bases ?? [])
|
|
309
|
+
parts.push(renderBase(base));
|
|
310
|
+
const body = parts.length > 1 ? `Type.Intersect([${parts.join(', ')}])` : parts[0];
|
|
171
311
|
return `export const ${schema.name} = ${body};`;
|
|
172
312
|
}
|
|
173
313
|
/** Render the Static type export for a DTO. */
|
|
174
314
|
export function renderDtoTypeExport(name) {
|
|
175
315
|
return `export type ${name} = Static<typeof ${name}>;`;
|
|
176
316
|
}
|
|
317
|
+
/** Render one token export: a flat TypeBox object. Security fields keep
|
|
318
|
+
* their declared optionality (secret required, cipher optional); identity
|
|
319
|
+
* fields are Optional except the primary-key anchor — they only exist after
|
|
320
|
+
* login (two-state object, flat runtime shape), while the PK is guaranteed
|
|
321
|
+
* to be projected (token validation enforces it) and serves as the identity
|
|
322
|
+
* anchor (e.g. the tenant key for tenant-scoped controllers). */
|
|
323
|
+
export function renderTokenExport(token, resolver) {
|
|
324
|
+
const constName = `${token.name}Token`;
|
|
325
|
+
const entries = [
|
|
326
|
+
...Object.entries(token.security).map(([name, f]) => ` ${name}: ${renderField(f, 1, resolver)}`),
|
|
327
|
+
...Object.entries(token.identity).map(([name, f]) => isPrimaryKeyProjection(f) ? ` ${name}: ${renderValue(f, 1, resolver)}` : ` ${name}: Type.Optional(${renderValue(f, 1, resolver)})`),
|
|
328
|
+
];
|
|
329
|
+
const desc = token.description !== undefined ? `, { description: ${renderString(token.description)} }` : '';
|
|
330
|
+
return `export const ${constName} = Type.Object({\n${entries.join(',\n')}\n}${desc});`;
|
|
331
|
+
}
|
|
332
|
+
/** True when the DTO field projects a primary-key column of its source table. */
|
|
333
|
+
function isPrimaryKeyProjection(f) {
|
|
334
|
+
const tbl = f.field.schema;
|
|
335
|
+
if (tbl?.type !== 'table')
|
|
336
|
+
return false;
|
|
337
|
+
// A table-backed field is always a plain Field (only Field carries a table
|
|
338
|
+
// schema); array/object DTO fields reference messages, not tables.
|
|
339
|
+
const field = f.field;
|
|
340
|
+
const table = tbl;
|
|
341
|
+
const pk = table.primaryKey;
|
|
342
|
+
if (!pk)
|
|
343
|
+
return false;
|
|
344
|
+
const pks = Array.isArray(pk) ? pk : [pk];
|
|
345
|
+
return pks.includes(field);
|
|
346
|
+
}
|
|
347
|
+
/** Render the Static type export for a token. */
|
|
348
|
+
export function renderTokenTypeExport(name) {
|
|
349
|
+
return `export type ${name}Token = Static<typeof ${name}Token>;`;
|
|
350
|
+
}
|
|
351
|
+
/** Collect all imports needed to render a token: enum references across
|
|
352
|
+
* security + identity fields. */
|
|
353
|
+
export function collectTokenImports(token, resolver, out) {
|
|
354
|
+
for (const f of [...Object.values(token.security), ...Object.values(token.identity)]) {
|
|
355
|
+
collectEnumImports(f, resolver, out);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
177
358
|
export function renderDtoMessage(schema, options = {}) {
|
|
178
359
|
const { resolver, source } = options;
|
|
179
360
|
const imports = new Map();
|
package/docs/curd.md
CHANGED
|
@@ -1,146 +1,150 @@
|
|
|
1
|
-
# 管理端 CRUD 页面标准(CurdSchema)
|
|
2
|
-
|
|
3
|
-
CurdSchema 是**管理端专用**(`FrontAppSchema.type === 'admin'`)的 CRUD 页面标准:绑定一张实体表 + 一个管理端 app,描述列表页与新增/编辑/详情动作页生成所需的全部页面语义。一条 CurdSchema = 列表页(+ 动作页)的生成规格。
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
**CurdSchema 只依赖 table schema(`Field` 实例),不挂钩 DTO(`DtoMessage`)**——DTO 由生成器按标准从 `columns` 推导。
|
|
8
|
-
|
|
9
|
-
## 定义
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
import { defineCurd } from '@pylonts/dsl';
|
|
13
|
-
import { admin } from '../project.config';
|
|
14
|
-
import { order } from '../schema/order.table';
|
|
15
|
-
import { merchant } from '../schema/merchant.table';
|
|
16
|
-
import { orderListFilter } from '../filter_schema/api/admin/filter/order-list.filter';
|
|
17
|
-
|
|
18
|
-
export const orderCurd = defineCurd('order', { // name = table.name 的 kebab(即 admin 路由路径)
|
|
19
|
-
description: '订单管理',
|
|
20
|
-
app: admin, // 所属管理端(project.config.ts 的 FrontAppSchema 共享实例)
|
|
21
|
-
table: order, // 绑定实体表(共享实例)
|
|
22
|
-
title: '订单管理',
|
|
23
|
-
section: '订单管理', // 必填:sidebar 分组名
|
|
24
|
-
actions: [defineAction('EXPORT', '导出订单')], // 额外操作按钮
|
|
25
|
-
actionPages: {
|
|
26
|
-
add: { mode: 'modal', columns: [order.columns.order_no, order.columns.mer_id] },
|
|
27
|
-
update: { mode: 'modal', columns: [order.columns.id, order.columns.order_no] },
|
|
28
|
-
detail: { mode: 'route', columns: [order.columns.id, order.columns.order_no, order.columns.amount] },
|
|
29
|
-
},
|
|
30
|
-
list: {
|
|
31
|
-
columns: [order.columns.id, order.columns.order_no, merchant.columns.name], // 可跨表
|
|
32
|
-
filter: orderListFilter, // 搜索表单 + keyword(FilterSchema 引用,可选)
|
|
33
|
-
orderBy: { column: order.columns.id, direction: 'desc' },
|
|
34
|
-
columnTitles: { order_no: '订单号', name: '商户名称' }, // Field.name → 文案
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
搜索条件**不内联在 list 里**,而是独立的 **FilterSchema**(`defineFilter`)声明,存放在 `filter_schema/{api.name}/{app.name}/filter/`(机器校验:一文件一 filter,文件名 = 名字去 Filter 后缀转 kebab):
|
|
40
|
-
|
|
41
|
-
```ts
|
|
42
|
-
// filter_schema/api/admin/filter/order-list.filter.ts
|
|
43
|
-
import { defineFilter } from '@pylonts/dsl';
|
|
44
|
-
import { admin, api } from '../../../project.config';
|
|
45
|
-
import { order } from '../../../schema/order.table';
|
|
46
|
-
|
|
47
|
-
export const orderListFilter = defineFilter({
|
|
48
|
-
name: 'OrderListFilter',
|
|
49
|
-
api,
|
|
50
|
-
app: admin,
|
|
51
|
-
conditions: [
|
|
52
|
-
{ field: order.columns.status, optional: true }, // op 默认 eq;optional = 有值才加 WHERE
|
|
53
|
-
{ field: order.columns.order_no, op: 'like', optional: true },
|
|
54
|
-
],
|
|
55
|
-
keyword: { columns: [order.columns.order_no] }, // 单输入值多列 OR 模糊
|
|
56
|
-
});
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
生成物为 `{api}/src/modules/{app}/filter/{FilterName}.ts`(两段柯里化 WHERE 拼装方法,DAO/Service 列表查询共用)。
|
|
60
|
-
|
|
61
|
-
## 字段
|
|
62
|
-
|
|
63
|
-
| 字段 | 类型 | 说明 |
|
|
64
|
-
|---|---|---|
|
|
65
|
-
| `app` | `FrontAppSchema` | 所属管理端(共享实例,`type` 必须为 `'admin'`) |
|
|
66
|
-
| `table` | `TableSchema` | 绑定实体表(共享实例) |
|
|
67
|
-
| `title` | `string` | 列表页中文标题 |
|
|
68
|
-
| `section` | `string` | **必填**:sidebar 分组名 |
|
|
69
|
-
| `actions?` | `ActionSchema[]` | 页面额外可执行动作(标准 CRUD 之外,如导出、审核) |
|
|
70
|
-
| `actionPages?` | `{ add? / update? / detail? }` | 动作页:`{ mode: 'modal' \| 'route'; columns: Field[] }` |
|
|
71
|
-
| `list` | `CurdListConfig` | 列表页配置(必填) |
|
|
72
|
-
|
|
73
|
-
### ActionPage
|
|
74
|
-
|
|
75
|
-
| 字段 | 类型 | 说明 |
|
|
76
|
-
|---|---|---|
|
|
77
|
-
| `mode` | `'modal' \| 'route'` | 弹窗或独立路由 |
|
|
78
|
-
| `columns` | `Field[]` | 该页面渲染的字段,**必填非空**——前端要显示的字段必须全部显式列出 |
|
|
79
|
-
|
|
80
|
-
### CurdListConfig
|
|
81
|
-
|
|
82
|
-
| 字段 | 类型 | 说明 |
|
|
83
|
-
|---|---|---|
|
|
84
|
-
| `columns` | `Field[]` | 列表列,**必填非空**;可含跨表字段 |
|
|
85
|
-
| `filter?` | `FilterSchema` | 页面过滤器引用:搜索表单(AND 条件)+ keyword(多列 OR 模糊);缺省 = 无搜索表单 |
|
|
86
|
-
| `orderBy` | `{ column: Field; direction: 'asc' \| 'desc' }` | 默认排序,**必填**,column 与 direction 都必填;column 必须是**本表字段实例** |
|
|
87
|
-
| `columnTitles?` | `Record<string, string>` | 列标题覆盖:`Field.name` → 中文文案 |
|
|
88
|
-
|
|
89
|
-
### FilterSchema(`defineFilter`)
|
|
90
|
-
|
|
91
|
-
| 字段 | 类型 | 说明 |
|
|
92
|
-
|---|---|---|
|
|
93
|
-
| `name` | `string` | PascalCase、`Filter` 结尾;导出名 = name 首字母小写 |
|
|
94
|
-
| `api` | `ProjectApiSchema` | 所属后端 api(project.config.ts 共享实例);`api.apps` 必须包含 `app` |
|
|
95
|
-
| `app` | `FrontAppSchema` | 所属前端 app(共享实例);必须与引用它的 curd 同 app |
|
|
96
|
-
| `conditions?` | `FilterCondition[]` | AND 组合条件:`{ field, op?='eq', right?, optional? }`;`optional: true` = 有值才加 WHERE(页面搜索场景) |
|
|
97
|
-
| `keyword?` | `{ columns: Field[] }` | 单输入值对多列 OR like 模糊;配置后驱动「关键词查询」端点(`query({ keyword })`,供 Select/AutoComplete 搜索) |
|
|
98
|
-
|
|
99
|
-
## 跨表字段
|
|
100
|
-
|
|
101
|
-
`list.columns` 与 filter `conditions` 里的 `Field` 实例可指向**本表或其他表**的列——列表列与搜索条件因此可以显示/过滤关联表字段(如订单列表显示商户名称、按商户名称过滤)。
|
|
102
|
-
|
|
103
|
-
## 默认与校验
|
|
104
|
-
|
|
105
|
-
- `list.columns` / `actionPages.*.columns` **必填非空**(不允许省略、不允许空数组)
|
|
106
|
-
- `list.orderBy` **必填**,`column` 与 `direction` 都必填(规格:默认主键 desc 由定义方显式写出)
|
|
107
|
-
- 运行时校验(`defineCurd`,仿 `defineTable` 强校验风格):
|
|
108
|
-
- `app.type` 必须为 `'admin'`,否则抛错
|
|
109
|
-
- **`name` 必须是 `table.name` 的 kebab 形式**(name 即 admin 路由路径,不允许与所服务的表漂移)
|
|
110
|
-
- `section` 必填
|
|
111
|
-
- 所有 `columns` 非空,否则抛错
|
|
112
|
-
- `list.filter.app` 必须 === `curd.app`,否则抛错
|
|
113
|
-
- `list.orderBy.column` 必须属于 `table`,否则抛错
|
|
114
|
-
- `list.columns` 允许跨表,**不校验归属**
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
|
129
|
-
|
|
130
|
-
|
|
|
131
|
-
|
|
|
132
|
-
|
|
|
133
|
-
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
|
142
|
-
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
|
|
|
1
|
+
# 管理端 CRUD 页面标准(CurdSchema)
|
|
2
|
+
|
|
3
|
+
CurdSchema 是**管理端专用**(`FrontAppSchema.type === 'admin'`)的 CRUD 页面标准:绑定一张实体表 + 一个管理端 app,描述列表页与新增/编辑/详情动作页生成所需的全部页面语义。一条 CurdSchema = 列表页(+ 动作页)的生成规格。
|
|
4
|
+
|
|
5
|
+
页面定义文件按**管理端 app + 实体表**组织:`{project}/curd_schema/{app}/{table}.curd.ts`(一文件一 curd;同一张表可在不同 admin app 下分别 CRUD——curd 的身份是 **(app, table)**,生成物落在 `dto_schema/{app}/` 也按 app 隔离,不会互相撞名)。
|
|
6
|
+
|
|
7
|
+
**CurdSchema 只依赖 table schema(`Field` 实例),不挂钩 DTO(`DtoMessage`)**——DTO 由生成器按标准从 `columns` 推导。
|
|
8
|
+
|
|
9
|
+
## 定义
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { defineCurd } from '@pylonts/dsl';
|
|
13
|
+
import { admin } from '../project.config';
|
|
14
|
+
import { order } from '../schema/order.table';
|
|
15
|
+
import { merchant } from '../schema/merchant.table';
|
|
16
|
+
import { orderListFilter } from '../filter_schema/api/admin/filter/order-list.filter';
|
|
17
|
+
|
|
18
|
+
export const orderCurd = defineCurd('order', { // name = table.name 的 kebab(即 admin 路由路径)
|
|
19
|
+
description: '订单管理',
|
|
20
|
+
app: admin, // 所属管理端(project.config.ts 的 FrontAppSchema 共享实例)
|
|
21
|
+
table: order, // 绑定实体表(共享实例)
|
|
22
|
+
title: '订单管理',
|
|
23
|
+
section: '订单管理', // 必填:sidebar 分组名
|
|
24
|
+
actions: [defineAction('EXPORT', '导出订单')], // 额外操作按钮
|
|
25
|
+
actionPages: {
|
|
26
|
+
add: { mode: 'modal', columns: [order.columns.order_no, order.columns.mer_id] },
|
|
27
|
+
update: { mode: 'modal', columns: [order.columns.id, order.columns.order_no] },
|
|
28
|
+
detail: { mode: 'route', columns: [order.columns.id, order.columns.order_no, order.columns.amount] },
|
|
29
|
+
},
|
|
30
|
+
list: {
|
|
31
|
+
columns: [order.columns.id, order.columns.order_no, merchant.columns.name], // 可跨表
|
|
32
|
+
filter: orderListFilter, // 搜索表单 + keyword(FilterSchema 引用,可选)
|
|
33
|
+
orderBy: { column: order.columns.id, direction: 'desc' },
|
|
34
|
+
columnTitles: { order_no: '订单号', name: '商户名称' }, // Field.name → 文案
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
搜索条件**不内联在 list 里**,而是独立的 **FilterSchema**(`defineFilter`)声明,存放在 `filter_schema/{api.name}/{app.name}/filter/`(机器校验:一文件一 filter,文件名 = 名字去 Filter 后缀转 kebab):
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// filter_schema/api/admin/filter/order-list.filter.ts
|
|
43
|
+
import { defineFilter } from '@pylonts/dsl';
|
|
44
|
+
import { admin, api } from '../../../project.config';
|
|
45
|
+
import { order } from '../../../schema/order.table';
|
|
46
|
+
|
|
47
|
+
export const orderListFilter = defineFilter({
|
|
48
|
+
name: 'OrderListFilter',
|
|
49
|
+
api,
|
|
50
|
+
app: admin,
|
|
51
|
+
conditions: [
|
|
52
|
+
{ field: order.columns.status, optional: true }, // op 默认 eq;optional = 有值才加 WHERE
|
|
53
|
+
{ field: order.columns.order_no, op: 'like', optional: true },
|
|
54
|
+
],
|
|
55
|
+
keyword: { columns: [order.columns.order_no] }, // 单输入值多列 OR 模糊
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
生成物为 `{api}/src/modules/{app}/filter/{FilterName}.ts`(两段柯里化 WHERE 拼装方法,DAO/Service 列表查询共用)。
|
|
60
|
+
|
|
61
|
+
## 字段
|
|
62
|
+
|
|
63
|
+
| 字段 | 类型 | 说明 |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `app` | `FrontAppSchema` | 所属管理端(共享实例,`type` 必须为 `'admin'`) |
|
|
66
|
+
| `table` | `TableSchema` | 绑定实体表(共享实例) |
|
|
67
|
+
| `title` | `string` | 列表页中文标题 |
|
|
68
|
+
| `section` | `string` | **必填**:sidebar 分组名 |
|
|
69
|
+
| `actions?` | `ActionSchema[]` | 页面额外可执行动作(标准 CRUD 之外,如导出、审核) |
|
|
70
|
+
| `actionPages?` | `{ add? / update? / detail? }` | 动作页:`{ mode: 'modal' \| 'route'; columns: Field[] }` |
|
|
71
|
+
| `list` | `CurdListConfig` | 列表页配置(必填) |
|
|
72
|
+
|
|
73
|
+
### ActionPage
|
|
74
|
+
|
|
75
|
+
| 字段 | 类型 | 说明 |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `mode` | `'modal' \| 'route'` | 弹窗或独立路由 |
|
|
78
|
+
| `columns` | `Field[]` | 该页面渲染的字段,**必填非空**——前端要显示的字段必须全部显式列出 |
|
|
79
|
+
|
|
80
|
+
### CurdListConfig
|
|
81
|
+
|
|
82
|
+
| 字段 | 类型 | 说明 |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `columns` | `Field[]` | 列表列,**必填非空**;可含跨表字段 |
|
|
85
|
+
| `filter?` | `FilterSchema` | 页面过滤器引用:搜索表单(AND 条件)+ keyword(多列 OR 模糊);缺省 = 无搜索表单 |
|
|
86
|
+
| `orderBy` | `{ column: Field; direction: 'asc' \| 'desc' }` | 默认排序,**必填**,column 与 direction 都必填;column 必须是**本表字段实例** |
|
|
87
|
+
| `columnTitles?` | `Record<string, string>` | 列标题覆盖:`Field.name` → 中文文案 |
|
|
88
|
+
|
|
89
|
+
### FilterSchema(`defineFilter`)
|
|
90
|
+
|
|
91
|
+
| 字段 | 类型 | 说明 |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `name` | `string` | PascalCase、`Filter` 结尾;导出名 = name 首字母小写 |
|
|
94
|
+
| `api` | `ProjectApiSchema` | 所属后端 api(project.config.ts 共享实例);`api.apps` 必须包含 `app` |
|
|
95
|
+
| `app` | `FrontAppSchema` | 所属前端 app(共享实例);必须与引用它的 curd 同 app |
|
|
96
|
+
| `conditions?` | `FilterCondition[]` | AND 组合条件:`{ field, op?='eq', right?, optional? }`;`optional: true` = 有值才加 WHERE(页面搜索场景) |
|
|
97
|
+
| `keyword?` | `{ columns: Field[] }` | 单输入值对多列 OR like 模糊;配置后驱动「关键词查询」端点(`query({ keyword })`,供 Select/AutoComplete 搜索) |
|
|
98
|
+
|
|
99
|
+
## 跨表字段
|
|
100
|
+
|
|
101
|
+
`list.columns` 与 filter `conditions` 里的 `Field` 实例可指向**本表或其他表**的列——列表列与搜索条件因此可以显示/过滤关联表字段(如订单列表显示商户名称、按商户名称过滤)。
|
|
102
|
+
|
|
103
|
+
## 默认与校验
|
|
104
|
+
|
|
105
|
+
- `list.columns` / `actionPages.*.columns` **必填非空**(不允许省略、不允许空数组)
|
|
106
|
+
- `list.orderBy` **必填**,`column` 与 `direction` 都必填(规格:默认主键 desc 由定义方显式写出)
|
|
107
|
+
- 运行时校验(`defineCurd`,仿 `defineTable` 强校验风格):
|
|
108
|
+
- `app.type` 必须为 `'admin'`,否则抛错
|
|
109
|
+
- **`name` 必须是 `table.name` 的 kebab 形式**(name 即 admin 路由路径,不允许与所服务的表漂移)
|
|
110
|
+
- `section` 必填
|
|
111
|
+
- 所有 `columns` 非空,否则抛错
|
|
112
|
+
- `list.filter.app` 必须 === `curd.app`,否则抛错
|
|
113
|
+
- `list.orderBy.column` 必须属于 `table`,否则抛错
|
|
114
|
+
- `list.columns` 允许跨表,**不校验归属**
|
|
115
|
+
- 存储校验(`loadCurds`,仿 `loadDaos`/`loadEntities` 机器校验风格):
|
|
116
|
+
- 唯一合法目录是 `curd_schema/{app.name}/`(一级,app 名);`curd.app` 必须是 project.config.ts 共享实例(`type === 'admin'`),且与所在目录一致
|
|
117
|
+
- **文件名 = `{table}.curd.ts`(表名 verbatim**,与 `{table}.entity.ts` / `{table}.dao.ts` 同规)——(app, table) 两维决定 curd 身份,同一张表可在不同 admin app 下合法共存
|
|
118
|
+
- 一文件一 curd(多导出/零导出报错)
|
|
119
|
+
- 运行时校验(`defineFilter`):`api.apps` 包含 `app`;conditions 与 keyword 不能同时为空;keyword.columns 非空
|
|
120
|
+
- 生成时校验(curd 生成器,`DtoSchemaGen.add` / `update`):
|
|
121
|
+
- 表配置 `autoIncrement` 或 `generator`(主键由服务端生成)时,`actionPages.add.columns` **不允许包含主键字段**,否则抛错——AddRequest 不携带服务端生成的主键
|
|
122
|
+
- `actionPages.update.columns` **必须包含主键字段**,否则抛错——UpdateRequest 靠主键定位记录
|
|
123
|
+
|
|
124
|
+
## DTO 推导(生成器约定)
|
|
125
|
+
|
|
126
|
+
DTO 由 curd 生成器从 `CurdSchema` 按标准命名推导,页面语义不持有 DTO 实例:
|
|
127
|
+
|
|
128
|
+
| DTO | 命名 | 字段来源 |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| Row | `{Pascal}Row` | `list.columns` |
|
|
131
|
+
| ListRequest | `{Pascal}ListRequest` | filter 的 conditions(camelCase + op)与 keyword + 分页参数(`PageRequest`,仅 paginated 表) |
|
|
132
|
+
| QueryRequest | `{Pascal}QueryRequest` | filter 的 conditions + keyword,无分页——keyword 查询端点专用(仅配置 keyword 时生成) |
|
|
133
|
+
| ListResponse | `{Pascal}ListResponse` | `PageResult(Row)`(仅 paginated 表;非分页表列表接口直接返回 `Row[]`,不生成 ListResponse) |
|
|
134
|
+
| AddRequest | `{Pascal}AddRequest` | `actionPages.add.columns` |
|
|
135
|
+
| UpdateRequest | `{Pascal}UpdateRequest` | `actionPages.update.columns` |
|
|
136
|
+
| DetailRequest | `{Pascal}DetailRequest` | 主键 |
|
|
137
|
+
| DetailResponse | `{Pascal}DetailResponse` | `actionPages.detail.columns` |
|
|
138
|
+
|
|
139
|
+
## 与旧 PageConfig 的差异
|
|
140
|
+
|
|
141
|
+
| PageConfig(旧方案,已废弃) | CurdSchema |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `module: string` | 由 `app` 推导(后端模块 == app 1:1) |
|
|
144
|
+
| `schema: 'bd'` 字符串 | `table: TableSchema` 实例(类型安全) |
|
|
145
|
+
| `operations: { label, action }` | `actions: ActionSchema[]` |
|
|
146
|
+
| `detail.mode` 单例 | `actionPages.detail.mode` |
|
|
147
|
+
| `forms.add / forms.update` | `actionPages.add / actionPages.update` |
|
|
148
|
+
| `keyword` / `orderBy` / `columnTitles` | `list.filter`(FilterSchema)/ `list.orderBy` / `list.columnTitles` |
|
|
149
|
+
| `naming` | 去掉(DTO 命名是生成器约定,非页面语义) |
|
|
150
|
+
| DTO 引用(`request` / `fields` / `DtoFields`) | 去掉(DTO 由生成器推导,页面只依赖 table) |
|