@robsun/create-keystone-app 0.2.15 → 0.4.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.
Files changed (96) hide show
  1. package/README.md +46 -44
  2. package/dist/create-keystone-app.js +347 -10
  3. package/dist/create-module.js +1217 -1187
  4. package/package.json +1 -1
  5. package/template/.claude/skills/keystone-implement/SKILL.md +113 -0
  6. package/template/.claude/skills/keystone-implement/references/CHECKLIST.md +91 -0
  7. package/template/.claude/skills/keystone-implement/references/PATTERNS.md +1088 -0
  8. package/template/.claude/skills/keystone-implement/references/SCHEMA.md +135 -0
  9. package/template/.claude/skills/keystone-implement/references/TESTING.md +231 -0
  10. package/template/.claude/skills/keystone-requirements/SKILL.md +296 -0
  11. package/template/.claude/skills/keystone-requirements/references/CONFIRM_TEMPLATE.md +170 -0
  12. package/template/.claude/skills/keystone-requirements/references/SCHEMA.md +135 -0
  13. package/template/.eslintrc.js +3 -0
  14. package/template/.github/workflows/ci.yml +30 -0
  15. package/template/.github/workflows/release.yml +32 -0
  16. package/template/.golangci.yml +11 -0
  17. package/template/README.md +82 -81
  18. package/template/apps/server/README.md +8 -0
  19. package/template/apps/server/cmd/server/main.go +27 -185
  20. package/template/apps/server/config.example.yaml +31 -1
  21. package/template/apps/server/config.yaml +31 -1
  22. package/template/apps/server/go.mod +61 -19
  23. package/template/apps/server/go.sum +185 -32
  24. package/template/apps/server/internal/frontend/embed.go +3 -8
  25. package/template/apps/server/internal/modules/example/README.md +18 -0
  26. package/template/apps/server/internal/modules/example/api/handler/handler_test.go +9 -0
  27. package/template/apps/server/internal/modules/example/api/handler/item_handler.go +468 -165
  28. package/template/apps/server/internal/modules/example/bootstrap/seeds/item.go +217 -8
  29. package/template/apps/server/internal/modules/example/domain/models/item.go +40 -7
  30. package/template/apps/server/internal/modules/example/domain/service/approval_callback.go +68 -0
  31. package/template/apps/server/internal/modules/example/domain/service/approval_schema.go +41 -0
  32. package/template/apps/server/internal/modules/example/domain/service/errors.go +20 -22
  33. package/template/apps/server/internal/modules/example/domain/service/item_service.go +267 -7
  34. package/template/apps/server/internal/modules/example/domain/service/item_service_test.go +281 -0
  35. package/template/apps/server/internal/modules/example/i18n/keys.go +32 -20
  36. package/template/apps/server/internal/modules/example/i18n/locales/en-US.json +30 -18
  37. package/template/apps/server/internal/modules/example/i18n/locales/zh-CN.json +30 -18
  38. package/template/apps/server/internal/modules/example/infra/exporter/item_exporter.go +119 -0
  39. package/template/apps/server/internal/modules/example/infra/importer/item_importer.go +77 -0
  40. package/template/apps/server/internal/modules/example/infra/repository/item_repository.go +99 -49
  41. package/template/apps/server/internal/modules/example/module.go +171 -97
  42. package/template/apps/server/internal/modules/example/tests/integration_test.go +7 -0
  43. package/template/apps/server/internal/modules/manifest.go +7 -7
  44. package/template/apps/web/README.md +4 -2
  45. package/template/apps/web/package.json +1 -1
  46. package/template/apps/web/src/app.config.ts +8 -6
  47. package/template/apps/web/src/index.css +7 -3
  48. package/template/apps/web/src/main.tsx +2 -5
  49. package/template/apps/web/src/modules/example/help/en-US/faq.md +27 -0
  50. package/template/apps/web/src/modules/example/help/en-US/items.md +30 -0
  51. package/template/apps/web/src/modules/example/help/en-US/overview.md +31 -0
  52. package/template/apps/web/src/modules/example/help/zh-CN/faq.md +27 -0
  53. package/template/apps/web/src/modules/example/help/zh-CN/items.md +31 -0
  54. package/template/apps/web/src/modules/example/help/zh-CN/overview.md +32 -0
  55. package/template/apps/web/src/modules/example/locales/en-US/example.json +99 -32
  56. package/template/apps/web/src/modules/example/locales/zh-CN/example.json +85 -18
  57. package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +840 -237
  58. package/template/apps/web/src/modules/example/services/exampleItems.ts +79 -8
  59. package/template/apps/web/src/modules/example/types.ts +14 -1
  60. package/template/apps/web/src/modules/index.ts +1 -0
  61. package/template/apps/web/vite.config.ts +9 -3
  62. package/template/docs/CONVENTIONS.md +17 -17
  63. package/template/package.json +4 -5
  64. package/template/pnpm-lock.yaml +76 -5
  65. package/template/scripts/build.bat +15 -3
  66. package/template/scripts/build.sh +9 -3
  67. package/template/scripts/check-help.js +249 -0
  68. package/template/scripts/compress-assets.js +89 -0
  69. package/template/scripts/test.bat +23 -0
  70. package/template/scripts/test.sh +16 -0
  71. package/template/.claude/skills/keystone-dev/SKILL.md +0 -90
  72. package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
  73. package/template/.claude/skills/keystone-dev/references/APPROVAL.md +0 -121
  74. package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  75. package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +0 -285
  76. package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +0 -390
  77. package/template/.claude/skills/keystone-dev/references/PATTERNS.md +0 -605
  78. package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +0 -2710
  79. package/template/.claude/skills/keystone-dev/references/TESTING.md +0 -44
  80. package/template/.codex/skills/keystone-dev/SKILL.md +0 -90
  81. package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
  82. package/template/.codex/skills/keystone-dev/references/APPROVAL.md +0 -121
  83. package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +0 -261
  84. package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +0 -285
  85. package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +0 -390
  86. package/template/.codex/skills/keystone-dev/references/PATTERNS.md +0 -605
  87. package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +0 -2710
  88. package/template/.codex/skills/keystone-dev/references/TESTING.md +0 -44
  89. package/template/apps/server/internal/app/routes/module_routes.go +0 -16
  90. package/template/apps/server/internal/app/routes/routes.go +0 -226
  91. package/template/apps/server/internal/app/startup/startup.go +0 -74
  92. package/template/apps/server/internal/frontend/handler.go +0 -122
  93. package/template/apps/server/internal/modules/registry.go +0 -145
  94. package/template/apps/web/src/modules/example/help/faq.md +0 -23
  95. package/template/apps/web/src/modules/example/help/items.md +0 -26
  96. package/template/apps/web/src/modules/example/help/overview.md +0 -25
