@robsun/create-keystone-app 0.2.13 → 0.2.15
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 +1 -0
- package/dist/create-keystone-app.js +0 -0
- package/dist/create-module.js +584 -2
- package/package.json +22 -23
- package/template/.claude/skills/keystone-dev/SKILL.md +90 -103
- package/template/.claude/skills/keystone-dev/references/ADVANCED_PATTERNS.md +716 -0
- package/template/.claude/skills/keystone-dev/references/CHECKLIST.md +285 -0
- package/template/.claude/skills/keystone-dev/references/GOTCHAS.md +390 -0
- package/template/.claude/skills/keystone-dev/references/PATTERNS.md +605 -0
- package/template/.claude/skills/keystone-dev/references/TEMPLATES.md +2562 -384
- package/template/.codex/skills/keystone-dev/SKILL.md +90 -103
- package/template/.codex/skills/keystone-dev/references/ADVANCED_PATTERNS.md +716 -0
- package/template/.codex/skills/keystone-dev/references/CHECKLIST.md +285 -0
- package/template/.codex/skills/keystone-dev/references/GOTCHAS.md +390 -0
- package/template/.codex/skills/keystone-dev/references/PATTERNS.md +605 -0
- package/template/.codex/skills/keystone-dev/references/TEMPLATES.md +2562 -384
- package/template/README.md +8 -1
- package/template/docs/CONVENTIONS.md +11 -8
- package/template/package.json +3 -3
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ pnpm dlx --package @robsun/create-keystone-app create-keystone-module <module-na
|
|
|
29
29
|
- `--frontend-only`:只生成前端模块。
|
|
30
30
|
- `--backend-only`:只生成后端模块。
|
|
31
31
|
- `--with-crud`:包含 CRUD 示例代码。
|
|
32
|
+
- `--with-approval`:包含审批流代码(自动启用 `--with-crud`)。
|
|
32
33
|
- `--skip-register`:跳过自动注册步骤。
|
|
33
34
|
|
|
34
35
|
## AI Skills
|
|
File without changes
|
package/dist/create-module.js
CHANGED
|
@@ -11,6 +11,7 @@ const usage = [
|
|
|
11
11
|
' --frontend-only Only generate the frontend module',
|
|
12
12
|
' --backend-only Only generate the backend module',
|
|
13
13
|
' --with-crud Include CRUD example code',
|
|
14
|
+
' --with-approval Include approval workflow code (implies --with-crud)',
|
|
14
15
|
' --skip-register Skip auto registration steps',
|
|
15
16
|
' -h, --help Show help',
|
|
16
17
|
'',
|
|
@@ -35,7 +36,8 @@ async function main() {
|
|
|
35
36
|
|
|
36
37
|
let includeFrontend = !args.backendOnly
|
|
37
38
|
let includeBackend = !args.frontendOnly
|
|
38
|
-
let withCrud = args.withCrud
|
|
39
|
+
let withCrud = args.withCrud || args.withApproval // --with-approval implies --with-crud
|
|
40
|
+
let withApproval = args.withApproval
|
|
39
41
|
let skipRegister = args.skipRegister
|
|
40
42
|
|
|
41
43
|
try {
|
|
@@ -67,9 +69,12 @@ async function main() {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
if (!args.withCrud && rl) {
|
|
72
|
+
if (!args.withCrud && !args.withApproval && rl) {
|
|
71
73
|
withCrud = await promptConfirm(rl, 'Include CRUD example code', false)
|
|
72
74
|
}
|
|
75
|
+
if (withCrud && !args.withApproval && rl) {
|
|
76
|
+
withApproval = await promptConfirm(rl, 'Include approval workflow code', false)
|
|
77
|
+
}
|
|
73
78
|
if (!args.skipRegister && rl) {
|
|
74
79
|
const autoRegister = await promptConfirm(rl, 'Auto-register module', true)
|
|
75
80
|
skipRegister = !autoRegister
|
|
@@ -99,6 +104,9 @@ async function main() {
|
|
|
99
104
|
if (!withCrud) {
|
|
100
105
|
pruneFrontendCrud(frontendTargetDir, names)
|
|
101
106
|
}
|
|
107
|
+
if (withApproval) {
|
|
108
|
+
addFrontendApproval(frontendTargetDir, names)
|
|
109
|
+
}
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
if (includeBackend) {
|
|
@@ -112,6 +120,9 @@ async function main() {
|
|
|
112
120
|
if (!withCrud) {
|
|
113
121
|
pruneBackendCrud(backendTargetDir, names)
|
|
114
122
|
}
|
|
123
|
+
if (withApproval) {
|
|
124
|
+
addBackendApproval(backendTargetDir, names)
|
|
125
|
+
}
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
if (!skipRegister) {
|
|
@@ -140,6 +151,12 @@ async function main() {
|
|
|
140
151
|
if (!withCrud) {
|
|
141
152
|
console.log('Generated minimal scaffolding (no CRUD example).')
|
|
142
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
|
+
}
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
function isInteractive() {
|
|
@@ -595,6 +612,7 @@ function parseArgs(argv) {
|
|
|
595
612
|
frontendOnly: false,
|
|
596
613
|
backendOnly: false,
|
|
597
614
|
withCrud: false,
|
|
615
|
+
withApproval: false,
|
|
598
616
|
skipRegister: false,
|
|
599
617
|
help: false,
|
|
600
618
|
}
|
|
@@ -615,6 +633,10 @@ function parseArgs(argv) {
|
|
|
615
633
|
out.withCrud = true
|
|
616
634
|
continue
|
|
617
635
|
}
|
|
636
|
+
if (arg === '--with-approval') {
|
|
637
|
+
out.withApproval = true
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
618
640
|
if (arg === '--skip-register') {
|
|
619
641
|
out.skipRegister = true
|
|
620
642
|
continue
|
|
@@ -631,6 +653,566 @@ function parseArgs(argv) {
|
|
|
631
653
|
return out
|
|
632
654
|
}
|
|
633
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
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
634
1216
|
function fail(message) {
|
|
635
1217
|
console.error(message)
|
|
636
1218
|
console.error(usage)
|
package/package.json
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@robsun/create-keystone-app",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
},
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@robsun/create-keystone-app",
|
|
3
|
+
"version": "0.2.15",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-keystone-app": "dist/create-keystone-app.js",
|
|
9
|
+
"create-keystone-module": "dist/create-module.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"template",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "node scripts/build.js"
|
|
21
|
+
}
|
|
22
|
+
}
|