@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.
- package/README.md +46 -44
- package/dist/create-keystone-app.js +347 -10
- package/dist/create-module.js +1217 -1187
- package/package.json +1 -1
- package/template/.claude/skills/keystone-implement/SKILL.md +113 -0
- package/template/.claude/skills/keystone-implement/references/CHECKLIST.md +91 -0
- package/template/.claude/skills/keystone-implement/references/PATTERNS.md +1088 -0
- package/template/.claude/skills/keystone-implement/references/SCHEMA.md +135 -0
- package/template/.claude/skills/keystone-implement/references/TESTING.md +231 -0
- package/template/.claude/skills/keystone-requirements/SKILL.md +296 -0
- package/template/.claude/skills/keystone-requirements/references/CONFIRM_TEMPLATE.md +170 -0
- package/template/.claude/skills/keystone-requirements/references/SCHEMA.md +135 -0
- package/template/.eslintrc.js +3 -0
- package/template/.github/workflows/ci.yml +30 -0
- package/template/.github/workflows/release.yml +32 -0
- package/template/.golangci.yml +11 -0
- package/template/README.md +82 -81
- package/template/apps/server/README.md +8 -0
- package/template/apps/server/cmd/server/main.go +27 -185
- package/template/apps/server/config.example.yaml +31 -1
- package/template/apps/server/config.yaml +31 -1
- package/template/apps/server/go.mod +61 -19
- package/template/apps/server/go.sum +185 -32
- package/template/apps/server/internal/frontend/embed.go +3 -8
- package/template/apps/server/internal/modules/example/README.md +18 -0
- package/template/apps/server/internal/modules/example/api/handler/handler_test.go +9 -0
- package/template/apps/server/internal/modules/example/api/handler/item_handler.go +468 -165
- package/template/apps/server/internal/modules/example/bootstrap/seeds/item.go +217 -8
- package/template/apps/server/internal/modules/example/domain/models/item.go +40 -7
- package/template/apps/server/internal/modules/example/domain/service/approval_callback.go +68 -0
- package/template/apps/server/internal/modules/example/domain/service/approval_schema.go +41 -0
- package/template/apps/server/internal/modules/example/domain/service/errors.go +20 -22
- package/template/apps/server/internal/modules/example/domain/service/item_service.go +267 -7
- package/template/apps/server/internal/modules/example/domain/service/item_service_test.go +281 -0
- package/template/apps/server/internal/modules/example/i18n/keys.go +32 -20
- package/template/apps/server/internal/modules/example/i18n/locales/en-US.json +30 -18
- package/template/apps/server/internal/modules/example/i18n/locales/zh-CN.json +30 -18
- package/template/apps/server/internal/modules/example/infra/exporter/item_exporter.go +119 -0
- package/template/apps/server/internal/modules/example/infra/importer/item_importer.go +77 -0
- package/template/apps/server/internal/modules/example/infra/repository/item_repository.go +99 -49
- package/template/apps/server/internal/modules/example/module.go +171 -97
- package/template/apps/server/internal/modules/example/tests/integration_test.go +7 -0
- package/template/apps/server/internal/modules/manifest.go +7 -7
- package/template/apps/web/README.md +4 -2
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/app.config.ts +8 -6
- package/template/apps/web/src/index.css +7 -3
- package/template/apps/web/src/main.tsx +2 -5
- package/template/apps/web/src/modules/example/help/en-US/faq.md +27 -0
- package/template/apps/web/src/modules/example/help/en-US/items.md +30 -0
- package/template/apps/web/src/modules/example/help/en-US/overview.md +31 -0
- package/template/apps/web/src/modules/example/help/zh-CN/faq.md +27 -0
- package/template/apps/web/src/modules/example/help/zh-CN/items.md +31 -0
- package/template/apps/web/src/modules/example/help/zh-CN/overview.md +32 -0
- package/template/apps/web/src/modules/example/locales/en-US/example.json +99 -32
- package/template/apps/web/src/modules/example/locales/zh-CN/example.json +85 -18
- package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +840 -237
- package/template/apps/web/src/modules/example/services/exampleItems.ts +79 -8
- package/template/apps/web/src/modules/example/types.ts +14 -1
- package/template/apps/web/src/modules/index.ts +1 -0
- package/template/apps/web/vite.config.ts +9 -3
- package/template/docs/CONVENTIONS.md +17 -17
- package/template/package.json +4 -5
- package/template/pnpm-lock.yaml +76 -5
- package/template/scripts/build.bat +15 -3
- package/template/scripts/build.sh +9 -3
- package/template/scripts/check-help.js +249 -0
- package/template/scripts/compress-assets.js +89 -0
- package/template/scripts/test.bat +23 -0
- package/template/scripts/test.sh +16 -0
- package/template/.claude/skills/keystone-dev/SKILL.md +0 -90
- package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
- package/template/.claude/skills/keystone-dev/references/APPROVAL.md +0 -121
- package/template/.claude/skills/keystone-dev/references/CAPABILITIES.md +0 -261
- package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +0 -285
- package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +0 -390
- package/template/.claude/skills/keystone-dev/references/PATTERNS.md +0 -605
- package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +0 -2710
- package/template/.claude/skills/keystone-dev/references/TESTING.md +0 -44
- package/template/.codex/skills/keystone-dev/SKILL.md +0 -90
- package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +0 -716
- package/template/.codex/skills/keystone-dev/references/APPROVAL.md +0 -121
- package/template/.codex/skills/keystone-dev/references/CAPABILITIES.md +0 -261
- package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +0 -285
- package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +0 -390
- package/template/.codex/skills/keystone-dev/references/PATTERNS.md +0 -605
- package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +0 -2710
- package/template/.codex/skills/keystone-dev/references/TESTING.md +0 -44
- package/template/apps/server/internal/app/routes/module_routes.go +0 -16
- package/template/apps/server/internal/app/routes/routes.go +0 -226
- package/template/apps/server/internal/app/startup/startup.go +0 -74
- package/template/apps/server/internal/frontend/handler.go +0 -122
- package/template/apps/server/internal/modules/registry.go +0 -145
- package/template/apps/web/src/modules/example/help/faq.md +0 -23
- package/template/apps/web/src/modules/example/help/items.md +0 -26
- package/template/apps/web/src/modules/example/help/overview.md +0 -25
package/dist/create-module.js
CHANGED
|
@@ -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
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
1225
|
+
'## Overview',
|
|
1226
|
+
`- Module key: \`${names.kebab}\``,
|
|
1227
|
+
'- Description: TODO',
|
|
454
1228
|
'',
|
|
455
|
-
'
|
|
456
|
-
'
|
|
457
|
-
'}',
|
|
1229
|
+
'## API Endpoints',
|
|
1230
|
+
'- TODO: list REST endpoints and handlers',
|
|
458
1231
|
'',
|
|
459
|
-
'
|
|
460
|
-
|
|
461
|
-
'}',
|
|
1232
|
+
'## Permissions',
|
|
1233
|
+
'- TODO: list permission codes',
|
|
462
1234
|
'',
|
|
463
|
-
'
|
|
464
|
-
'',
|
|
465
|
-
'
|
|
466
|
-
|
|
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(
|
|
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) {
|