tha-cli 0.0.0-stage → 0.1.1
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/ARCHITECTURE.md +193 -0
- package/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/tha/cli.mjs +116 -0
- package/tha/commands/adopt.mjs +119 -0
- package/tha/commands/check.mjs +226 -0
- package/tha/commands/diff.mjs +70 -0
- package/tha/commands/init.mjs +262 -0
- package/tha/commands/remove.mjs +328 -0
- package/tha/commands/sync.mjs +826 -0
- package/tha/config/field.mjs +178 -0
- package/tha/config/load.mjs +188 -0
- package/tha/config/manifest.mjs +75 -0
- package/tha/config/paths.mjs +124 -0
- package/tha/config/project.mjs +52 -0
- package/tha/config/rules.mjs +85 -0
- package/tha/config/schema.mjs +306 -0
- package/tha/config/template.mjs +78 -0
- package/tha/generate/domain/api.mjs +67 -0
- package/tha/generate/domain/context.mjs +76 -0
- package/tha/generate/domain/detail.mjs +35 -0
- package/tha/generate/domain/entry.mjs +91 -0
- package/tha/generate/domain/executors.mjs +93 -0
- package/tha/generate/domain/form.mjs +34 -0
- package/tha/generate/domain/index.mjs +72 -0
- package/tha/generate/domain/permissions.mjs +35 -0
- package/tha/generate/domain/store.mjs +68 -0
- package/tha/generate/domain/view.mjs +29 -0
- package/tha/generate/global.mjs +111 -0
- package/tha/generate/header.mjs +29 -0
- package/tha/generate/names.mjs +45 -0
- package/tha/generate/plan.mjs +41 -0
- package/tha/kit/ThaCrudPage.vue +344 -0
- package/tha/kit/ThaDetail.vue +146 -0
- package/tha/kit/ThaForm.vue +194 -0
- package/tha/kit/request.ts +30 -0
- package/tha/kit/transport.ts +11 -0
- package/tha/utils/args.mjs +59 -0
- package/tha/utils/compare.mjs +38 -0
- package/tha/utils/files.mjs +175 -0
- package/tha/utils/format.mjs +23 -0
- package/tha/utils/output.mjs +79 -0
- package/tha/utils/style.mjs +147 -0
- package/tha/utils/ts.mjs +73 -0
- package/tha/verify/ast.mjs +143 -0
- package/tha/verify/boundary.mjs +219 -0
- package/tha/verify/contract.mjs +186 -0
- package/tha/verify/openapi.mjs +345 -0
- package/tha/verify/rules.mjs +91 -0
- package/tha/verify/scan.mjs +59 -0
- package/tha.mjs +22 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { validateFields } from './field.mjs'
|
|
2
|
+
import {
|
|
3
|
+
API_KEYS,
|
|
4
|
+
API_RE,
|
|
5
|
+
CRUD_KEYS,
|
|
6
|
+
DEFAULT_PAGES,
|
|
7
|
+
LIST_KEYS,
|
|
8
|
+
NAME_RE,
|
|
9
|
+
PAGE_NAMES,
|
|
10
|
+
PATH_RE,
|
|
11
|
+
PERMISSION_KEYS,
|
|
12
|
+
RESERVED_NAMES,
|
|
13
|
+
SORT_TYPES,
|
|
14
|
+
TOP_KEYS,
|
|
15
|
+
error,
|
|
16
|
+
isPlainObject
|
|
17
|
+
} from './rules.mjs'
|
|
18
|
+
|
|
19
|
+
export { API_RE, NAME_RE, PATH_RE, normalizeUrlPath } from './rules.mjs'
|
|
20
|
+
|
|
21
|
+
export function validateProject(configs) {
|
|
22
|
+
const errors = []
|
|
23
|
+
const domains = []
|
|
24
|
+
const names = new Map()
|
|
25
|
+
const paths = new Map()
|
|
26
|
+
const apis = new Map()
|
|
27
|
+
|
|
28
|
+
for (const parsed of configs) {
|
|
29
|
+
const file = parsed.relativeFile
|
|
30
|
+
const raw = parsed.value
|
|
31
|
+
const before = errors.length
|
|
32
|
+
|
|
33
|
+
if (!isPlainObject(raw)) {
|
|
34
|
+
error(errors, file, '', 'config 必须是一个对象')
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const key of Object.keys(raw)) {
|
|
39
|
+
if (!TOP_KEYS.has(key)) {
|
|
40
|
+
error(
|
|
41
|
+
errors,
|
|
42
|
+
file,
|
|
43
|
+
key,
|
|
44
|
+
'不认识的配置项;只支持 name/title/path/api/fields/list/crud/permissions/pages'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const name = raw.name
|
|
50
|
+
if (typeof name !== 'string' || !NAME_RE.test(name)) {
|
|
51
|
+
error(errors, file, 'name', '必须以小写字母开头(允许小驼峰),例如 doc、userProfile')
|
|
52
|
+
} else {
|
|
53
|
+
if (RESERVED_NAMES.has(name)) {
|
|
54
|
+
error(errors, file, 'name', `不能使用保留名 ${name}`)
|
|
55
|
+
}
|
|
56
|
+
if (name !== parsed.dirName) {
|
|
57
|
+
error(errors, file, 'name', `必须和目录名 ${parsed.dirName} 一致`)
|
|
58
|
+
}
|
|
59
|
+
if (names.has(name)) {
|
|
60
|
+
error(errors, file, 'name', `模块名重复,已被 ${names.get(name)} 使用`)
|
|
61
|
+
} else {
|
|
62
|
+
names.set(name, file)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const title = raw.title
|
|
67
|
+
if (typeof title !== 'string' || !title.trim()) {
|
|
68
|
+
error(errors, file, 'title', '必须是非空字符串')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const routePath = raw.path
|
|
72
|
+
if (typeof routePath !== 'string' || !PATH_RE.test(routePath)) {
|
|
73
|
+
error(errors, file, 'path', '必须以 / 开头,例如 /doc 或 /system/doc')
|
|
74
|
+
} else if (paths.has(routePath)) {
|
|
75
|
+
error(errors, file, 'path', `页面地址重复,已被 ${paths.get(routePath)} 使用`)
|
|
76
|
+
} else {
|
|
77
|
+
paths.set(routePath, file)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!isPlainObject(raw.api)) {
|
|
81
|
+
error(errors, file, 'api', '必须是对象,例如 { base: "/docs" }')
|
|
82
|
+
} else {
|
|
83
|
+
for (const key of Object.keys(raw.api)) {
|
|
84
|
+
if (!API_KEYS.has(key)) {
|
|
85
|
+
error(errors, file, `api.${key}`, '不认识的配置项;只支持 base、contract')
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const base = raw.api.base
|
|
89
|
+
if (typeof base !== 'string' || !API_RE.test(base)) {
|
|
90
|
+
error(errors, file, 'api.base', '必须以 / 开头,例如 /docs')
|
|
91
|
+
} else if (apis.has(base)) {
|
|
92
|
+
error(errors, file, 'api.base', `接口地址重复,已被 ${apis.get(base)} 使用`)
|
|
93
|
+
} else {
|
|
94
|
+
apis.set(base, file)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fields = validateFields(errors, file, raw.fields)
|
|
99
|
+
const list = validateList(errors, file, raw.list, fields)
|
|
100
|
+
const crud = validateCrud(errors, file, raw.crud)
|
|
101
|
+
const permissions = validatePermissions(errors, file, raw.permissions)
|
|
102
|
+
const pages = validatePages(errors, file, raw.pages, crud)
|
|
103
|
+
const contract = validateContract(errors, file, raw.api?.contract)
|
|
104
|
+
|
|
105
|
+
if (crud && permissions) {
|
|
106
|
+
if (!crud.create && permissions.create) {
|
|
107
|
+
error(errors, file, 'permissions.create', 'crud.create 已关闭,不要再声明 create 权限')
|
|
108
|
+
}
|
|
109
|
+
if (!crud.update && permissions.update) {
|
|
110
|
+
error(errors, file, 'permissions.update', 'crud.update 已关闭,不要再声明 update 权限')
|
|
111
|
+
}
|
|
112
|
+
if (!crud.remove && permissions.remove) {
|
|
113
|
+
error(errors, file, 'permissions.remove', 'crud.remove 已关闭,不要再声明 remove 权限')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (fields && crud) {
|
|
118
|
+
const listFields = Object.values(fields).filter((field) => field.list)
|
|
119
|
+
const formFields = Object.values(fields).filter((field) => field.form && !field.readonly)
|
|
120
|
+
|
|
121
|
+
if (listFields.length === 0) {
|
|
122
|
+
error(errors, file, 'fields', '至少有一个字段出现在列表里')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if ((crud.create || crud.update) && formFields.length === 0) {
|
|
126
|
+
error(errors, file, 'fields', '开启新增/编辑时,至少有一个可写字段出现在表单里')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (errors.length !== before) continue
|
|
131
|
+
|
|
132
|
+
domains.push({
|
|
133
|
+
...parsed,
|
|
134
|
+
name,
|
|
135
|
+
title,
|
|
136
|
+
path: routePath,
|
|
137
|
+
api: { base: raw.api.base, ...(contract ? { contract } : {}) },
|
|
138
|
+
fields,
|
|
139
|
+
list,
|
|
140
|
+
crud,
|
|
141
|
+
permissions,
|
|
142
|
+
pages
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
domains: errors.length === 0 ? domains : [],
|
|
148
|
+
errors
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validatePages(errors, file, value, crud) {
|
|
153
|
+
if (value === undefined) return [...DEFAULT_PAGES]
|
|
154
|
+
|
|
155
|
+
if (!Array.isArray(value)) {
|
|
156
|
+
error(errors, file, 'pages', '必须是数组,例如 ["list", "detail"]')
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (value.length === 0) {
|
|
161
|
+
error(errors, file, 'pages', '至少包含 list')
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const pages = []
|
|
166
|
+
|
|
167
|
+
for (const item of value) {
|
|
168
|
+
if (typeof item !== 'string' || !PAGE_NAMES.has(item)) {
|
|
169
|
+
error(errors, file, 'pages', `只支持 list、detail、form,收到 ${JSON.stringify(item)}`)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (pages.includes(item)) {
|
|
173
|
+
error(errors, file, 'pages', `重复的页面:${item}`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
pages.push(item)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!pages.includes('list')) {
|
|
180
|
+
error(errors, file, 'pages', 'list 必须保留(列表页是入口)')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (crud && pages.includes('form') && !crud.create && !crud.update) {
|
|
184
|
+
error(errors, file, 'pages', 'form 页需要 create 或 update 至少开一个')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return pages.length > 0 ? pages : null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateContract(errors, file, value) {
|
|
191
|
+
if (value === undefined) return undefined
|
|
192
|
+
|
|
193
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
194
|
+
error(errors, file, 'api.contract', '必须是相对项目根的文件路径,例如 "docs/openapi.json"')
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const normalized = value.trim().replace(/\\/g, '/')
|
|
199
|
+
|
|
200
|
+
if (normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) {
|
|
201
|
+
error(errors, file, 'api.contract', '必须是项目内相对路径,不能写绝对路径或 ..')
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!/\.(json|ya?ml)$/i.test(normalized)) {
|
|
206
|
+
error(errors, file, 'api.contract', '只支持 .json / .yaml / .yml 格式的 OpenAPI 文件')
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return normalized
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateList(errors, file, value, fields) {
|
|
214
|
+
if (value === undefined) return { pageSize: 20 }
|
|
215
|
+
if (!isPlainObject(value)) {
|
|
216
|
+
error(errors, file, 'list', '必须是对象,例如 { pageSize: 20 }')
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const key of Object.keys(value)) {
|
|
221
|
+
if (!LIST_KEYS.has(key)) {
|
|
222
|
+
error(errors, file, `list.${key}`, '不认识的配置项;只支持 pageSize、orderBy、order')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const pageSize = value.pageSize ?? 20
|
|
227
|
+
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
|
|
228
|
+
error(errors, file, 'list.pageSize', '必须是 1–100 的整数')
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = { pageSize }
|
|
233
|
+
const orderBy = value.orderBy
|
|
234
|
+
|
|
235
|
+
if (orderBy !== undefined) {
|
|
236
|
+
if (typeof orderBy !== 'string' || !orderBy.trim()) {
|
|
237
|
+
error(errors, file, 'list.orderBy', '必须是非空字符串,例如 "createdAt"')
|
|
238
|
+
} else if (fields && !fields[orderBy]) {
|
|
239
|
+
error(errors, file, 'list.orderBy', `字段 ${orderBy} 没有在 fields 里声明`)
|
|
240
|
+
} else if (fields && !SORT_TYPES.has(fields[orderBy].type)) {
|
|
241
|
+
error(
|
|
242
|
+
errors,
|
|
243
|
+
file,
|
|
244
|
+
'list.orderBy',
|
|
245
|
+
`${orderBy} 的类型是 ${fields[orderBy].type},不能排序;能排序的类型:${[...SORT_TYPES].join('、')}`
|
|
246
|
+
)
|
|
247
|
+
} else {
|
|
248
|
+
result.orderBy = orderBy
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const order = value.order
|
|
253
|
+
if (order !== undefined) {
|
|
254
|
+
if (orderBy === undefined) {
|
|
255
|
+
error(errors, file, 'list.order', 'order 要和 orderBy 一起写')
|
|
256
|
+
} else if (order !== 'asc' && order !== 'desc') {
|
|
257
|
+
error(errors, file, 'list.order', '只支持 asc、desc')
|
|
258
|
+
} else {
|
|
259
|
+
result.order = order
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function validateCrud(errors, file, value) {
|
|
267
|
+
if (value === undefined) return { create: true, update: true, remove: true }
|
|
268
|
+
if (!isPlainObject(value)) {
|
|
269
|
+
error(errors, file, 'crud', '必须是对象,例如 { create: true, update: true, remove: true }')
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const key of Object.keys(value)) {
|
|
274
|
+
if (!CRUD_KEYS.has(key)) error(errors, file, `crud.${key}`, '不认识的配置项;只支持 create/update/remove')
|
|
275
|
+
else if (typeof value[key] !== 'boolean') error(errors, file, `crud.${key}`, '必须是 boolean')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
create: value.create !== false,
|
|
280
|
+
update: value.update !== false,
|
|
281
|
+
remove: value.remove !== false
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function validatePermissions(errors, file, value) {
|
|
286
|
+
if (value === undefined) return undefined
|
|
287
|
+
if (!isPlainObject(value)) {
|
|
288
|
+
error(errors, file, 'permissions', '必须是对象,例如 { view: "doc:view" }')
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const permissions = {}
|
|
293
|
+
for (const key of Object.keys(value)) {
|
|
294
|
+
if (!PERMISSION_KEYS.has(key)) {
|
|
295
|
+
error(errors, file, `permissions.${key}`, '不认识的配置项;只支持 view/create/update/remove')
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
if (typeof value[key] !== 'string' || !value[key].trim()) {
|
|
299
|
+
error(errors, file, `permissions.${key}`, '必须是非空字符串')
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
permissions[key] = value[key].trim()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return permissions
|
|
306
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { quote } from '../generate/names.mjs'
|
|
2
|
+
|
|
3
|
+
export function renderInitConfig({ name, title, path, apiBase }) {
|
|
4
|
+
const exportName = `${name}Config`
|
|
5
|
+
|
|
6
|
+
return `/**
|
|
7
|
+
* THA 模块配置 · ${name}
|
|
8
|
+
*
|
|
9
|
+
* 必填:name / title / path / api.base / fields
|
|
10
|
+
* 选填:pages / list / crud / permissions / api.contract
|
|
11
|
+
*
|
|
12
|
+
* 流程:
|
|
13
|
+
* tha init ${name} -> 改这里 -> tha sync
|
|
14
|
+
* 可选:tha diff 预览,tha check 做 CI 检查。
|
|
15
|
+
*
|
|
16
|
+
* 规则:
|
|
17
|
+
* - 这里只描述标准增删改查,不写函数、组件、HTML、请求
|
|
18
|
+
* - 自定义功能放 domains/${name}/executors/,手写文件不加 .generated 后缀
|
|
19
|
+
* - 不认识的配置项会报错,一个文件都不会写
|
|
20
|
+
* - 结构由 tha check 检查;类型见下面注释
|
|
21
|
+
*/
|
|
22
|
+
export const ${exportName} = {
|
|
23
|
+
// ===== 1. 基本信息(必填)=====
|
|
24
|
+
name: ${quote(name)},
|
|
25
|
+
title: ${quote(title)},
|
|
26
|
+
|
|
27
|
+
// ===== 2. 页面地址(必填)=====
|
|
28
|
+
// path 是浏览器里的页面地址,可自定义;内部名称默认用 name。
|
|
29
|
+
path: ${quote(path)},
|
|
30
|
+
|
|
31
|
+
// ===== 3. 接口(必填)=====
|
|
32
|
+
api: {
|
|
33
|
+
base: ${quote(apiBase)},
|
|
34
|
+
// 选填:项目里的 OpenAPI 文件(相对路径,扩展名 .json / .yaml / .yml)。
|
|
35
|
+
// 填了之后 tha check 会拿它和 fields 比对,报字段缺失与类型不一致。
|
|
36
|
+
// contract: 'docs/openapi.json'
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// ===== 4. 字段(必填,至少一个)=====
|
|
40
|
+
// 类型:string | text | number | boolean | enum | date | datetime
|
|
41
|
+
// 选填:required / list / form / search / readonly
|
|
42
|
+
// 类型是 enum 时,必须给 options;default 必须在 options 里。
|
|
43
|
+
fields: {
|
|
44
|
+
title: {
|
|
45
|
+
label: '标题',
|
|
46
|
+
type: 'string',
|
|
47
|
+
required: true,
|
|
48
|
+
search: true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
// ===== 5. 页面(选填)=====
|
|
53
|
+
// 默认:['list', 'detail']
|
|
54
|
+
// list 必留;detail 生成详情页;form 生成新增/编辑表单页。
|
|
55
|
+
// form 要求 create 或 update 至少开一个。
|
|
56
|
+
pages: ['list', 'detail'],
|
|
57
|
+
|
|
58
|
+
// ===== 6. 列表(选填)=====
|
|
59
|
+
// 默认:{ pageSize: 20 },范围 1–100。
|
|
60
|
+
// 排序:orderBy 写字段名,order 写 asc / desc(默认 desc)。
|
|
61
|
+
// 例:{ pageSize: 20, orderBy: 'createdAt', order: 'desc' }
|
|
62
|
+
list: {},
|
|
63
|
+
|
|
64
|
+
// ===== 7. 操作(选填)=====
|
|
65
|
+
// 默认全开:{ create: true, update: true, remove: true }
|
|
66
|
+
// 关掉的动作会真裁掉:接口方法、执行器、store 的 pending key、页面按钮一起消失。
|
|
67
|
+
// 只读示例:{ create: false, update: false, remove: false }
|
|
68
|
+
crud: {},
|
|
69
|
+
|
|
70
|
+
// ===== 8. 权限(选填)=====
|
|
71
|
+
// 默认 {},不生成权限文件。
|
|
72
|
+
// 需要时填写:{ view: '${name}:view', create: '${name}:create', update: '${name}:update', remove: '${name}:remove' }
|
|
73
|
+
// 填了之后生成 permissions.ts,执行器在每个写动作前判断 can(action);
|
|
74
|
+
// permissions.ts 里的 canXxx 是骨架,接到项目自己的权限来源即可。
|
|
75
|
+
permissions: {}
|
|
76
|
+
}
|
|
77
|
+
`
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ============ 网络层模板 ============
|
|
2
|
+
// crud 关掉的能力不生成对应方法;pages 要详情/表单才生成 detail。
|
|
3
|
+
|
|
4
|
+
import { fieldTsType, pascal } from '../names.mjs'
|
|
5
|
+
import { importSpec } from '../../config/paths.mjs'
|
|
6
|
+
|
|
7
|
+
export function apiBody(domain, dirs) {
|
|
8
|
+
const type = pascal(domain.name)
|
|
9
|
+
const baseConst = `${domain.name}ApiBase`
|
|
10
|
+
const apiModule = importSpec(dirs, 'api', domain.name)
|
|
11
|
+
const entries = Object.entries(domain.fields)
|
|
12
|
+
const writable = entries.filter(([, field]) => !field.readonly)
|
|
13
|
+
const { create, update, remove } = domain.crud
|
|
14
|
+
const needDetail = domain.pages.includes('detail') || domain.pages.includes('form')
|
|
15
|
+
|
|
16
|
+
const docFields = entries
|
|
17
|
+
.map(([key, field]) => ` ${key}: ${fieldTsType(field)}`)
|
|
18
|
+
.join('\n')
|
|
19
|
+
|
|
20
|
+
const writableType = `{
|
|
21
|
+
${writable
|
|
22
|
+
.map(([key, field]) => ` ${key}${field.required ? '' : '?'}: ${fieldTsType(field)}`)
|
|
23
|
+
.join('\n')}
|
|
24
|
+
}`
|
|
25
|
+
|
|
26
|
+
const types = [`export interface ${type} {
|
|
27
|
+
id: number
|
|
28
|
+
${docFields}
|
|
29
|
+
}`]
|
|
30
|
+
|
|
31
|
+
if (create) types.push(`export type ${type}Create = ${writableType}`)
|
|
32
|
+
if (update) types.push(`export type ${type}Update = Partial<${writableType}>`)
|
|
33
|
+
|
|
34
|
+
const methods = [` list: () => request.list<${type}>(${baseConst})`]
|
|
35
|
+
|
|
36
|
+
if (needDetail) {
|
|
37
|
+
methods.push(` detail: (id: number) => request.detail<${type}>(id, ${baseConst})`)
|
|
38
|
+
}
|
|
39
|
+
if (create) {
|
|
40
|
+
methods.push(` create: (data: ${type}Create) => request.create<${type}>(data, ${baseConst})`)
|
|
41
|
+
}
|
|
42
|
+
if (update) {
|
|
43
|
+
methods.push(
|
|
44
|
+
` update: (id: number, data: ${type}Update) => request.update<${type}>(id, data, ${baseConst})`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
if (remove) {
|
|
48
|
+
methods.push(` remove: (id: number) => request.remove(id, ${baseConst})`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const notes = []
|
|
52
|
+
if (!create) notes.push('create 未开启')
|
|
53
|
+
if (!update) notes.push('update 未开启')
|
|
54
|
+
if (!remove) notes.push('remove 未开启')
|
|
55
|
+
|
|
56
|
+
return `import { request } from '${importSpec(dirs, 'utils', 'request')}'
|
|
57
|
+
|
|
58
|
+
/* 网络层:类型 + 增删改查接口。字段来自 config,不需要手写。${notes.length > 0 ? `\n * 已关闭:${notes.join('、')}` : ''} */
|
|
59
|
+
${types.join('\n\n')}
|
|
60
|
+
|
|
61
|
+
export const ${baseConst} = '${domain.api.base}'
|
|
62
|
+
|
|
63
|
+
export const ${domain.name}Api = {
|
|
64
|
+
${methods.join(',\n')}
|
|
65
|
+
}
|
|
66
|
+
`
|
|
67
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ============ 上下文模板 ============
|
|
2
|
+
// 权限落地的地方:ctx.can(action) 是执行器入口与页面按钮共用的唯一判据。
|
|
3
|
+
// 声明了 permissions 就引模块自己的 permissions.ts(骨架只补一次,归人维护);
|
|
4
|
+
// 没声明就恒为 true,不生成多余文件。
|
|
5
|
+
|
|
6
|
+
import { pascal } from '../names.mjs'
|
|
7
|
+
import { importSpec } from '../../config/paths.mjs'
|
|
8
|
+
|
|
9
|
+
export function contextBody(domain, dirs) {
|
|
10
|
+
const type = pascal(domain.name)
|
|
11
|
+
const apiModule = importSpec(dirs, 'api', domain.name)
|
|
12
|
+
const storeModule = importSpec(dirs, 'stores', domain.name)
|
|
13
|
+
const { create, update, remove } = domain.crud
|
|
14
|
+
const hasPermissions = Boolean(domain.permissions && Object.keys(domain.permissions).length > 0)
|
|
15
|
+
|
|
16
|
+
const actions = [
|
|
17
|
+
'view',
|
|
18
|
+
...(create ? ['create'] : []),
|
|
19
|
+
...(update ? ['update'] : []),
|
|
20
|
+
...(remove ? ['remove'] : [])
|
|
21
|
+
]
|
|
22
|
+
const actionType = actions.map((action) => `'${action}'`).join(' | ')
|
|
23
|
+
|
|
24
|
+
const permissionImport = hasPermissions
|
|
25
|
+
? `import { can${type} } from './permissions'\n`
|
|
26
|
+
: ''
|
|
27
|
+
|
|
28
|
+
const canImpl = hasPermissions
|
|
29
|
+
? `can: (action) => can${type}(action)`
|
|
30
|
+
: 'can: () => true'
|
|
31
|
+
|
|
32
|
+
return `import { ${domain.name}Api } from '${apiModule}'
|
|
33
|
+
import { use${type}Store, type PendingKey } from '${storeModule}'
|
|
34
|
+
import { toast } from '${importSpec(dirs, 'utils', 'transport')}'
|
|
35
|
+
${permissionImport}
|
|
36
|
+
/** 本模块可判定的动作(跟着 crud 开关走) */
|
|
37
|
+
export type ${type}Action = ${actionType}
|
|
38
|
+
|
|
39
|
+
/* 上下文:本模块统一的依赖入口;业务执行只依赖 ctx。 */
|
|
40
|
+
export interface ${type}Context {
|
|
41
|
+
state: ReturnType<typeof use${type}Store>
|
|
42
|
+
api: typeof ${domain.name}Api
|
|
43
|
+
ui: {
|
|
44
|
+
toast: typeof toast
|
|
45
|
+
}
|
|
46
|
+
run: <T>(key: PendingKey, task: () => Promise<T>) => Promise<T | undefined>
|
|
47
|
+
/** 权限判定:执行器入口与页面按钮共用 */
|
|
48
|
+
can: (action: ${type}Action) => boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function create${type}Context(): ${type}Context {
|
|
52
|
+
const state = use${type}Store()
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
state,
|
|
56
|
+
api: ${domain.name}Api,
|
|
57
|
+
ui: { toast },
|
|
58
|
+
${canImpl},
|
|
59
|
+
async run<T>(key: PendingKey, task: () => Promise<T>): Promise<T | undefined> {
|
|
60
|
+
state.setPending(key, true)
|
|
61
|
+
state.setError(null)
|
|
62
|
+
try {
|
|
63
|
+
return await task()
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
66
|
+
state.setError(message)
|
|
67
|
+
toast('error', message)
|
|
68
|
+
return undefined
|
|
69
|
+
} finally {
|
|
70
|
+
state.setPending(key, false)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`
|
|
76
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ============ 详情页模板 ============
|
|
2
|
+
// pages 含 detail 才生成。id 从路由参数拿,找不到就走中枢的 load 拉一次。
|
|
3
|
+
|
|
4
|
+
import { pascal } from '../names.mjs'
|
|
5
|
+
import { importSpec } from '../../config/paths.mjs'
|
|
6
|
+
|
|
7
|
+
export function detailBody(domain, dirs) {
|
|
8
|
+
const type = pascal(domain.name)
|
|
9
|
+
const kitDetail = importSpec(dirs, 'components', 'tha/ThaDetail.vue')
|
|
10
|
+
const config = importSpec(dirs, 'domains', domain.name, 'config')
|
|
11
|
+
const hub = importSpec(dirs, 'domains', domain.name)
|
|
12
|
+
const store = importSpec(dirs, 'stores', domain.name)
|
|
13
|
+
|
|
14
|
+
return `<script setup lang="ts">
|
|
15
|
+
import { useRoute } from 'vue-router'
|
|
16
|
+
import ThaDetail from '${kitDetail}'
|
|
17
|
+
import { ${domain.name}Config } from '${config}'
|
|
18
|
+
import { use${type}Module } from '${hub}'
|
|
19
|
+
import { use${type}Store } from '${store}'
|
|
20
|
+
|
|
21
|
+
const route = useRoute()
|
|
22
|
+
const actions = use${type}Module()
|
|
23
|
+
const store = use${type}Store()
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<ThaDetail
|
|
28
|
+
:config="${domain.name}Config"
|
|
29
|
+
:actions="actions"
|
|
30
|
+
:store="store"
|
|
31
|
+
:id="Number(route.params.id)"
|
|
32
|
+
/>
|
|
33
|
+
</template>
|
|
34
|
+
`
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ============ 中枢骨架模板 ============
|
|
2
|
+
// 只补一次:存在之后归模块所有,sync 不再覆盖。
|
|
3
|
+
// 两条硬约束:内部工厂不导出(防绕开单例)、动作集合跟着 crud 走。
|
|
4
|
+
|
|
5
|
+
import { pascal } from '../names.mjs'
|
|
6
|
+
import { importSpec } from '../../config/paths.mjs'
|
|
7
|
+
|
|
8
|
+
export function entryBody(domain, dirs) {
|
|
9
|
+
const type = pascal(domain.name)
|
|
10
|
+
const apiModule = importSpec(dirs, 'api', domain.name)
|
|
11
|
+
const { create, update, remove } = domain.crud
|
|
12
|
+
|
|
13
|
+
const needDetail = domain.pages.includes('detail') || domain.pages.includes('form')
|
|
14
|
+
const executorNames = ['refresh' + type]
|
|
15
|
+
if (needDetail) executorNames.push('load' + type)
|
|
16
|
+
if (create) executorNames.push('create' + type)
|
|
17
|
+
if (update) executorNames.push('update' + type)
|
|
18
|
+
if (remove) executorNames.push('remove' + type)
|
|
19
|
+
|
|
20
|
+
const apiTypeImports = []
|
|
21
|
+
if (needDetail) apiTypeImports.push(type)
|
|
22
|
+
if (create) apiTypeImports.push(`${type}Create`)
|
|
23
|
+
if (update) apiTypeImports.push(`${type}Update`)
|
|
24
|
+
|
|
25
|
+
const imports = [
|
|
26
|
+
`import { create${type}Context, type ${type}Context } from './context'`,
|
|
27
|
+
`import type { ${type}Action } from './context'`,
|
|
28
|
+
`import { ${executorNames.join(', ')} } from './executors/crud.generated'`
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
if (apiTypeImports.length > 0) {
|
|
32
|
+
imports.push(`import type { ${apiTypeImports.join(', ')} } from '${apiModule}'`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const members = ['refresh: () => Promise<boolean>']
|
|
36
|
+
const wire = ['refresh: (): Promise<boolean> => refresh' + type + '(ctx)']
|
|
37
|
+
const moduleMembers = ['refresh', 'can']
|
|
38
|
+
|
|
39
|
+
if (needDetail) {
|
|
40
|
+
members.push(`load: (id: number) => Promise<${type} | undefined>`)
|
|
41
|
+
wire.push(`load: (id: number): Promise<${type} | undefined> => load${type}(id, ctx)`)
|
|
42
|
+
moduleMembers.push('load')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (create) {
|
|
46
|
+
members.push(`create: (input: ${type}Create) => Promise<boolean>`)
|
|
47
|
+
wire.push(`create: (input: ${type}Create): Promise<boolean> => create${type}(input, ctx)`)
|
|
48
|
+
moduleMembers.push('create')
|
|
49
|
+
}
|
|
50
|
+
if (update) {
|
|
51
|
+
members.push(`update: (input: { id: number; data: ${type}Update }) => Promise<boolean>`)
|
|
52
|
+
wire.push(
|
|
53
|
+
`update: (input: { id: number; data: ${type}Update }): Promise<boolean> => update${type}(input, ctx)`
|
|
54
|
+
)
|
|
55
|
+
moduleMembers.push('update')
|
|
56
|
+
}
|
|
57
|
+
if (remove) {
|
|
58
|
+
members.push('remove: (id: number) => Promise<boolean>')
|
|
59
|
+
wire.push(`remove: (id: number): Promise<boolean> => remove${type}(id, ctx)`)
|
|
60
|
+
moduleMembers.push('remove')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
members.push('can: (action: ' + type + 'Action) => boolean')
|
|
64
|
+
wire.push('can: (action: ' + type + 'Action): boolean => ctx.can(action)')
|
|
65
|
+
|
|
66
|
+
return `${imports.join('\n')}
|
|
67
|
+
|
|
68
|
+
export interface ${type}Module {
|
|
69
|
+
${members.map((member) => ` ${member}`).join('\n')}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let ${domain.name}Module: ${type}Module | null = null
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 中枢单例:视图调动作的唯一入口(全局唯一实例,视图不能各造一个私人大脑)
|
|
76
|
+
* @returns 模块级唯一的 ${type}Module
|
|
77
|
+
*/
|
|
78
|
+
export function use${type}Module(): ${type}Module {
|
|
79
|
+
if (!${domain.name}Module) ${domain.name}Module = create${type}Module()
|
|
80
|
+
return ${domain.name}Module
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* 中枢:这里只生成一次脚手架,之后归模块所有,sync 不再覆盖。
|
|
84
|
+
* 只挂载动作,不判断、不 await、不调接口;不导出,避免视图绕开单例。 */
|
|
85
|
+
function create${type}Module(ctx: ${type}Context = create${type}Context()): ${type}Module {
|
|
86
|
+
return {
|
|
87
|
+
${wire.map((line) => ` ${line}`).join(',\n')}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
`
|
|
91
|
+
}
|