@@ -1,114 +1,114 @@
1
- #!/usr/bin/env node
2
- const fs = require('fs')
3
- const path = require('path')
4
- const readline = require('readline/promises')
5
- const { stdin: input, stdout: output } = require('process')
6
-
7
- const usage = [
8
- 'Usage: create-module <module-name> [options]',
9
- '',
10
- 'Options:',
11
- ' --frontend-only Only generate the frontend module',
12
- ' --backend-only Only generate the backend module',
13
- ' --with-crud Include CRUD example code',
14
- ' --with-approval Include approval workflow code (implies --with-crud)',
15
- ' --skip-register Skip auto registration steps',
16
- ' -h, --help Show help',
17
- '',
18
- 'Run without options to use guided prompts.',
19
- ].join('\n')
20
-
21
- main().catch((err) => {
22
- const message = err instanceof Error ? err.message : String(err)
23
- console.error(message)
24
- process.exit(1)
25
- })
26
-
27
- async function main() {
28
- const args = parseArgs(process.argv.slice(2))
29
- if (args.help) {
30
- console.log(usage)
31
- return
32
- }
33
-
34
- const canPrompt = isInteractive()
35
- const rl = canPrompt ? readline.createInterface({ input, output }) : null
36
-
37
- let includeFrontend = !args.backendOnly
38
- let includeBackend = !args.frontendOnly
39
- let withCrud = args.withCrud || args.withApproval // --with-approval implies --with-crud
40
- let withApproval = args.withApproval
41
- let skipRegister = args.skipRegister
42
-
43
- try {
44
- if (!args.target) {
45
- if (!rl) {
46
- console.error(usage)
47
- process.exit(1)
48
- }
49
- args.target = await promptText(rl, 'Module name')
50
- }
51
-
52
- if (args.frontendOnly && args.backendOnly) {
53
- fail('Use either --frontend-only or --backend-only, not both.')
54
- }
55
-
56
- if (!args.frontendOnly && !args.backendOnly && rl) {
57
- const selection = await promptSelect(
58
- rl,
59
- 'Generate which module(s)?',
60
- ['frontend + backend', 'frontend only', 'backend only'],
61
- 0
62
- )
63
- if (selection === 'frontend only') {
64
- includeFrontend = true
65
- includeBackend = false
66
- } else if (selection === 'backend only') {
67
- includeFrontend = false
68
- includeBackend = true
69
- }
70
- }
71
-
72
- if (!args.withCrud && !args.withApproval && rl) {
73
- withCrud = await promptConfirm(rl, 'Include CRUD example code', false)
74
- }
75
- if (withCrud && !args.withApproval && rl) {
76
- withApproval = await promptConfirm(rl, 'Include approval workflow code', false)
77
- }
78
- if (!args.skipRegister && rl) {
79
- const autoRegister = await promptConfirm(rl, 'Auto-register module', true)
80
- skipRegister = !autoRegister
81
- }
82
- } finally {
83
- if (rl) {
84
- rl.close()
85
- }
86
- }
87
-
88
- const names = buildNames(args.target)
89
- const rootDir = resolveProjectRoot(process.cwd(), includeFrontend, includeBackend)
90
-
91
- const frontendSourceDir = path.join(rootDir, 'apps', 'web', 'src', 'modules', 'example')
92
- const frontendTargetDir = path.join(rootDir, 'apps', 'web', 'src', 'modules', names.kebab)
93
- const backendSourceDir = path.join(rootDir, 'apps', 'server', 'internal', 'modules', 'example')
94
- const backendTargetDir = path.join(rootDir, 'apps', 'server', 'internal', 'modules', names.kebab)
95
-
96
- if (includeFrontend) {
97
- if (!fs.existsSync(frontendSourceDir)) {
98
- fail(`Frontend example module not found at ${frontendSourceDir}`)
99
- }
100
- if (fs.existsSync(frontendTargetDir)) {
101
- fail(`Frontend module already exists: ${frontendTargetDir}`)
102
- }
103
- copyModule(frontendSourceDir, frontendTargetDir, names, 'frontend')
104
- if (!withCrud) {
105
- pruneFrontendCrud(frontendTargetDir, names)
106
- }
107
- if (withApproval) {
108
- addFrontendApproval(frontendTargetDir, names)
109
- }
110
- }
111
-
1
+ #!/usr/bin/env node
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const readline = require('readline/promises')
5
+ const { stdin: input, stdout: output } = require('process')
6
+
7
+ const usage = [
8
+ 'Usage: create-module <module-name> [options]',
9
+ '',
10
+ 'Options:',
11
+ ' --frontend-only Only generate the frontend module',
12
+ ' --backend-only Only generate the backend module',
13
+ ' --with-crud Include CRUD example code',
14
+ ' --with-approval Include approval workflow code (implies --with-crud)',
15
+ ' --skip-register Skip auto registration steps',
16
+ ' -h, --help Show help',
17
+ '',
18
+ 'Run without options to use guided prompts.',
19
+ ].join('\n')
20
+
21
+ main().catch((err) => {
22
+ const message = err instanceof Error ? err.message : String(err)
23
+ console.error(message)
24
+ process.exit(1)
25
+ })
26
+
27
+ async function main() {
28
+ const args = parseArgs(process.argv.slice(2))
29
+ if (args.help) {
30
+ console.log(usage)
31
+ return
32
+ }
33
+
34
+ const canPrompt = isInteractive()
35
+ const rl = canPrompt ? readline.createInterface({ input, output }) : null
36
+
37
+ let includeFrontend = !args.backendOnly
38
+ let includeBackend = !args.frontendOnly
39
+ let withCrud = args.withCrud || args.withApproval // --with-approval implies --with-crud
40
+ let withApproval = args.withApproval
41
+ let skipRegister = args.skipRegister
42
+
43
+ try {
44
+ if (!args.target) {
45
+ if (!rl) {
46
+ console.error(usage)
47
+ process.exit(1)
48
+ }
49
+ args.target = await promptText(rl, 'Module name')
50
+ }
51
+
52
+ if (args.frontendOnly && args.backendOnly) {
53
+ fail('Use either --frontend-only or --backend-only, not both.')
54
+ }
55
+
56
+ if (!args.frontendOnly && !args.backendOnly && rl) {
57
+ const selection = await promptSelect(
58
+ rl,
59
+ 'Generate which module(s)?',
60
+ ['frontend + backend', 'frontend only', 'backend only'],
61
+ 0
62
+ )
63
+ if (selection === 'frontend only') {
64
+ includeFrontend = true
65
+ includeBackend = false
66
+ } else if (selection === 'backend only') {
67
+ includeFrontend = false
68
+ includeBackend = true
69
+ }
70
+ }
71
+
72
+ if (!args.withCrud && !args.withApproval && rl) {
73
+ withCrud = await promptConfirm(rl, 'Include CRUD example code', false)
74
+ }
75
+ if (withCrud && !args.withApproval && rl) {
76
+ withApproval = await promptConfirm(rl, 'Include approval workflow code', false)
77
+ }
78
+ if (!args.skipRegister && rl) {
79
+ const autoRegister = await promptConfirm(rl, 'Auto-register module', true)
80
+ skipRegister = !autoRegister
81
+ }
82
+ } finally {
83
+ if (rl) {
84
+ rl.close()
85
+ }
86
+ }
87
+
88
+ const names = buildNames(args.target)
89
+ const rootDir = resolveProjectRoot(process.cwd(), includeFrontend, includeBackend)
90
+
91
+ const frontendSourceDir = path.join(rootDir, 'apps', 'web', 'src', 'modules', 'example')
92
+ const frontendTargetDir = path.join(rootDir, 'apps', 'web', 'src', 'modules', names.kebab)
93
+ const backendSourceDir = path.join(rootDir, 'apps', 'server', 'internal', 'modules', 'example')
94
+ const backendTargetDir = path.join(rootDir, 'apps', 'server', 'internal', 'modules', names.kebab)
95
+
96
+ if (includeFrontend) {
97
+ if (!fs.existsSync(frontendSourceDir)) {
98
+ fail(`Frontend example module not found at ${frontendSourceDir}`)
99
+ }
100
+ if (fs.existsSync(frontendTargetDir)) {
101
+ fail(`Frontend module already exists: ${frontendTargetDir}`)
102
+ }
103
+ copyModule(frontendSourceDir, frontendTargetDir, names, 'frontend')
104
+ if (!withCrud) {
105
+ pruneFrontendCrud(frontendTargetDir, names)
106
+ }
107
+ if (withApproval) {
108
+ addFrontendApproval(frontendTargetDir, names)
109
+ }
110
+ }
111
+
112
112
  if (includeBackend) {
113
113
  if (!fs.existsSync(backendSourceDir)) {
114
114
  fail(`Backend example module not found at ${backendSourceDir}`)
@@ -123,1094 +123,1124 @@ async function main() {
123
123
  if (withApproval) {
124
124
  addBackendApproval(backendTargetDir, names)
125
125
  }
126
+ writeBackendReadme(backendTargetDir, names)
126
127
  }
127
-
128
- if (!skipRegister) {
129
- if (includeFrontend) {
130
- registerFrontendModule(path.join(rootDir, 'apps', 'web', 'src', 'main.tsx'), names)
131
- }
132
- if (includeBackend) {
133
- registerBackendModule(
134
- path.join(rootDir, 'apps', 'server', 'internal', 'modules', 'manifest.go'),
135
- path.join(rootDir, 'apps', 'server', 'config.yaml'),
136
- names
137
- )
138
- }
139
- }
140
-
141
- const targets = []
142
- if (includeFrontend) targets.push('frontend')
143
- if (includeBackend) targets.push('backend')
144
-
145
- console.log(`Created ${names.kebab} module (${targets.join(' + ')})`)
146
- if (skipRegister) {
147
- console.log('Registration skipped. Update the app entrypoints manually.')
148
- } else {
149
- console.log('Registration updated.')
150
- }
151
- if (!withCrud) {
152
- console.log('Generated minimal scaffolding (no CRUD example).')
153
- }
154
- if (withApproval) {
155
- console.log('Approval workflow code added.')
156
- console.log(' - Backend: domain/service/approval.go, api/handler/approval.go')
157
- console.log(' - Frontend: components/ApprovalActions.tsx')
158
- console.log(' Note: Register approval callback in module.go RegisterApprovalCallback()')
159
- }
160
- }
161
-
162
- function isInteractive() {
163
- return Boolean(input.isTTY && output.isTTY)
164
- }
165
-
166
- async function promptText(rl, label) {
167
- while (true) {
168
- const answer = await rl.question(`${label}: `)
169
- const trimmed = answer.trim()
170
- if (trimmed) {
171
- return trimmed
172
- }
173
- console.log('Please enter a value.')
174
- }
175
- }
176
-
177
- async function promptSelect(rl, label, choices, defaultIndex) {
178
- while (true) {
179
- const options = choices.map((choice, index) => ` ${index + 1}) ${choice}`).join('\n')
180
- const answer = await rl.question(
181
- `${label}\n${options}\nSelect an option [${defaultIndex + 1}]: `
182
- )
183
- const normalized = answer.trim().toLowerCase()
184
- if (!normalized) {
185
- return choices[defaultIndex]
186
- }
187
- const numeric = Number(normalized)
188
- if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
189
- return choices[numeric - 1]
190
- }
191
- const match = choices.find((choice) => choice.toLowerCase() === normalized)
192
- if (match) {
193
- return match
194
- }
195
- console.log('Invalid selection. Try again.')
196
- }
197
- }
198
-
199
- async function promptConfirm(rl, label, defaultValue) {
200
- while (true) {
201
- const suffix = defaultValue ? 'Y/n' : 'y/N'
202
- const answer = await rl.question(`${label} (${suffix}): `)
203
- const normalized = answer.trim().toLowerCase()
204
- if (!normalized) {
205
- return defaultValue
206
- }
207
- if (['y', 'yes'].includes(normalized)) {
208
- return true
209
- }
210
- if (['n', 'no'].includes(normalized)) {
211
- return false
212
- }
213
- console.log('Please enter y or n.')
214
- }
215
- }
216
-
217
- function buildNames(raw) {
218
- const words = splitWords(raw)
219
- if (words.length === 0) {
220
- fail(`Invalid module name: ${raw}`)
221
- }
222
- const capitalized = words.map(capitalize)
223
- const kebab = words.join('-')
224
- const camel = words[0] + capitalized.slice(1).join('')
225
- const pascal = capitalized.join('')
226
- const lower = words.join('')
227
- const display = capitalized.join(' ')
228
- return {
229
- raw,
230
- words,
231
- kebab,
232
- camel,
233
- pascal,
234
- lower,
235
- display,
236
- }
237
- }
238
-
239
- function splitWords(value) {
240
- const normalized = String(value || '')
241
- .trim()
242
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
243
- .replace(/[^a-zA-Z0-9]+/g, ' ')
244
- .trim()
245
- if (!normalized) return []
246
- return normalized.split(/\s+/).map((word) => word.toLowerCase())
247
- }
248
-
249
- function capitalize(value) {
250
- return value ? value[0].toUpperCase() + value.slice(1) : ''
251
- }
252
-
253
- function resolveProjectRoot(startDir, includeFrontend, includeBackend) {
254
- const candidates = [
255
- startDir,
256
- path.join(startDir, 'packages', 'create-keystone-app', 'template'),
257
- path.join(startDir, 'template'),
258
- ]
259
- for (const candidate of candidates) {
260
- const frontendOk =
261
- !includeFrontend || fs.existsSync(path.join(candidate, 'apps', 'web', 'src', 'modules', 'example'))
262
- const backendOk =
263
- !includeBackend || fs.existsSync(path.join(candidate, 'apps', 'server', 'internal', 'modules', 'example'))
264
- if (frontendOk && backendOk) {
265
- return candidate
266
- }
267
- }
268
- fail('Could not find a project with apps/web and apps/server module templates.')
269
- }
270
-
271
- function copyModule(srcDir, destDir, names, mode) {
272
- copyDir(srcDir, destDir, (entryName) => renameSegment(entryName, names), (content, filePath) =>
273
- transformContent(content, filePath, names, mode)
274
- )
275
- }
276
-
277
- function copyDir(src, dest, renameEntry, transform) {
278
- fs.mkdirSync(dest, { recursive: true })
279
- const entries = fs.readdirSync(src, { withFileTypes: true })
280
- for (const entry of entries) {
281
- const srcPath = path.join(src, entry.name)
282
- const nextName = renameEntry(entry.name)
283
- const destPath = path.join(dest, nextName)
284
- if (entry.isDirectory()) {
285
- copyDir(srcPath, destPath, renameEntry, transform)
286
- } else {
287
- copyFile(srcPath, destPath, transform)
288
- }
289
- }
290
- }
291
-
292
- function copyFile(src, dest, transform) {
293
- const content = fs.readFileSync(src, 'utf8')
294
- const next = transform ? transform(content, dest) : content
295
- fs.mkdirSync(path.dirname(dest), { recursive: true })
296
- fs.writeFileSync(dest, next, 'utf8')
297
- }
298
-
299
- function renameSegment(segment, names) {
300
- if (segment === 'example') {
301
- return names.kebab
302
- }
303
- return segment.replace(/Example/g, names.pascal).replace(/example/g, names.camel)
304
- }
305
-
306
- function transformContent(content, filePath, names, mode) {
307
- const ext = path.extname(filePath).toLowerCase()
308
- if (ext === '.go') {
309
- return replaceInCode(
310
- content,
311
- [
312
- ['Example', names.pascal],
313
- ['example', names.lower],
314
- ],
315
- (value) => replaceStringLiteral(value, names, mode === 'backend' ? names.lower : names.camel)
316
- )
317
- }
318
- if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
319
- return replaceInCode(
320
- content,
321
- [
322
- ['Example', names.pascal],
323
- ['example', names.camel],
324
- ],
325
- (value) => replaceStringLiteral(value, names, names.camel)
326
- )
327
- }
328
- return replaceText(content, names)
329
- }
330
-
331
- function replaceText(content, names) {
332
- return applyReplacements(content, [
333
- ['Example', names.display],
334
- ['example', names.kebab],
335
- ])
336
- }
337
-
338
- function replaceStringLiteral(value, names, pathWord) {
339
- if (value.startsWith('./') || value.startsWith('../')) {
340
- return applyReplacements(value, [
341
- ['Example', names.pascal],
342
- ['example', pathWord],
343
- ])
344
- }
345
- return applyReplacements(value, [
346
- ['Example', names.display],
347
- ['example', names.kebab],
348
- ])
349
- }
350
-
351
- function replaceInCode(content, codeReplacements, stringReplacer) {
352
- let out = ''
353
- let i = 0
354
- let start = 0
355
- let inString = null
356
- let escaped = false
357
- while (i < content.length) {
358
- const ch = content[i]
359
- if (!inString) {
360
- if (ch === '"' || ch === "'" || ch === '`') {
361
- out += applyReplacements(content.slice(start, i), codeReplacements)
362
- inString = ch
363
- out += ch
364
- i += 1
365
- start = i
366
- continue
367
- }
368
- i += 1
369
- continue
370
- }
371
- if (escaped) {
372
- escaped = false
373
- i += 1
374
- continue
375
- }
376
- if (ch === '\\' && inString !== '`') {
377
- escaped = true
378
- i += 1
379
- continue
380
- }
381
- if (ch === inString) {
382
- out += stringReplacer(content.slice(start, i))
383
- out += ch
384
- inString = null
385
- i += 1
386
- start = i
387
- continue
388
- }
389
- i += 1
390
- }
391
- if (start < content.length) {
392
- const tail = content.slice(start)
393
- out += inString ? stringReplacer(tail) : applyReplacements(tail, codeReplacements)
394
- }
395
- return out
396
- }
397
-
398
- function applyReplacements(value, replacements) {
399
- let next = value
400
- for (const [from, to] of replacements) {
401
- next = next.split(from).join(to)
402
- }
403
- return next
404
- }
405
-
406
- function pruneFrontendCrud(targetDir, names) {
407
- const servicesDir = path.join(targetDir, 'services')
408
- if (fs.existsSync(servicesDir)) {
409
- fs.rmSync(servicesDir, { recursive: true, force: true })
410
- }
411
- const typesFile = path.join(targetDir, 'types.ts')
412
- if (fs.existsSync(typesFile)) {
413
- fs.rmSync(typesFile, { force: true })
128
+
129
+ if (!skipRegister) {
130
+ if (includeFrontend) {
131
+ registerFrontendModule(path.join(rootDir, 'apps', 'web', 'src', 'main.tsx'), names)
132
+ }
133
+ if (includeBackend) {
134
+ registerBackendModule(
135
+ path.join(rootDir, 'apps', 'server', 'internal', 'modules', 'manifest.go'),
136
+ path.join(rootDir, 'apps', 'server', 'config.yaml'),
137
+ names
138
+ )
139
+ }
140
+ }
141
+
142
+ const targets = []
143
+ if (includeFrontend) targets.push('frontend')
144
+ if (includeBackend) targets.push('backend')
145
+
146
+ console.log(`Created ${names.kebab} module (${targets.join(' + ')})`)
147
+ if (skipRegister) {
148
+ console.log('Registration skipped. Update the app entrypoints manually.')
149
+ } else {
150
+ console.log('Registration updated.')
151
+ }
152
+ if (!withCrud) {
153
+ console.log('Generated minimal scaffolding (no CRUD example).')
154
+ }
155
+ if (withApproval) {
156
+ console.log('Approval workflow code added.')
157
+ console.log(' - Backend: domain/service/approval.go, api/handler/approval.go')
158
+ console.log(' - Frontend: components/ApprovalActions.tsx')
159
+ console.log(' Note: Register approval callback in module.go RegisterApprovalCallback()')
160
+ }
161
+ }
162
+
163
+ function isInteractive() {
164
+ return Boolean(input.isTTY && output.isTTY)
165
+ }
166
+
167
+ async function promptText(rl, label) {
168
+ while (true) {
169
+ const answer = await rl.question(`${label}: `)
170
+ const trimmed = answer.trim()
171
+ if (trimmed) {
172
+ return trimmed
173
+ }
174
+ console.log('Please enter a value.')
175
+ }
176
+ }
177
+
178
+ async function promptSelect(rl, label, choices, defaultIndex) {
179
+ while (true) {
180
+ const options = choices.map((choice, index) => ` ${index + 1}) ${choice}`).join('\n')
181
+ const answer = await rl.question(
182
+ `${label}\n${options}\nSelect an option [${defaultIndex + 1}]: `
183
+ )
184
+ const normalized = answer.trim().toLowerCase()
185
+ if (!normalized) {
186
+ return choices[defaultIndex]
187
+ }
188
+ const numeric = Number(normalized)
189
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
190
+ return choices[numeric - 1]
191
+ }
192
+ const match = choices.find((choice) => choice.toLowerCase() === normalized)
193
+ if (match) {
194
+ return match
195
+ }
196
+ console.log('Invalid selection. Try again.')
197
+ }
198
+ }
199
+
200
+ async function promptConfirm(rl, label, defaultValue) {
201
+ while (true) {
202
+ const suffix = defaultValue ? 'Y/n' : 'y/N'
203
+ const answer = await rl.question(`${label} (${suffix}): `)
204
+ const normalized = answer.trim().toLowerCase()
205
+ if (!normalized) {
206
+ return defaultValue
207
+ }
208
+ if (['y', 'yes'].includes(normalized)) {
209
+ return true
210
+ }
211
+ if (['n', 'no'].includes(normalized)) {
212
+ return false
213
+ }
214
+ console.log('Please enter y or n.')
215
+ }
216
+ }
217
+
218
+ function buildNames(raw) {
219
+ const words = splitWords(raw)
220
+ if (words.length === 0) {
221
+ fail(`Invalid module name: ${raw}`)
222
+ }
223
+ const capitalized = words.map(capitalize)
224
+ const kebab = words.join('-')
225
+ const camel = words[0] + capitalized.slice(1).join('')
226
+ const pascal = capitalized.join('')
227
+ const lower = words.join('')
228
+ const display = capitalized.join(' ')
229
+ return {
230
+ raw,
231
+ words,
232
+ kebab,
233
+ camel,
234
+ pascal,
235
+ lower,
236
+ display,
237
+ }
238
+ }
239
+
240
+ function splitWords(value) {
241
+ const normalized = String(value || '')
242
+ .trim()
243
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
244
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
245
+ .trim()
246
+ if (!normalized) return []
247
+ return normalized.split(/\s+/).map((word) => word.toLowerCase())
248
+ }
249
+
250
+ function capitalize(value) {
251
+ return value ? value[0].toUpperCase() + value.slice(1) : ''
252
+ }
253
+
254
+ function resolveProjectRoot(startDir, includeFrontend, includeBackend) {
255
+ const candidates = [
256
+ startDir,
257
+ path.join(startDir, 'packages', 'create-keystone-app', 'template'),
258
+ path.join(startDir, 'template'),
259
+ ]
260
+ for (const candidate of candidates) {
261
+ const frontendOk =
262
+ !includeFrontend || fs.existsSync(path.join(candidate, 'apps', 'web', 'src', 'modules', 'example'))
263
+ const backendOk =
264
+ !includeBackend || fs.existsSync(path.join(candidate, 'apps', 'server', 'internal', 'modules', 'example'))
265
+ if (frontendOk && backendOk) {
266
+ return candidate
267
+ }
268
+ }
269
+ fail('Could not find a project with apps/web and apps/server module templates.')
270
+ }
271
+
272
+ function copyModule(srcDir, destDir, names, mode) {
273
+ copyDir(srcDir, destDir, (entryName) => renameSegment(entryName, names), (content, filePath) =>
274
+ transformContent(content, filePath, names, mode)
275
+ )
276
+ }
277
+
278
+ function copyDir(src, dest, renameEntry, transform) {
279
+ fs.mkdirSync(dest, { recursive: true })
280
+ const entries = fs.readdirSync(src, { withFileTypes: true })
281
+ for (const entry of entries) {
282
+ const srcPath = path.join(src, entry.name)
283
+ const nextName = renameEntry(entry.name)
284
+ const destPath = path.join(dest, nextName)
285
+ if (entry.isDirectory()) {
286
+ copyDir(srcPath, destPath, renameEntry, transform)
287
+ } else {
288
+ copyFile(srcPath, destPath, transform)
289
+ }
290
+ }
291
+ }
292
+
293
+ function copyFile(src, dest, transform) {
294
+ const content = fs.readFileSync(src, 'utf8')
295
+ const next = transform ? transform(content, dest) : content
296
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
297
+ fs.writeFileSync(dest, next, 'utf8')
298
+ }
299
+
300
+ function renameSegment(segment, names) {
301
+ if (segment === 'example') {
302
+ return names.kebab
303
+ }
304
+ return segment.replace(/Example/g, names.pascal).replace(/example/g, names.camel)
305
+ }
306
+
307
+ function transformContent(content, filePath, names, mode) {
308
+ const ext = path.extname(filePath).toLowerCase()
309
+ if (ext === '.go') {
310
+ return replaceInCode(
311
+ content,
312
+ [
313
+ ['Example', names.pascal],
314
+ ['example', names.lower],
315
+ ],
316
+ (value) => replaceStringLiteral(value, names, mode === 'backend' ? names.lower : names.camel)
317
+ )
318
+ }
319
+ if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
320
+ return replaceInCode(
321
+ content,
322
+ [
323
+ ['Example', names.pascal],
324
+ ['example', names.camel],
325
+ ],
326
+ (value) => replaceStringLiteral(value, names, names.camel)
327
+ )
328
+ }
329
+ return replaceText(content, names)
330
+ }
331
+
332
+ function replaceText(content, names) {
333
+ return applyReplacements(content, [
334
+ ['Example', names.display],
335
+ ['example', names.kebab],
336
+ ])
337
+ }
338
+
339
+ function replaceStringLiteral(value, names, pathWord) {
340
+ if (value.startsWith('./') || value.startsWith('../')) {
341
+ return applyReplacements(value, [
342
+ ['Example', names.pascal],
343
+ ['example', pathWord],
344
+ ])
345
+ }
346
+ return applyReplacements(value, [
347
+ ['Example', names.display],
348
+ ['example', names.kebab],
349
+ ])
350
+ }
351
+
352
+ function replaceInCode(content, codeReplacements, stringReplacer) {
353
+ let out = ''
354
+ let i = 0
355
+ let start = 0
356
+ let inString = null
357
+ let escaped = false
358
+ while (i < content.length) {
359
+ const ch = content[i]
360
+ if (!inString) {
361
+ if (ch === '"' || ch === "'" || ch === '`') {
362
+ out += applyReplacements(content.slice(start, i), codeReplacements)
363
+ inString = ch
364
+ out += ch
365
+ i += 1
366
+ start = i
367
+ continue
368
+ }
369
+ i += 1
370
+ continue
371
+ }
372
+ if (escaped) {
373
+ escaped = false
374
+ i += 1
375
+ continue
376
+ }
377
+ if (ch === '\\' && inString !== '`') {
378
+ escaped = true
379
+ i += 1
380
+ continue
381
+ }
382
+ if (ch === inString) {
383
+ out += stringReplacer(content.slice(start, i))
384
+ out += ch
385
+ inString = null
386
+ i += 1
387
+ start = i
388
+ continue
389
+ }
390
+ i += 1
391
+ }
392
+ if (start < content.length) {
393
+ const tail = content.slice(start)
394
+ out += inString ? stringReplacer(tail) : applyReplacements(tail, codeReplacements)
395
+ }
396
+ return out
397
+ }
398
+
399
+ function applyReplacements(value, replacements) {
400
+ let next = value
401
+ for (const [from, to] of replacements) {
402
+ next = next.split(from).join(to)
403
+ }
404
+ return next
405
+ }
406
+
407
+ function pruneFrontendCrud(targetDir, names) {
408
+ const servicesDir = path.join(targetDir, 'services')
409
+ if (fs.existsSync(servicesDir)) {
410
+ fs.rmSync(servicesDir, { recursive: true, force: true })
411
+ }
412
+ const typesFile = path.join(targetDir, 'types.ts')
413
+ if (fs.existsSync(typesFile)) {
414
+ fs.rmSync(typesFile, { force: true })
415
+ }
416
+ const pageFile = path.join(targetDir, 'pages', `${names.pascal}ItemsPage.tsx`)
417
+ const content = [
418
+ "import { Card, Typography } from 'antd'",
419
+ '',
420
+ `export function ${names.pascal}ItemsPage() {`,
421
+ ' return (',
422
+ ` <Card title="${names.display}">`,
423
+ ' <Typography.Paragraph type="secondary">',
424
+ ` ${names.display} module is ready. Start building your pages here.`,
425
+ ' </Typography.Paragraph>',
426
+ ' </Card>',
427
+ ' )',
428
+ '}',
429
+ '',
430
+ ].join('\n')
431
+ fs.writeFileSync(pageFile, content, 'utf8')
432
+ }
433
+
434
+ function pruneBackendCrud(targetDir, names) {
435
+ const dirs = ['api', 'bootstrap', 'domain', 'infra', 'tests']
436
+ for (const dir of dirs) {
437
+ const fullPath = path.join(targetDir, dir)
438
+ if (fs.existsSync(fullPath)) {
439
+ fs.rmSync(fullPath, { recursive: true, force: true })
440
+ }
441
+ }
442
+ const moduleFile = path.join(targetDir, 'module.go')
443
+ const content = [
444
+ `package ${names.lower}`,
445
+ '',
446
+ 'import (',
447
+ '\t"github.com/gin-gonic/gin"',
448
+ '\t"gorm.io/gorm"',
449
+ '',
450
+ '\t"github.com/robsuncn/keystone/domain/permissions"',
451
+ '\t"github.com/robsuncn/keystone/infra/jobs"',
452
+ ')',
453
+ '',
454
+ 'type Module struct{}',
455
+ '',
456
+ 'func NewModule() *Module {',
457
+ '\treturn &Module{}',
458
+ '}',
459
+ '',
460
+ 'func (m *Module) Name() string {',
461
+ `\treturn "${names.kebab}"`,
462
+ '}',
463
+ '',
464
+ 'func (m *Module) RegisterRoutes(_ *gin.RouterGroup) {}',
465
+ '',
466
+ 'func (m *Module) RegisterModels() []interface{} {',
467
+ '\treturn nil',
468
+ '}',
469
+ '',
470
+ 'func (m *Module) RegisterPermissions(reg *permissions.Registry) error {',
471
+ '\tif reg == nil {',
472
+ '\t\treturn nil',
473
+ '\t}',
474
+ `\tif err := reg.CreateMenu("${names.kebab}:overview", "${names.display}", "${names.kebab}", 10); err != nil {`,
475
+ '\t\treturn err',
476
+ '\t}',
477
+ `\tif err := reg.CreateAction("${names.kebab}:overview:view", "View ${names.display}", "${names.kebab}", "${names.kebab}:overview"); err != nil {`,
478
+ '\t\treturn err',
479
+ '\t}',
480
+ '\treturn nil',
481
+ '}',
482
+ '',
483
+ 'func (m *Module) RegisterJobs(_ *jobs.Registry) error {',
484
+ '\treturn nil',
485
+ '}',
486
+ '',
487
+ 'func (m *Module) Migrate(_ *gorm.DB) error {',
488
+ '\treturn nil',
489
+ '}',
490
+ '',
491
+ 'func (m *Module) Seed(_ *gorm.DB) error {',
492
+ '\treturn nil',
493
+ '}',
494
+ '',
495
+ ].join('\n')
496
+ fs.writeFileSync(moduleFile, content, 'utf8')
497
+ }
498
+
499
+ function registerFrontendModule(mainPath, names) {
500
+ if (!fs.existsSync(mainPath)) {
501
+ return
502
+ }
503
+ const content = fs.readFileSync(mainPath, 'utf8')
504
+ const importLine = `import './modules/${names.kebab}'`
505
+ if (content.includes(importLine)) {
506
+ return
507
+ }
508
+ const lines = content.split(/\r?\n/)
509
+ let lastImport = -1
510
+ for (let i = 0; i < lines.length; i += 1) {
511
+ if (/^\s*import\s/.test(lines[i])) {
512
+ lastImport = i
513
+ }
514
+ }
515
+ if (lastImport === -1) {
516
+ lines.unshift(importLine)
517
+ } else {
518
+ lines.splice(lastImport + 1, 0, importLine)
519
+ }
520
+ fs.writeFileSync(mainPath, lines.join('\n'), 'utf8')
521
+ }
522
+
523
+ function registerBackendModule(manifestPath, configPath, names) {
524
+ updateManifest(manifestPath, names)
525
+ updateConfig(configPath, names)
526
+ }
527
+
528
+ function updateManifest(manifestPath, names) {
529
+ if (!fs.existsSync(manifestPath)) {
530
+ return
531
+ }
532
+ const content = fs.readFileSync(manifestPath, 'utf8')
533
+ const lines = content.split(/\r?\n/)
534
+ const importStart = lines.findIndex((line) => line.trim() === 'import (')
535
+ if (importStart === -1) {
536
+ return
537
+ }
538
+ const importEnd = lines.findIndex((line, index) => index > importStart && line.trim() === ')')
539
+ if (importEnd === -1) {
540
+ return
541
+ }
542
+ const importBlock = lines.slice(importStart + 1, importEnd)
543
+ const exampleImport = importBlock.find((line) => line.includes('/internal/modules/example'))
544
+ if (!exampleImport) {
545
+ return
546
+ }
547
+ const pathMatch = exampleImport.match(/\"([^\"]+)\"/)
548
+ if (!pathMatch) {
549
+ return
550
+ }
551
+ const modulePath = pathMatch[1].replace(/\/example$/, `/${names.kebab}`)
552
+ const alias = names.lower
553
+ const importLine = `${alias} "${modulePath}"`
554
+ if (!importBlock.some((line) => line.includes(`/${names.kebab}"`))) {
555
+ lines.splice(importEnd, 0, importLine)
556
+ }
557
+ const registerLine = `\tRegister(${alias}.NewModule())`
558
+ if (!lines.some((line) => line.trim() === registerLine.trim())) {
559
+ const registerIndices = lines
560
+ .map((line, index) => ({ line, index }))
561
+ .filter(({ line }) => line.includes('Register('))
562
+ if (registerIndices.length > 0) {
563
+ lines.splice(registerIndices[registerIndices.length - 1].index + 1, 0, registerLine)
564
+ } else {
565
+ const clearIndex = lines.findIndex((line) => line.includes('Clear()'))
566
+ if (clearIndex !== -1) {
567
+ lines.splice(clearIndex + 1, 0, registerLine)
568
+ }
569
+ }
570
+ }
571
+ fs.writeFileSync(manifestPath, lines.join('\n'), 'utf8')
572
+ }
573
+
574
+ function updateConfig(configPath, names) {
575
+ if (!fs.existsSync(configPath)) {
576
+ return
577
+ }
578
+ const content = fs.readFileSync(configPath, 'utf8')
579
+ if (content.includes(`- "${names.kebab}"`) || content.includes(`- '${names.kebab}'`)) {
580
+ return
581
+ }
582
+ const lines = content.split(/\r?\n/)
583
+ const modulesIndex = lines.findIndex((line) => line.trim() === 'modules:')
584
+ if (modulesIndex === -1) {
585
+ return
586
+ }
587
+ const enabledIndex = lines.findIndex((line, index) => index > modulesIndex && line.trim() === 'enabled:')
588
+ if (enabledIndex === -1) {
589
+ return
590
+ }
591
+ let insertIndex = enabledIndex + 1
592
+ let indent = null
593
+ for (let i = enabledIndex + 1; i < lines.length; i += 1) {
594
+ const line = lines[i]
595
+ if (!line.trim()) continue
596
+ if (!line.trim().startsWith('-')) {
597
+ insertIndex = i
598
+ break
599
+ }
600
+ if (indent === null) {
601
+ indent = line.match(/^\s*/)?.[0] ?? ''
602
+ }
603
+ insertIndex = i + 1
604
+ }
605
+ const prefix = indent ?? ' '
606
+ lines.splice(insertIndex, 0, `${prefix}- "${names.kebab}"`)
607
+ fs.writeFileSync(configPath, lines.join('\n'), 'utf8')
608
+ }
609
+
610
+ function parseArgs(argv) {
611
+ const out = {
612
+ target: null,
613
+ frontendOnly: false,
614
+ backendOnly: false,
615
+ withCrud: false,
616
+ withApproval: false,
617
+ skipRegister: false,
618
+ help: false,
619
+ }
620
+ for (const arg of argv) {
621
+ if (arg === '--help' || arg === '-h') {
622
+ out.help = true
623
+ continue
624
+ }
625
+ if (arg === '--frontend-only') {
626
+ out.frontendOnly = true
627
+ continue
628
+ }
629
+ if (arg === '--backend-only') {
630
+ out.backendOnly = true
631
+ continue
632
+ }
633
+ if (arg === '--with-crud') {
634
+ out.withCrud = true
635
+ continue
636
+ }
637
+ if (arg === '--with-approval') {
638
+ out.withApproval = true
639
+ continue
640
+ }
641
+ if (arg === '--skip-register') {
642
+ out.skipRegister = true
643
+ continue
644
+ }
645
+ if (arg.startsWith('-')) {
646
+ fail(`Unknown option: ${arg}`)
647
+ }
648
+ if (!out.target) {
649
+ out.target = arg
650
+ continue
651
+ }
652
+ fail('Too many arguments.')
653
+ }
654
+ return out
655
+ }
656
+
657
+ // ============================================================================
658
+ // Approval Workflow Code Generation
659
+ // ============================================================================
660
+
661
+ function addBackendApproval(targetDir, names) {
662
+ // 1. Add approval status constants to model
663
+ addApprovalStatusToModel(targetDir, names)
664
+
665
+ // 2. Create approval service file
666
+ createApprovalService(targetDir, names)
667
+
668
+ // 3. Create approval callback file
669
+ createApprovalCallback(targetDir, names)
670
+
671
+ // 4. Create approval handler file
672
+ createApprovalHandler(targetDir, names)
673
+
674
+ // 5. Add approval routes to module.go
675
+ addApprovalRoutes(targetDir, names)
676
+
677
+ // 6. Add approval i18n keys
678
+ addApprovalI18nKeys(targetDir, names)
679
+ }
680
+
681
+ function addApprovalStatusToModel(targetDir, names) {
682
+ // Find the model file - could be item.go or {names.lower}.go
683
+ const modelDir = path.join(targetDir, 'domain', 'models')
684
+ if (!fs.existsSync(modelDir)) return
685
+
686
+ const modelFiles = fs.readdirSync(modelDir).filter((f) => f.endsWith('.go'))
687
+ if (modelFiles.length === 0) return
688
+
689
+ const modelFile = path.join(modelDir, modelFiles[0])
690
+ let content = fs.readFileSync(modelFile, 'utf8')
691
+
692
+ // Add approval status constants if not exists
693
+ if (!content.includes('StatusPending') && !content.includes('StatusDraft')) {
694
+ // Pattern: StatusActive ItemStatus = "active"
695
+ content = content.replace(
696
+ /(StatusActive\s+\w+\s*=\s*"active")/,
697
+ `StatusDraft ItemStatus = "draft"\n\tStatusPending ItemStatus = "pending"\n\tStatusApproved ItemStatus = "approved"\n\tStatusRejected ItemStatus = "rejected"\n\t$1`
698
+ )
699
+
700
+ // Update IsValid to include new statuses
701
+ content = content.replace(
702
+ /case StatusActive, StatusInactive:/,
703
+ `case StatusDraft, StatusPending, StatusApproved, StatusRejected, StatusActive, StatusInactive:`
704
+ )
705
+ }
706
+
707
+ // Add approval fields to struct if not exists
708
+ if (!content.includes('ApprovalInstanceID')) {
709
+ // Pattern: Status ItemStatus `gorm:...` json:"status"`
710
+ // Need to match the full line including the backticks and add before closing brace
711
+ content = content.replace(
712
+ /(Status\s+ItemStatus\s+`[^`]+`)/,
713
+ `$1\n\tApprovalInstanceID *uint \`gorm:"index" json:"approval_instance_id,omitempty"\`\n\tRejectReason string \`gorm:"size:500" json:"reject_reason,omitempty"\``
714
+ )
715
+ }
716
+
717
+ fs.writeFileSync(modelFile, content, 'utf8')
718
+ }
719
+
720
+ function createApprovalService(targetDir, names) {
721
+ const serviceDir = path.join(targetDir, 'domain', 'service')
722
+ fs.mkdirSync(serviceDir, { recursive: true })
723
+
724
+ const content = `package service
725
+
726
+ import (
727
+ \t"context"
728
+ \t"fmt"
729
+
730
+ \tapproval "github.com/robsuncn/keystone/domain/approval/service"
731
+ \t"__APP_NAME__/apps/server/internal/modules/${names.kebab}/domain/models"
732
+ )
733
+
734
+ // ApprovalBusinessType is the approval business type for this module
735
+ const ApprovalBusinessType = "${names.lower}_item_approval"
736
+
737
+ // Submit submits the entity for approval
738
+ func (s *${names.pascal}Service) Submit(ctx context.Context, tenantID, id, userID uint) error {
739
+ \tentity, err := s.repo.FindByID(tenantID, id)
740
+ \tif err != nil {
741
+ \t\treturn err
742
+ \t}
743
+
744
+ \tif entity.Status != models.StatusDraft {
745
+ \t\treturn ErrInvalidStatusForSubmit
746
+ \t}
747
+
748
+ \tinstance, err := s.approval.CreateInstance(ctx, approval.CreateInstanceInput{
749
+ \t\tTenantID: tenantID,
750
+ \t\tBusinessType: ApprovalBusinessType,
751
+ \t\tBusinessID: id,
752
+ \t\tApplicantID: userID,
753
+ \t\tContext: map[string]interface{}{
754
+ \t\t\t"name": entity.Name,
755
+ \t\t\t"description": entity.Description,
756
+ \t\t},
757
+ \t})
758
+ \tif err != nil {
759
+ \t\treturn fmt.Errorf("create approval instance failed: %w", err)
760
+ \t}
761
+
762
+ \tentity.Status = models.StatusPending
763
+ \tentity.ApprovalInstanceID = &instance.ID
764
+ \treturn s.repo.Update(ctx, entity)
765
+ }
766
+
767
+ // Cancel cancels the approval request
768
+ func (s *${names.pascal}Service) Cancel(ctx context.Context, tenantID, id, userID uint) error {
769
+ \tentity, err := s.repo.FindByID(tenantID, id)
770
+ \tif err != nil {
771
+ \t\treturn err
772
+ \t}
773
+
774
+ \tif entity.Status != models.StatusPending {
775
+ \t\treturn ErrInvalidStatusForCancel
776
+ \t}
777
+
778
+ \tif entity.ApprovalInstanceID == nil {
779
+ \t\treturn ErrNoApprovalInstance
780
+ \t}
781
+
782
+ \tif err := s.approval.Cancel(ctx, *entity.ApprovalInstanceID, userID); err != nil {
783
+ \t\treturn err
784
+ \t}
785
+
786
+ \tentity.Status = models.StatusDraft
787
+ \tentity.ApprovalInstanceID = nil
788
+ \treturn s.repo.Update(ctx, entity)
789
+ }
790
+ `
791
+ fs.writeFileSync(path.join(serviceDir, 'approval.go'), content, 'utf8')
792
+
793
+ // Add approval errors to errors.go
794
+ const errorsFile = path.join(serviceDir, 'errors.go')
795
+ if (fs.existsSync(errorsFile)) {
796
+ let errContent = fs.readFileSync(errorsFile, 'utf8')
797
+ if (!errContent.includes('ErrInvalidStatusForSubmit')) {
798
+ errContent = errContent.replace(
799
+ /\)(\s*)$/,
800
+ `\tErrInvalidStatusForSubmit = &i18n.I18nError{Key: modulei18n.MsgInvalidStatusForSubmit}\n\tErrInvalidStatusForCancel = &i18n.I18nError{Key: modulei18n.MsgInvalidStatusForCancel}\n\tErrNoApprovalInstance = &i18n.I18nError{Key: modulei18n.MsgNoApprovalInstance}\n)$1`
801
+ )
802
+ fs.writeFileSync(errorsFile, errContent, 'utf8')
803
+ }
804
+ }
805
+ }
806
+
807
+ function createApprovalCallback(targetDir, names) {
808
+ const serviceDir = path.join(targetDir, 'domain', 'service')
809
+ fs.mkdirSync(serviceDir, { recursive: true })
810
+
811
+ const content = `package service
812
+
813
+ import (
814
+ \t"context"
815
+ \t"log/slog"
816
+
817
+ \t"__APP_NAME__/apps/server/internal/modules/${names.kebab}/domain/models"
818
+ \t"__APP_NAME__/apps/server/internal/modules/${names.kebab}/infra/repository"
819
+ )
820
+
821
+ // ${names.pascal}ApprovalCallback implements the approval callback interface
822
+ type ${names.pascal}ApprovalCallback struct {
823
+ \trepo *repository.${names.pascal}Repository
824
+ }
825
+
826
+ // New${names.pascal}ApprovalCallback creates a new approval callback
827
+ func New${names.pascal}ApprovalCallback(repo *repository.${names.pascal}Repository) *${names.pascal}ApprovalCallback {
828
+ \treturn &${names.pascal}ApprovalCallback{repo: repo}
829
+ }
830
+
831
+ // OnApproved is called when the approval is approved
832
+ func (c *${names.pascal}ApprovalCallback) OnApproved(
833
+ \tctx context.Context,
834
+ \ttenantID, businessID, approverID uint,
835
+ ) error {
836
+ \tentity, err := c.repo.FindByID(tenantID, businessID)
837
+ \tif err != nil {
838
+ \t\treturn err
839
+ \t}
840
+
841
+ \tif entity.Status != models.StatusPending {
842
+ \t\tslog.Warn("approval callback: status mismatch",
843
+ \t\t\t"expected", models.StatusPending,
844
+ \t\t\t"actual", entity.Status,
845
+ \t\t)
846
+ \t\treturn nil // idempotent
847
+ \t}
848
+
849
+ \tentity.Status = models.StatusApproved
850
+ \treturn c.repo.Update(ctx, entity)
851
+ }
852
+
853
+ // OnRejected is called when the approval is rejected
854
+ func (c *${names.pascal}ApprovalCallback) OnRejected(
855
+ \tctx context.Context,
856
+ \ttenantID, businessID, approverID uint,
857
+ \treason string,
858
+ ) error {
859
+ \tentity, err := c.repo.FindByID(tenantID, businessID)
860
+ \tif err != nil {
861
+ \t\treturn err
862
+ \t}
863
+
864
+ \tif entity.Status != models.StatusPending {
865
+ \t\treturn nil // idempotent
866
+ \t}
867
+
868
+ \tentity.Status = models.StatusRejected
869
+ \tentity.RejectReason = reason
870
+ \treturn c.repo.Update(ctx, entity)
871
+ }
872
+ `
873
+ fs.writeFileSync(path.join(serviceDir, 'callback.go'), content, 'utf8')
874
+ }
875
+
876
+ function createApprovalHandler(targetDir, names) {
877
+ const handlerDir = path.join(targetDir, 'api', 'handler')
878
+ fs.mkdirSync(handlerDir, { recursive: true })
879
+
880
+ const content = `package handler
881
+
882
+ import (
883
+ \t"github.com/gin-gonic/gin"
884
+ \thcommon "github.com/robsuncn/keystone/api/handler/common"
885
+ \t"github.com/robsuncn/keystone/api/response"
886
+
887
+ \tmodulei18n "__APP_NAME__/apps/server/internal/modules/${names.kebab}/i18n"
888
+ )
889
+
890
+ // Submit submits the entity for approval
891
+ func (h *${names.pascal}Handler) Submit(c *gin.Context) {
892
+ \tif h == nil || h.svc == nil {
893
+ \t\tresponse.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
894
+ \t\treturn
895
+ \t}
896
+ \ttenantID := resolveTenantID(c)
897
+ \tuserID, _ := hcommon.GetUserID(c)
898
+
899
+ \tid, err := hcommon.ParseUintParam(c, "id")
900
+ \tif err != nil || id == 0 {
901
+ \t\tresponse.BadRequestI18n(c, modulei18n.MsgInvalidID)
902
+ \t\treturn
903
+ \t}
904
+
905
+ \tif err := h.svc.Submit(c.Request.Context(), tenantID, id, userID); err != nil {
906
+ \t\thandleServiceError(c, err)
907
+ \t\treturn
908
+ \t}
909
+
910
+ \tresponse.SuccessI18n(c, modulei18n.MsgSubmitted, nil)
911
+ }
912
+
913
+ // Cancel cancels the approval request
914
+ func (h *${names.pascal}Handler) Cancel(c *gin.Context) {
915
+ \tif h == nil || h.svc == nil {
916
+ \t\tresponse.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
917
+ \t\treturn
918
+ \t}
919
+ \ttenantID := resolveTenantID(c)
920
+ \tuserID, _ := hcommon.GetUserID(c)
921
+
922
+ \tid, err := hcommon.ParseUintParam(c, "id")
923
+ \tif err != nil || id == 0 {
924
+ \t\tresponse.BadRequestI18n(c, modulei18n.MsgInvalidID)
925
+ \t\treturn
926
+ \t}
927
+
928
+ \tif err := h.svc.Cancel(c.Request.Context(), tenantID, id, userID); err != nil {
929
+ \t\thandleServiceError(c, err)
930
+ \t\treturn
931
+ \t}
932
+
933
+ \tresponse.SuccessI18n(c, modulei18n.MsgCancelled, nil)
934
+ }
935
+ `
936
+ fs.writeFileSync(path.join(handlerDir, 'approval.go'), content, 'utf8')
937
+ }
938
+
939
+ function addApprovalRoutes(targetDir, names) {
940
+ const moduleFile = path.join(targetDir, 'module.go')
941
+ if (!fs.existsSync(moduleFile)) return
942
+
943
+ let content = fs.readFileSync(moduleFile, 'utf8')
944
+
945
+ // Add approval routes if not exists
946
+ if (!content.includes('Submit') && content.includes('RegisterRoutes')) {
947
+ // Pattern: group.DELETE("/items/:id", handler.Delete) or similar
948
+ content = content.replace(
949
+ /(group\.DELETE\("[^"]+",\s*\w+\.Delete\))/,
950
+ `$1
951
+
952
+ \t// Approval routes
953
+ \tgroup.POST("/items/:id/submit", handler.Submit)
954
+ \tgroup.POST("/items/:id/cancel", handler.Cancel)`
955
+ )
956
+ fs.writeFileSync(moduleFile, content, 'utf8')
957
+ }
958
+ }
959
+
960
+ function addApprovalI18nKeys(targetDir, names) {
961
+ const keysFile = path.join(targetDir, 'i18n', 'keys.go')
962
+ if (!fs.existsSync(keysFile)) return
963
+
964
+ let content = fs.readFileSync(keysFile, 'utf8')
965
+
966
+ if (!content.includes('MsgSubmitted')) {
967
+ content = content.replace(
968
+ /MsgServiceUnavailable\s*=\s*"[^"]+"/,
969
+ `MsgServiceUnavailable = "${names.kebab}.service.unavailable"
970
+
971
+ \t// Approval messages
972
+ \tMsgSubmitted = "${names.kebab}.${names.lower}.submitted"
973
+ \tMsgCancelled = "${names.kebab}.${names.lower}.cancelled"
974
+ \tMsgInvalidStatusForSubmit = "${names.kebab}.validation.invalidStatusForSubmit"
975
+ \tMsgInvalidStatusForCancel = "${names.kebab}.validation.invalidStatusForCancel"
976
+ \tMsgNoApprovalInstance = "${names.kebab}.validation.noApprovalInstance"`
977
+ )
978
+ fs.writeFileSync(keysFile, content, 'utf8')
979
+ }
980
+
981
+ // Update locale files
982
+ const localeFiles = [
983
+ { file: path.join(targetDir, 'i18n', 'locales', 'zh-CN.json'), lang: 'zh' },
984
+ { file: path.join(targetDir, 'i18n', 'locales', 'en-US.json'), lang: 'en' },
985
+ ]
986
+
987
+ for (const { file, lang } of localeFiles) {
988
+ if (!fs.existsSync(file)) continue
989
+ try {
990
+ const locale = JSON.parse(fs.readFileSync(file, 'utf8'))
991
+ const moduleKey = names.kebab
992
+ if (!locale[moduleKey]) locale[moduleKey] = {}
993
+ if (!locale[moduleKey][names.lower]) locale[moduleKey][names.lower] = {}
994
+ if (!locale[moduleKey].validation) locale[moduleKey].validation = {}
995
+
996
+ if (lang === 'zh') {
997
+ locale[moduleKey][names.lower].submitted = '\u5df2\u63d0\u4ea4\u5ba1\u6279'
998
+ locale[moduleKey][names.lower].cancelled = '\u5df2\u64a4\u56de\u5ba1\u6279'
999
+ locale[moduleKey].validation.invalidStatusForSubmit = '\u5f53\u524d\u72b6\u6001\u4e0d\u5141\u8bb8\u63d0\u4ea4'
1000
+ locale[moduleKey].validation.invalidStatusForCancel = '\u5f53\u524d\u72b6\u6001\u4e0d\u5141\u8bb8\u64a4\u56de'
1001
+ locale[moduleKey].validation.noApprovalInstance = '\u65e0\u5ba1\u6279\u5b9e\u4f8b'
1002
+ } else {
1003
+ locale[moduleKey][names.lower].submitted = 'Submitted for approval'
1004
+ locale[moduleKey][names.lower].cancelled = 'Approval cancelled'
1005
+ locale[moduleKey].validation.invalidStatusForSubmit = 'Cannot submit in current status'
1006
+ locale[moduleKey].validation.invalidStatusForCancel = 'Cannot cancel in current status'
1007
+ locale[moduleKey].validation.noApprovalInstance = 'No approval instance'
1008
+ }
1009
+
1010
+ fs.writeFileSync(file, JSON.stringify(locale, null, 2), 'utf8')
1011
+ } catch {
1012
+ // ignore parse errors
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ function addFrontendApproval(targetDir, names) {
1018
+ // 1. Create ApprovalActions component
1019
+ createApprovalActionsComponent(targetDir, names)
1020
+
1021
+ // 2. Update types.ts to include approval status
1022
+ updateFrontendTypes(targetDir, names)
1023
+
1024
+ // 3. Add approval API functions
1025
+ addApprovalApiServices(targetDir, names)
1026
+
1027
+ // 4. Add approval i18n translations
1028
+ addFrontendApprovalI18n(targetDir, names)
1029
+ }
1030
+
1031
+ function createApprovalActionsComponent(targetDir, names) {
1032
+ const componentsDir = path.join(targetDir, 'components')
1033
+ fs.mkdirSync(componentsDir, { recursive: true })
1034
+
1035
+ const content = `import { useCallback, useState } from 'react'
1036
+ import { App, Button, Popconfirm, Space, Tag } from 'antd'
1037
+ import { CheckCircleOutlined, CloseCircleOutlined, SendOutlined, UndoOutlined } from '@ant-design/icons'
1038
+ import { useTranslation } from 'react-i18next'
1039
+ import type { ${names.pascal}ItemStatus } from '../types'
1040
+
1041
+ interface Props {
1042
+ id: number
1043
+ status: ${names.pascal}ItemStatus
1044
+ onSubmit: (id: number) => Promise<void>
1045
+ onCancel: (id: number) => Promise<void>
1046
+ onRefresh: () => void
1047
+ }
1048
+
1049
+ const statusConfig: Record<${names.pascal}ItemStatus, { color: string; icon: React.ReactNode }> = {
1050
+ draft: { color: 'default', icon: null },
1051
+ pending: { color: 'processing', icon: <CloseCircleOutlined spin /> },
1052
+ approved: { color: 'success', icon: <CheckCircleOutlined /> },
1053
+ rejected: { color: 'error', icon: <CloseCircleOutlined /> },
1054
+ active: { color: 'success', icon: null },
1055
+ inactive: { color: 'default', icon: null },
1056
+ }
1057
+
1058
+ export function ApprovalActions({ id, status, onSubmit, onCancel, onRefresh }: Props) {
1059
+ const { t } = useTranslation('${names.camel}')
1060
+ const { t: tc } = useTranslation('common')
1061
+ const { message } = App.useApp()
1062
+ const [loading, setLoading] = useState(false)
1063
+
1064
+ const handleSubmit = useCallback(async () => {
1065
+ setLoading(true)
1066
+ try {
1067
+ await onSubmit(id)
1068
+ message.success(t('messages.submitSuccess'))
1069
+ onRefresh()
1070
+ } catch (err) {
1071
+ message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
1072
+ } finally {
1073
+ setLoading(false)
1074
+ }
1075
+ }, [id, message, onRefresh, onSubmit, t, tc])
1076
+
1077
+ const handleCancel = useCallback(async () => {
1078
+ setLoading(true)
1079
+ try {
1080
+ await onCancel(id)
1081
+ message.success(t('messages.cancelSuccess'))
1082
+ onRefresh()
1083
+ } catch (err) {
1084
+ message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
1085
+ } finally {
1086
+ setLoading(false)
1087
+ }
1088
+ }, [id, message, onRefresh, onCancel, t, tc])
1089
+
1090
+ const config = statusConfig[status] || { color: 'default', icon: null }
1091
+
1092
+ return (
1093
+ <Space>
1094
+ <Tag color={config.color} icon={config.icon}>
1095
+ {t(\`status.\${status}\`)}
1096
+ </Tag>
1097
+
1098
+ {status === 'draft' && (
1099
+ <Popconfirm title={t('confirm.submit')} onConfirm={handleSubmit}>
1100
+ <Button type="primary" icon={<SendOutlined />} loading={loading} size="small">
1101
+ {t('actions.submit')}
1102
+ </Button>
1103
+ </Popconfirm>
1104
+ )}
1105
+
1106
+ {status === 'pending' && (
1107
+ <Popconfirm title={t('confirm.cancel')} onConfirm={handleCancel}>
1108
+ <Button icon={<UndoOutlined />} loading={loading} size="small">
1109
+ {t('actions.cancelApproval')}
1110
+ </Button>
1111
+ </Popconfirm>
1112
+ )}
1113
+ </Space>
1114
+ )
1115
+ }
1116
+ `
1117
+ fs.writeFileSync(path.join(componentsDir, 'ApprovalActions.tsx'), content, 'utf8')
1118
+ }
1119
+
1120
+ function updateFrontendTypes(targetDir, names) {
1121
+ const typesFile = path.join(targetDir, 'types.ts')
1122
+ if (!fs.existsSync(typesFile)) return
1123
+
1124
+ let content = fs.readFileSync(typesFile, 'utf8')
1125
+
1126
+ // Update status type to include approval statuses
1127
+ // Pattern: export type XxxItemStatus = 'active' | 'inactive'
1128
+ if (!content.includes("'pending'")) {
1129
+ content = content.replace(
1130
+ /export type (\w+)Status = ['"]active['"] \| ['"]inactive['"]/,
1131
+ `export type $1Status = 'draft' | 'pending' | 'approved' | 'rejected' | 'active' | 'inactive'`
1132
+ )
1133
+ }
1134
+
1135
+ // Add approval fields to interface
1136
+ // Pattern: status: XxxItemStatus
1137
+ if (!content.includes('approval_instance_id')) {
1138
+ content = content.replace(
1139
+ /(status:\s*\w+Status\s*\n)/,
1140
+ `$1 approval_instance_id?: number\n reject_reason?: string\n`
1141
+ )
1142
+ }
1143
+
1144
+ fs.writeFileSync(typesFile, content, 'utf8')
1145
+ }
1146
+
1147
+ function addApprovalApiServices(targetDir, names) {
1148
+ const apiFile = path.join(targetDir, 'services', 'api.ts')
1149
+ if (!fs.existsSync(apiFile)) return
1150
+
1151
+ let content = fs.readFileSync(apiFile, 'utf8')
1152
+
1153
+ if (!content.includes('submit')) {
1154
+ content += `
1155
+
1156
+ // Approval API
1157
+ export const submit${names.pascal}Item = async (id: number) => {
1158
+ await api.post(\`/${names.kebab}/items/\${id}/submit\`)
1159
+ }
1160
+
1161
+ export const cancel${names.pascal}ItemApproval = async (id: number) => {
1162
+ await api.post(\`/${names.kebab}/items/\${id}/cancel\`)
1163
+ }
1164
+ `
1165
+ fs.writeFileSync(apiFile, content, 'utf8')
1166
+ }
1167
+ }
1168
+
1169
+ function addFrontendApprovalI18n(targetDir, names) {
1170
+ const localeFiles = [
1171
+ { file: path.join(targetDir, 'locales', 'zh-CN', `${names.camel}.json`), lang: 'zh' },
1172
+ { file: path.join(targetDir, 'locales', 'en-US', `${names.camel}.json`), lang: 'en' },
1173
+ ]
1174
+
1175
+ for (const { file, lang } of localeFiles) {
1176
+ if (!fs.existsSync(file)) continue
1177
+ try {
1178
+ const locale = JSON.parse(fs.readFileSync(file, 'utf8'))
1179
+
1180
+ // Add approval status translations
1181
+ if (!locale.status) locale.status = {}
1182
+ if (!locale.messages) locale.messages = {}
1183
+ if (!locale.actions) locale.actions = {}
1184
+ if (!locale.confirm) locale.confirm = {}
1185
+
1186
+ if (lang === 'zh') {
1187
+ locale.status.draft = '\u8349\u7a3f'
1188
+ locale.status.pending = '\u5ba1\u6279\u4e2d'
1189
+ locale.status.approved = '\u5df2\u901a\u8fc7'
1190
+ locale.status.rejected = '\u5df2\u62d2\u7edd'
1191
+ locale.messages.submitSuccess = '\u63d0\u4ea4\u6210\u529f'
1192
+ locale.messages.cancelSuccess = '\u64a4\u56de\u6210\u529f'
1193
+ locale.actions.submit = '\u63d0\u4ea4\u5ba1\u6279'
1194
+ locale.actions.cancelApproval = '\u64a4\u56de'
1195
+ locale.confirm.submit = '\u786e\u5b9a\u8981\u63d0\u4ea4\u5ba1\u6279\u5417\uff1f'
1196
+ locale.confirm.cancel = '\u786e\u5b9a\u8981\u64a4\u56de\u5ba1\u6279\u5417\uff1f'
1197
+ } else {
1198
+ locale.status.draft = 'Draft'
1199
+ locale.status.pending = 'Pending'
1200
+ locale.status.approved = 'Approved'
1201
+ locale.status.rejected = 'Rejected'
1202
+ locale.messages.submitSuccess = 'Submitted successfully'
1203
+ locale.messages.cancelSuccess = 'Cancelled successfully'
1204
+ locale.actions.submit = 'Submit'
1205
+ locale.actions.cancelApproval = 'Cancel'
1206
+ locale.confirm.submit = 'Are you sure to submit for approval?'
1207
+ locale.confirm.cancel = 'Are you sure to cancel the approval?'
1208
+ }
1209
+
1210
+ fs.writeFileSync(file, JSON.stringify(locale, null, 2), 'utf8')
1211
+ } catch {
1212
+ // ignore parse errors
1213
+ }
414
1214
  }
415
- const pageFile = path.join(targetDir, 'pages', `${names.pascal}ItemsPage.tsx`)
416
- const content = [
417
- "import { Card, Typography } from 'antd'",
418
- '',
419
- `export function ${names.pascal}ItemsPage() {`,
420
- ' return (',
421
- ` <Card title="${names.display}">`,
422
- ' <Typography.Paragraph type="secondary">',
423
- ` ${names.display} module is ready. Start building your pages here.`,
424
- ' </Typography.Paragraph>',
425
- ' </Card>',
426
- ' )',
427
- '}',
428
- '',
429
- ].join('\n')
430
- fs.writeFileSync(pageFile, content, 'utf8')
431
1215
  }
432
1216
 
433
- function pruneBackendCrud(targetDir, names) {
434
- const dirs = ['api', 'bootstrap', 'domain', 'infra']
435
- for (const dir of dirs) {
436
- const fullPath = path.join(targetDir, dir)
437
- if (fs.existsSync(fullPath)) {
438
- fs.rmSync(fullPath, { recursive: true, force: true })
439
- }
1217
+ function writeBackendReadme(targetDir, names) {
1218
+ const readmePath = path.join(targetDir, 'README.md')
1219
+ if (fs.existsSync(readmePath)) {
1220
+ return
440
1221
  }
441
- const moduleFile = path.join(targetDir, 'module.go')
442
1222
  const content = [
443
- `package ${names.lower}`,
444
- '',
445
- 'import (',
446
- '\t"github.com/gin-gonic/gin"',
447
- '\t"gorm.io/gorm"',
448
- '',
449
- '\t"github.com/robsuncn/keystone/domain/permissions"',
450
- '\t"github.com/robsuncn/keystone/infra/jobs"',
451
- ')',
1223
+ `# ${names.display} Module`,
452
1224
  '',
453
- 'type Module struct{}',
1225
+ '## Overview',
1226
+ `- Module key: \`${names.kebab}\``,
1227
+ '- Description: TODO',
454
1228
  '',
455
- 'func NewModule() *Module {',
456
- '\treturn &Module{}',
457
- '}',
1229
+ '## API Endpoints',
1230
+ '- TODO: list REST endpoints and handlers',
458
1231
  '',
459
- 'func (m *Module) Name() string {',
460
- `\treturn "${names.kebab}"`,
461
- '}',
1232
+ '## Permissions',
1233
+ '- TODO: list permission codes',
462
1234
  '',
463
- 'func (m *Module) RegisterRoutes(_ *gin.RouterGroup) {}',
464
- '',
465
- 'func (m *Module) RegisterModels() []interface{} {',
466
- '\treturn nil',
467
- '}',
468
- '',
469
- 'func (m *Module) RegisterPermissions(reg *permissions.Registry) error {',
470
- '\tif reg == nil {',
471
- '\t\treturn nil',
472
- '\t}',
473
- `\tif err := reg.CreateMenu("${names.kebab}:overview", "${names.display}", "${names.kebab}", 10); err != nil {`,
474
- '\t\treturn err',
475
- '\t}',
476
- `\tif err := reg.CreateAction("${names.kebab}:overview:view", "View ${names.display}", "${names.kebab}", "${names.kebab}:overview"); err != nil {`,
477
- '\t\treturn err',
478
- '\t}',
479
- '\treturn nil',
480
- '}',
481
- '',
482
- 'func (m *Module) RegisterJobs(_ *jobs.Registry) error {',
483
- '\treturn nil',
484
- '}',
485
- '',
486
- 'func (m *Module) Migrate(_ *gorm.DB) error {',
487
- '\treturn nil',
488
- '}',
489
- '',
490
- 'func (m *Module) Seed(_ *gorm.DB) error {',
491
- '\treturn nil',
492
- '}',
1235
+ '## Configuration',
1236
+ '```yaml',
1237
+ 'modules:',
1238
+ ` ${names.kebab}:`,
1239
+ ' # key: value',
1240
+ '```',
493
1241
  '',
494
1242
  ].join('\n')
495
- fs.writeFileSync(moduleFile, content, 'utf8')
496
- }
497
-
498
- function registerFrontendModule(mainPath, names) {
499
- if (!fs.existsSync(mainPath)) {
500
- return
501
- }
502
- const content = fs.readFileSync(mainPath, 'utf8')
503
- const importLine = `import './modules/${names.kebab}'`
504
- if (content.includes(importLine)) {
505
- return
506
- }
507
- const lines = content.split(/\r?\n/)
508
- let lastImport = -1
509
- for (let i = 0; i < lines.length; i += 1) {
510
- if (/^\s*import\s/.test(lines[i])) {
511
- lastImport = i
512
- }
513
- }
514
- if (lastImport === -1) {
515
- lines.unshift(importLine)
516
- } else {
517
- lines.splice(lastImport + 1, 0, importLine)
518
- }
519
- fs.writeFileSync(mainPath, lines.join('\n'), 'utf8')
520
- }
521
-
522
- function registerBackendModule(manifestPath, configPath, names) {
523
- updateManifest(manifestPath, names)
524
- updateConfig(configPath, names)
525
- }
526
-
527
- function updateManifest(manifestPath, names) {
528
- if (!fs.existsSync(manifestPath)) {
529
- return
530
- }
531
- const content = fs.readFileSync(manifestPath, 'utf8')
532
- const lines = content.split(/\r?\n/)
533
- const importStart = lines.findIndex((line) => line.trim() === 'import (')
534
- if (importStart === -1) {
535
- return
536
- }
537
- const importEnd = lines.findIndex((line, index) => index > importStart && line.trim() === ')')
538
- if (importEnd === -1) {
539
- return
540
- }
541
- const importBlock = lines.slice(importStart + 1, importEnd)
542
- const exampleImport = importBlock.find((line) => line.includes('/internal/modules/example'))
543
- if (!exampleImport) {
544
- return
545
- }
546
- const pathMatch = exampleImport.match(/\"([^\"]+)\"/)
547
- if (!pathMatch) {
548
- return
549
- }
550
- const modulePath = pathMatch[1].replace(/\/example$/, `/${names.kebab}`)
551
- const alias = names.lower
552
- const importLine = `${alias} "${modulePath}"`
553
- if (!importBlock.some((line) => line.includes(`/${names.kebab}"`))) {
554
- lines.splice(importEnd, 0, importLine)
555
- }
556
- const registerLine = `\tRegister(${alias}.NewModule())`
557
- if (!lines.some((line) => line.trim() === registerLine.trim())) {
558
- const registerIndices = lines
559
- .map((line, index) => ({ line, index }))
560
- .filter(({ line }) => line.includes('Register('))
561
- if (registerIndices.length > 0) {
562
- lines.splice(registerIndices[registerIndices.length - 1].index + 1, 0, registerLine)
563
- } else {
564
- const clearIndex = lines.findIndex((line) => line.includes('Clear()'))
565
- if (clearIndex !== -1) {
566
- lines.splice(clearIndex + 1, 0, registerLine)
567
- }
568
- }
569
- }
570
- fs.writeFileSync(manifestPath, lines.join('\n'), 'utf8')
571
- }
572
-
573
- function updateConfig(configPath, names) {
574
- if (!fs.existsSync(configPath)) {
575
- return
576
- }
577
- const content = fs.readFileSync(configPath, 'utf8')
578
- if (content.includes(`- "${names.kebab}"`) || content.includes(`- '${names.kebab}'`)) {
579
- return
580
- }
581
- const lines = content.split(/\r?\n/)
582
- const modulesIndex = lines.findIndex((line) => line.trim() === 'modules:')
583
- if (modulesIndex === -1) {
584
- return
585
- }
586
- const enabledIndex = lines.findIndex((line, index) => index > modulesIndex && line.trim() === 'enabled:')
587
- if (enabledIndex === -1) {
588
- return
589
- }
590
- let insertIndex = enabledIndex + 1
591
- let indent = null
592
- for (let i = enabledIndex + 1; i < lines.length; i += 1) {
593
- const line = lines[i]
594
- if (!line.trim()) continue
595
- if (!line.trim().startsWith('-')) {
596
- insertIndex = i
597
- break
598
- }
599
- if (indent === null) {
600
- indent = line.match(/^\s*/)?.[0] ?? ''
601
- }
602
- insertIndex = i + 1
603
- }
604
- const prefix = indent ?? ' '
605
- lines.splice(insertIndex, 0, `${prefix}- "${names.kebab}"`)
606
- fs.writeFileSync(configPath, lines.join('\n'), 'utf8')
607
- }
608
-
609
- function parseArgs(argv) {
610
- const out = {
611
- target: null,
612
- frontendOnly: false,
613
- backendOnly: false,
614
- withCrud: false,
615
- withApproval: false,
616
- skipRegister: false,
617
- help: false,
618
- }
619
- for (const arg of argv) {
620
- if (arg === '--help' || arg === '-h') {
621
- out.help = true
622
- continue
623
- }
624
- if (arg === '--frontend-only') {
625
- out.frontendOnly = true
626
- continue
627
- }
628
- if (arg === '--backend-only') {
629
- out.backendOnly = true
630
- continue
631
- }
632
- if (arg === '--with-crud') {
633
- out.withCrud = true
634
- continue
635
- }
636
- if (arg === '--with-approval') {
637
- out.withApproval = true
638
- continue
639
- }
640
- if (arg === '--skip-register') {
641
- out.skipRegister = true
642
- continue
643
- }
644
- if (arg.startsWith('-')) {
645
- fail(`Unknown option: ${arg}`)
646
- }
647
- if (!out.target) {
648
- out.target = arg
649
- continue
650
- }
651
- fail('Too many arguments.')
652
- }
653
- return out
654
- }
655
-
656
- // ============================================================================
657
- // Approval Workflow Code Generation
658
- // ============================================================================
659
-
660
- function addBackendApproval(targetDir, names) {
661
- // 1. Add approval status constants to model
662
- addApprovalStatusToModel(targetDir, names)
663
-
664
- // 2. Create approval service file
665
- createApprovalService(targetDir, names)
666
-
667
- // 3. Create approval callback file
668
- createApprovalCallback(targetDir, names)
669
-
670
- // 4. Create approval handler file
671
- createApprovalHandler(targetDir, names)
672
-
673
- // 5. Add approval routes to module.go
674
- addApprovalRoutes(targetDir, names)
675
-
676
- // 6. Add approval i18n keys
677
- addApprovalI18nKeys(targetDir, names)
678
- }
679
-
680
- function addApprovalStatusToModel(targetDir, names) {
681
- // Find the model file - could be item.go or {names.lower}.go
682
- const modelDir = path.join(targetDir, 'domain', 'models')
683
- if (!fs.existsSync(modelDir)) return
684
-
685
- const modelFiles = fs.readdirSync(modelDir).filter((f) => f.endsWith('.go'))
686
- if (modelFiles.length === 0) return
687
-
688
- const modelFile = path.join(modelDir, modelFiles[0])
689
- let content = fs.readFileSync(modelFile, 'utf8')
690
-
691
- // Add approval status constants if not exists
692
- if (!content.includes('StatusPending') && !content.includes('StatusDraft')) {
693
- // Pattern: StatusActive ItemStatus = "active"
694
- content = content.replace(
695
- /(StatusActive\s+\w+\s*=\s*"active")/,
696
- `StatusDraft ItemStatus = "draft"\n\tStatusPending ItemStatus = "pending"\n\tStatusApproved ItemStatus = "approved"\n\tStatusRejected ItemStatus = "rejected"\n\t$1`
697
- )
698
-
699
- // Update IsValid to include new statuses
700
- content = content.replace(
701
- /case StatusActive, StatusInactive:/,
702
- `case StatusDraft, StatusPending, StatusApproved, StatusRejected, StatusActive, StatusInactive:`
703
- )
704
- }
705
-
706
- // Add approval fields to struct if not exists
707
- if (!content.includes('ApprovalInstanceID')) {
708
- // Pattern: Status ItemStatus `gorm:...` json:"status"`
709
- // Need to match the full line including the backticks and add before closing brace
710
- content = content.replace(
711
- /(Status\s+ItemStatus\s+`[^`]+`)/,
712
- `$1\n\tApprovalInstanceID *uint \`gorm:"index" json:"approval_instance_id,omitempty"\`\n\tRejectReason string \`gorm:"size:500" json:"reject_reason,omitempty"\``
713
- )
714
- }
715
-
716
- fs.writeFileSync(modelFile, content, 'utf8')
717
- }
718
-
719
- function createApprovalService(targetDir, names) {
720
- const serviceDir = path.join(targetDir, 'domain', 'service')
721
- fs.mkdirSync(serviceDir, { recursive: true })
722
-
723
- const content = `package service
724
-
725
- import (
726
- \t"context"
727
- \t"fmt"
728
-
729
- \tapproval "github.com/robsuncn/keystone/domain/approval/service"
730
- \t"__APP_NAME__/apps/server/internal/modules/${names.kebab}/domain/models"
731
- )
732
-
733
- // ApprovalBusinessType is the approval business type for this module
734
- const ApprovalBusinessType = "${names.kebab}_approval"
735
-
736
- // Submit submits the entity for approval
737
- func (s *${names.pascal}Service) Submit(ctx context.Context, tenantID, id, userID uint) error {
738
- \tentity, err := s.repo.FindByID(tenantID, id)
739
- \tif err != nil {
740
- \t\treturn err
741
- \t}
742
-
743
- \tif entity.Status != models.StatusDraft {
744
- \t\treturn ErrInvalidStatusForSubmit
745
- \t}
746
-
747
- \tinstance, err := s.approval.CreateInstance(ctx, approval.CreateInstanceInput{
748
- \t\tTenantID: tenantID,
749
- \t\tBusinessType: ApprovalBusinessType,
750
- \t\tBusinessID: id,
751
- \t\tApplicantID: userID,
752
- \t\tContext: map[string]interface{}{
753
- \t\t\t"name": entity.Name,
754
- \t\t\t"description": entity.Description,
755
- \t\t},
756
- \t})
757
- \tif err != nil {
758
- \t\treturn fmt.Errorf("create approval instance failed: %w", err)
759
- \t}
760
-
761
- \tentity.Status = models.StatusPending
762
- \tentity.ApprovalInstanceID = &instance.ID
763
- \treturn s.repo.Update(ctx, entity)
764
- }
765
-
766
- // Cancel cancels the approval request
767
- func (s *${names.pascal}Service) Cancel(ctx context.Context, tenantID, id, userID uint) error {
768
- \tentity, err := s.repo.FindByID(tenantID, id)
769
- \tif err != nil {
770
- \t\treturn err
771
- \t}
772
-
773
- \tif entity.Status != models.StatusPending {
774
- \t\treturn ErrInvalidStatusForCancel
775
- \t}
776
-
777
- \tif entity.ApprovalInstanceID == nil {
778
- \t\treturn ErrNoApprovalInstance
779
- \t}
780
-
781
- \tif err := s.approval.Cancel(ctx, *entity.ApprovalInstanceID, userID); err != nil {
782
- \t\treturn err
783
- \t}
784
-
785
- \tentity.Status = models.StatusDraft
786
- \tentity.ApprovalInstanceID = nil
787
- \treturn s.repo.Update(ctx, entity)
788
- }
789
- `
790
- fs.writeFileSync(path.join(serviceDir, 'approval.go'), content, 'utf8')
791
-
792
- // Add approval errors to errors.go
793
- const errorsFile = path.join(serviceDir, 'errors.go')
794
- if (fs.existsSync(errorsFile)) {
795
- let errContent = fs.readFileSync(errorsFile, 'utf8')
796
- if (!errContent.includes('ErrInvalidStatusForSubmit')) {
797
- errContent = errContent.replace(
798
- /\)(\s*)$/,
799
- `\tErrInvalidStatusForSubmit = &i18n.I18nError{Key: modulei18n.MsgInvalidStatusForSubmit}\n\tErrInvalidStatusForCancel = &i18n.I18nError{Key: modulei18n.MsgInvalidStatusForCancel}\n\tErrNoApprovalInstance = &i18n.I18nError{Key: modulei18n.MsgNoApprovalInstance}\n)$1`
800
- )
801
- fs.writeFileSync(errorsFile, errContent, 'utf8')
802
- }
803
- }
804
- }
805
-
806
- function createApprovalCallback(targetDir, names) {
807
- const serviceDir = path.join(targetDir, 'domain', 'service')
808
- fs.mkdirSync(serviceDir, { recursive: true })
809
-
810
- const content = `package service
811
-
812
- import (
813
- \t"context"
814
- \t"log/slog"
815
-
816
- \t"__APP_NAME__/apps/server/internal/modules/${names.kebab}/domain/models"
817
- \t"__APP_NAME__/apps/server/internal/modules/${names.kebab}/infra/repository"
818
- )
819
-
820
- // ${names.pascal}ApprovalCallback implements the approval callback interface
821
- type ${names.pascal}ApprovalCallback struct {
822
- \trepo *repository.${names.pascal}Repository
823
- }
824
-
825
- // New${names.pascal}ApprovalCallback creates a new approval callback
826
- func New${names.pascal}ApprovalCallback(repo *repository.${names.pascal}Repository) *${names.pascal}ApprovalCallback {
827
- \treturn &${names.pascal}ApprovalCallback{repo: repo}
828
- }
829
-
830
- // OnApproved is called when the approval is approved
831
- func (c *${names.pascal}ApprovalCallback) OnApproved(
832
- \tctx context.Context,
833
- \ttenantID, businessID, approverID uint,
834
- ) error {
835
- \tentity, err := c.repo.FindByID(tenantID, businessID)
836
- \tif err != nil {
837
- \t\treturn err
838
- \t}
839
-
840
- \tif entity.Status != models.StatusPending {
841
- \t\tslog.Warn("approval callback: status mismatch",
842
- \t\t\t"expected", models.StatusPending,
843
- \t\t\t"actual", entity.Status,
844
- \t\t)
845
- \t\treturn nil // idempotent
846
- \t}
847
-
848
- \tentity.Status = models.StatusApproved
849
- \treturn c.repo.Update(ctx, entity)
850
- }
851
-
852
- // OnRejected is called when the approval is rejected
853
- func (c *${names.pascal}ApprovalCallback) OnRejected(
854
- \tctx context.Context,
855
- \ttenantID, businessID, approverID uint,
856
- \treason string,
857
- ) error {
858
- \tentity, err := c.repo.FindByID(tenantID, businessID)
859
- \tif err != nil {
860
- \t\treturn err
861
- \t}
862
-
863
- \tif entity.Status != models.StatusPending {
864
- \t\treturn nil // idempotent
865
- \t}
866
-
867
- \tentity.Status = models.StatusRejected
868
- \tentity.RejectReason = reason
869
- \treturn c.repo.Update(ctx, entity)
870
- }
871
- `
872
- fs.writeFileSync(path.join(serviceDir, 'callback.go'), content, 'utf8')
873
- }
874
-
875
- function createApprovalHandler(targetDir, names) {
876
- const handlerDir = path.join(targetDir, 'api', 'handler')
877
- fs.mkdirSync(handlerDir, { recursive: true })
878
-
879
- const content = `package handler
880
-
881
- import (
882
- \t"github.com/gin-gonic/gin"
883
- \thcommon "github.com/robsuncn/keystone/api/handler/common"
884
- \t"github.com/robsuncn/keystone/api/response"
885
-
886
- \tmodulei18n "__APP_NAME__/apps/server/internal/modules/${names.kebab}/i18n"
887
- )
888
-
889
- // Submit submits the entity for approval
890
- func (h *${names.pascal}Handler) Submit(c *gin.Context) {
891
- \tif h == nil || h.svc == nil {
892
- \t\tresponse.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
893
- \t\treturn
894
- \t}
895
- \ttenantID := resolveTenantID(c)
896
- \tuserID, _ := hcommon.GetUserID(c)
897
-
898
- \tid, err := hcommon.ParseUintParam(c, "id")
899
- \tif err != nil || id == 0 {
900
- \t\tresponse.BadRequestI18n(c, modulei18n.MsgInvalidID)
901
- \t\treturn
902
- \t}
903
-
904
- \tif err := h.svc.Submit(c.Request.Context(), tenantID, id, userID); err != nil {
905
- \t\thandleServiceError(c, err)
906
- \t\treturn
907
- \t}
908
-
909
- \tresponse.SuccessI18n(c, modulei18n.MsgSubmitted, nil)
910
- }
911
-
912
- // Cancel cancels the approval request
913
- func (h *${names.pascal}Handler) Cancel(c *gin.Context) {
914
- \tif h == nil || h.svc == nil {
915
- \t\tresponse.ServiceUnavailableI18n(c, modulei18n.MsgServiceUnavailable)
916
- \t\treturn
917
- \t}
918
- \ttenantID := resolveTenantID(c)
919
- \tuserID, _ := hcommon.GetUserID(c)
920
-
921
- \tid, err := hcommon.ParseUintParam(c, "id")
922
- \tif err != nil || id == 0 {
923
- \t\tresponse.BadRequestI18n(c, modulei18n.MsgInvalidID)
924
- \t\treturn
925
- \t}
926
-
927
- \tif err := h.svc.Cancel(c.Request.Context(), tenantID, id, userID); err != nil {
928
- \t\thandleServiceError(c, err)
929
- \t\treturn
930
- \t}
931
-
932
- \tresponse.SuccessI18n(c, modulei18n.MsgCancelled, nil)
933
- }
934
- `
935
- fs.writeFileSync(path.join(handlerDir, 'approval.go'), content, 'utf8')
936
- }
937
-
938
- function addApprovalRoutes(targetDir, names) {
939
- const moduleFile = path.join(targetDir, 'module.go')
940
- if (!fs.existsSync(moduleFile)) return
941
-
942
- let content = fs.readFileSync(moduleFile, 'utf8')
943
-
944
- // Add approval routes if not exists
945
- if (!content.includes('Submit') && content.includes('RegisterRoutes')) {
946
- // Pattern: group.DELETE("/items/:id", handler.Delete) or similar
947
- content = content.replace(
948
- /(group\.DELETE\("[^"]+",\s*\w+\.Delete\))/,
949
- `$1
950
-
951
- \t// Approval routes
952
- \tgroup.POST("/items/:id/submit", handler.Submit)
953
- \tgroup.POST("/items/:id/cancel", handler.Cancel)`
954
- )
955
- fs.writeFileSync(moduleFile, content, 'utf8')
956
- }
957
- }
958
-
959
- function addApprovalI18nKeys(targetDir, names) {
960
- const keysFile = path.join(targetDir, 'i18n', 'keys.go')
961
- if (!fs.existsSync(keysFile)) return
962
-
963
- let content = fs.readFileSync(keysFile, 'utf8')
964
-
965
- if (!content.includes('MsgSubmitted')) {
966
- content = content.replace(
967
- /MsgServiceUnavailable\s*=\s*"[^"]+"/,
968
- `MsgServiceUnavailable = "${names.kebab}.service.unavailable"
969
-
970
- \t// Approval messages
971
- \tMsgSubmitted = "${names.kebab}.${names.lower}.submitted"
972
- \tMsgCancelled = "${names.kebab}.${names.lower}.cancelled"
973
- \tMsgInvalidStatusForSubmit = "${names.kebab}.validation.invalidStatusForSubmit"
974
- \tMsgInvalidStatusForCancel = "${names.kebab}.validation.invalidStatusForCancel"
975
- \tMsgNoApprovalInstance = "${names.kebab}.validation.noApprovalInstance"`
976
- )
977
- fs.writeFileSync(keysFile, content, 'utf8')
978
- }
979
-
980
- // Update locale files
981
- const localeFiles = [
982
- { file: path.join(targetDir, 'i18n', 'locales', 'zh-CN.json'), lang: 'zh' },
983
- { file: path.join(targetDir, 'i18n', 'locales', 'en-US.json'), lang: 'en' },
984
- ]
985
-
986
- for (const { file, lang } of localeFiles) {
987
- if (!fs.existsSync(file)) continue
988
- try {
989
- const locale = JSON.parse(fs.readFileSync(file, 'utf8'))
990
- const moduleKey = names.kebab
991
- if (!locale[moduleKey]) locale[moduleKey] = {}
992
- if (!locale[moduleKey][names.lower]) locale[moduleKey][names.lower] = {}
993
- if (!locale[moduleKey].validation) locale[moduleKey].validation = {}
994
-
995
- if (lang === 'zh') {
996
- locale[moduleKey][names.lower].submitted = '已提交审批'
997
- locale[moduleKey][names.lower].cancelled = '已撤回审批'
998
- locale[moduleKey].validation.invalidStatusForSubmit = '当前状态不允许提交'
999
- locale[moduleKey].validation.invalidStatusForCancel = '当前状态不允许撤回'
1000
- locale[moduleKey].validation.noApprovalInstance = '无审批实例'
1001
- } else {
1002
- locale[moduleKey][names.lower].submitted = 'Submitted for approval'
1003
- locale[moduleKey][names.lower].cancelled = 'Approval cancelled'
1004
- locale[moduleKey].validation.invalidStatusForSubmit = 'Cannot submit in current status'
1005
- locale[moduleKey].validation.invalidStatusForCancel = 'Cannot cancel in current status'
1006
- locale[moduleKey].validation.noApprovalInstance = 'No approval instance'
1007
- }
1008
-
1009
- fs.writeFileSync(file, JSON.stringify(locale, null, 2), 'utf8')
1010
- } catch {
1011
- // ignore parse errors
1012
- }
1013
- }
1014
- }
1015
-
1016
- function addFrontendApproval(targetDir, names) {
1017
- // 1. Create ApprovalActions component
1018
- createApprovalActionsComponent(targetDir, names)
1019
-
1020
- // 2. Update types.ts to include approval status
1021
- updateFrontendTypes(targetDir, names)
1022
-
1023
- // 3. Add approval API functions
1024
- addApprovalApiServices(targetDir, names)
1025
-
1026
- // 4. Add approval i18n translations
1027
- addFrontendApprovalI18n(targetDir, names)
1028
- }
1029
-
1030
- function createApprovalActionsComponent(targetDir, names) {
1031
- const componentsDir = path.join(targetDir, 'components')
1032
- fs.mkdirSync(componentsDir, { recursive: true })
1033
-
1034
- const content = `import { useCallback, useState } from 'react'
1035
- import { App, Button, Popconfirm, Space, Tag } from 'antd'
1036
- import { CheckCircleOutlined, CloseCircleOutlined, SendOutlined, UndoOutlined } from '@ant-design/icons'
1037
- import { useTranslation } from 'react-i18next'
1038
- import type { ${names.pascal}ItemStatus } from '../types'
1039
-
1040
- interface Props {
1041
- id: number
1042
- status: ${names.pascal}ItemStatus
1043
- onSubmit: (id: number) => Promise<void>
1044
- onCancel: (id: number) => Promise<void>
1045
- onRefresh: () => void
1046
- }
1047
-
1048
- const statusConfig: Record<${names.pascal}ItemStatus, { color: string; icon: React.ReactNode }> = {
1049
- draft: { color: 'default', icon: null },
1050
- pending: { color: 'processing', icon: <CloseCircleOutlined spin /> },
1051
- approved: { color: 'success', icon: <CheckCircleOutlined /> },
1052
- rejected: { color: 'error', icon: <CloseCircleOutlined /> },
1053
- active: { color: 'success', icon: null },
1054
- inactive: { color: 'default', icon: null },
1055
- }
1056
-
1057
- export function ApprovalActions({ id, status, onSubmit, onCancel, onRefresh }: Props) {
1058
- const { t } = useTranslation('${names.camel}')
1059
- const { t: tc } = useTranslation('common')
1060
- const { message } = App.useApp()
1061
- const [loading, setLoading] = useState(false)
1062
-
1063
- const handleSubmit = useCallback(async () => {
1064
- setLoading(true)
1065
- try {
1066
- await onSubmit(id)
1067
- message.success(t('messages.submitSuccess'))
1068
- onRefresh()
1069
- } catch (err) {
1070
- message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
1071
- } finally {
1072
- setLoading(false)
1073
- }
1074
- }, [id, message, onRefresh, onSubmit, t, tc])
1075
-
1076
- const handleCancel = useCallback(async () => {
1077
- setLoading(true)
1078
- try {
1079
- await onCancel(id)
1080
- message.success(t('messages.cancelSuccess'))
1081
- onRefresh()
1082
- } catch (err) {
1083
- message.error(err instanceof Error ? err.message : tc('messages.operationFailed'))
1084
- } finally {
1085
- setLoading(false)
1086
- }
1087
- }, [id, message, onRefresh, onCancel, t, tc])
1088
-
1089
- const config = statusConfig[status] || { color: 'default', icon: null }
1090
-
1091
- return (
1092
- <Space>
1093
- <Tag color={config.color} icon={config.icon}>
1094
- {t(\`status.\${status}\`)}
1095
- </Tag>
1096
-
1097
- {status === 'draft' && (
1098
- <Popconfirm title={t('confirm.submit')} onConfirm={handleSubmit}>
1099
- <Button type="primary" icon={<SendOutlined />} loading={loading} size="small">
1100
- {t('actions.submit')}
1101
- </Button>
1102
- </Popconfirm>
1103
- )}
1104
-
1105
- {status === 'pending' && (
1106
- <Popconfirm title={t('confirm.cancel')} onConfirm={handleCancel}>
1107
- <Button icon={<UndoOutlined />} loading={loading} size="small">
1108
- {t('actions.cancelApproval')}
1109
- </Button>
1110
- </Popconfirm>
1111
- )}
1112
- </Space>
1113
- )
1114
- }
1115
- `
1116
- fs.writeFileSync(path.join(componentsDir, 'ApprovalActions.tsx'), content, 'utf8')
1117
- }
1118
-
1119
- function updateFrontendTypes(targetDir, names) {
1120
- const typesFile = path.join(targetDir, 'types.ts')
1121
- if (!fs.existsSync(typesFile)) return
1122
-
1123
- let content = fs.readFileSync(typesFile, 'utf8')
1124
-
1125
- // Update status type to include approval statuses
1126
- // Pattern: export type XxxItemStatus = 'active' | 'inactive'
1127
- if (!content.includes("'pending'")) {
1128
- content = content.replace(
1129
- /export type (\w+)Status = ['"]active['"] \| ['"]inactive['"]/,
1130
- `export type $1Status = 'draft' | 'pending' | 'approved' | 'rejected' | 'active' | 'inactive'`
1131
- )
1132
- }
1133
-
1134
- // Add approval fields to interface
1135
- // Pattern: status: XxxItemStatus
1136
- if (!content.includes('approval_instance_id')) {
1137
- content = content.replace(
1138
- /(status:\s*\w+Status\s*\n)/,
1139
- `$1 approval_instance_id?: number\n reject_reason?: string\n`
1140
- )
1141
- }
1142
-
1143
- fs.writeFileSync(typesFile, content, 'utf8')
1144
- }
1145
-
1146
- function addApprovalApiServices(targetDir, names) {
1147
- const apiFile = path.join(targetDir, 'services', 'api.ts')
1148
- if (!fs.existsSync(apiFile)) return
1149
-
1150
- let content = fs.readFileSync(apiFile, 'utf8')
1151
-
1152
- if (!content.includes('submit')) {
1153
- content += `
1154
-
1155
- // Approval API
1156
- export const submit${names.pascal} = async (id: number) => {
1157
- await api.post(\`/${names.kebab}/${names.lower}s/\${id}/submit\`)
1158
- }
1159
-
1160
- export const cancel${names.pascal}Approval = async (id: number) => {
1161
- await api.post(\`/${names.kebab}/${names.lower}s/\${id}/cancel\`)
1162
- }
1163
- `
1164
- fs.writeFileSync(apiFile, content, 'utf8')
1165
- }
1166
- }
1167
-
1168
- function addFrontendApprovalI18n(targetDir, names) {
1169
- const localeFiles = [
1170
- { file: path.join(targetDir, 'locales', 'zh-CN', `${names.camel}.json`), lang: 'zh' },
1171
- { file: path.join(targetDir, 'locales', 'en-US', `${names.camel}.json`), lang: 'en' },
1172
- ]
1173
-
1174
- for (const { file, lang } of localeFiles) {
1175
- if (!fs.existsSync(file)) continue
1176
- try {
1177
- const locale = JSON.parse(fs.readFileSync(file, 'utf8'))
1178
-
1179
- // Add approval status translations
1180
- if (!locale.status) locale.status = {}
1181
- if (!locale.messages) locale.messages = {}
1182
- if (!locale.actions) locale.actions = {}
1183
- if (!locale.confirm) locale.confirm = {}
1184
-
1185
- if (lang === 'zh') {
1186
- locale.status.draft = '草稿'
1187
- locale.status.pending = '审批中'
1188
- locale.status.approved = '已通过'
1189
- locale.status.rejected = '已拒绝'
1190
- locale.messages.submitSuccess = '提交成功'
1191
- locale.messages.cancelSuccess = '撤回成功'
1192
- locale.actions.submit = '提交审批'
1193
- locale.actions.cancelApproval = '撤回'
1194
- locale.confirm.submit = '确定要提交审批吗?'
1195
- locale.confirm.cancel = '确定要撤回审批吗?'
1196
- } else {
1197
- locale.status.draft = 'Draft'
1198
- locale.status.pending = 'Pending'
1199
- locale.status.approved = 'Approved'
1200
- locale.status.rejected = 'Rejected'
1201
- locale.messages.submitSuccess = 'Submitted successfully'
1202
- locale.messages.cancelSuccess = 'Cancelled successfully'
1203
- locale.actions.submit = 'Submit'
1204
- locale.actions.cancelApproval = 'Cancel'
1205
- locale.confirm.submit = 'Are you sure to submit for approval?'
1206
- locale.confirm.cancel = 'Are you sure to cancel the approval?'
1207
- }
1208
-
1209
- fs.writeFileSync(file, JSON.stringify(locale, null, 2), 'utf8')
1210
- } catch {
1211
- // ignore parse errors
1212
- }
1213
- }
1243
+ fs.writeFileSync(readmePath, content, 'utf8')
1214
1244
  }
1215
1245
 
1216
1246
  function fail(message) {