tha-cli 0.0.0-stage → 0.1.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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +193 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -2
  4. package/package.json +42 -4
  5. package/tha/cli.mjs +116 -0
  6. package/tha/commands/adopt.mjs +119 -0
  7. package/tha/commands/check.mjs +226 -0
  8. package/tha/commands/diff.mjs +70 -0
  9. package/tha/commands/init.mjs +262 -0
  10. package/tha/commands/remove.mjs +328 -0
  11. package/tha/commands/sync.mjs +826 -0
  12. package/tha/config/field.mjs +178 -0
  13. package/tha/config/load.mjs +188 -0
  14. package/tha/config/manifest.mjs +75 -0
  15. package/tha/config/paths.mjs +124 -0
  16. package/tha/config/project.mjs +52 -0
  17. package/tha/config/rules.mjs +85 -0
  18. package/tha/config/schema.mjs +306 -0
  19. package/tha/config/template.mjs +78 -0
  20. package/tha/generate/domain/api.mjs +67 -0
  21. package/tha/generate/domain/context.mjs +76 -0
  22. package/tha/generate/domain/detail.mjs +35 -0
  23. package/tha/generate/domain/entry.mjs +91 -0
  24. package/tha/generate/domain/executors.mjs +93 -0
  25. package/tha/generate/domain/form.mjs +34 -0
  26. package/tha/generate/domain/index.mjs +72 -0
  27. package/tha/generate/domain/permissions.mjs +35 -0
  28. package/tha/generate/domain/store.mjs +68 -0
  29. package/tha/generate/domain/view.mjs +29 -0
  30. package/tha/generate/global.mjs +111 -0
  31. package/tha/generate/header.mjs +29 -0
  32. package/tha/generate/names.mjs +45 -0
  33. package/tha/generate/plan.mjs +41 -0
  34. package/tha/kit/ThaCrudPage.vue +344 -0
  35. package/tha/kit/ThaDetail.vue +146 -0
  36. package/tha/kit/ThaForm.vue +194 -0
  37. package/tha/kit/request.ts +30 -0
  38. package/tha/kit/transport.ts +11 -0
  39. package/tha/utils/args.mjs +59 -0
  40. package/tha/utils/compare.mjs +38 -0
  41. package/tha/utils/files.mjs +175 -0
  42. package/tha/utils/format.mjs +23 -0
  43. package/tha/utils/output.mjs +79 -0
  44. package/tha/utils/style.mjs +147 -0
  45. package/tha/utils/ts.mjs +73 -0
  46. package/tha/verify/ast.mjs +143 -0
  47. package/tha/verify/boundary.mjs +219 -0
  48. package/tha/verify/contract.mjs +186 -0
  49. package/tha/verify/openapi.mjs +345 -0
  50. package/tha/verify/rules.mjs +91 -0
  51. package/tha/verify/scan.mjs +59 -0
  52. 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
+ }