@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 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
@@ -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.13",
4
- "scripts": {
5
- "build": "node scripts/build.js",
6
- "prepublishOnly": "node scripts/build.js && node scripts/prune-template-deps.js"
7
- },
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "bin": {
12
- "create-keystone-app": "dist/create-keystone-app.js",
13
- "create-keystone-module": "dist/create-module.js"
14
- },
15
- "files": [
16
- "dist",
17
- "template",
18
- "README.md"
19
- ],
20
- "engines": {
21
- "node": ">=18"
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
+ }