create-fullstack-scaffold 0.4.24 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/cli/index.js +439 -15
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +10 -13
  4. package/template/eslint-rules/__tests__/no-merged-api-type-export.test.ts +96 -0
  5. package/template/eslint-rules/no-cross-module-service-import.js +4 -0
  6. package/template/eslint-rules/no-merged-api-type-export.js +190 -0
  7. package/template/eslint.config.js +3 -0
  8. package/template/package.json +9 -6
  9. package/template/scripts/sync-agent-hooks.mjs +189 -0
  10. package/template/src/admin/components/__tests__/StatsCard.test.tsx +2 -2
  11. package/template/src/admin/pages/ContentPage.tsx +1 -1
  12. package/template/src/admin/pages/DisputesPage.tsx +1 -1
  13. package/template/src/admin/pages/OrdersPage.tsx +1 -1
  14. package/template/src/admin/pages/TicketsPage.tsx +1 -1
  15. package/template/src/admin/pages/__tests__/ContentPage.test.tsx +5 -1
  16. package/template/src/admin/pages/__tests__/DashboardPage.test.tsx +62 -8
  17. package/template/src/admin/pages/__tests__/DisputesPage.test.tsx +8 -1
  18. package/template/src/admin/pages/__tests__/OrdersPage.test.tsx +4 -2
  19. package/template/src/admin/pages/__tests__/RegisterPage.test.tsx +40 -43
  20. package/template/src/admin/pages/__tests__/SettingsPage.test.tsx +83 -21
  21. package/template/src/admin/pages/__tests__/TicketsPage.test.tsx +3 -1
  22. package/template/src/admin/services/apiClient.ts +2 -3
  23. package/template/src/cli/modules/content/index.ts +3 -3
  24. package/template/src/cli/modules/dispute/index.ts +3 -3
  25. package/template/src/cli/modules/ticket/index.ts +3 -3
  26. package/template/src/cli/modules/todo/index.ts +1 -1
  27. package/template/src/cli/rpc/client.ts +3 -4
  28. package/template/src/cli/rpc/index.ts +1 -1
  29. package/template/src/client/App.tsx +3 -39
  30. package/template/src/client/AppRoutes.tsx +53 -0
  31. package/template/src/client/entry-server.tsx +75 -0
  32. package/template/src/client/main.tsx +3 -1
  33. package/template/src/client/pages/SearchPage.tsx +1 -1
  34. package/template/src/client/services/apiClient.ts +12 -4
  35. package/template/src/client/stores/__tests__/todoStore.test.ts +12 -3
  36. package/template/src/client/stores/entry-stores.ts +36 -0
  37. package/template/src/client/stores/notificationStore.ts +4 -4
  38. package/template/src/client/stores/todoStore.ts +2 -2
  39. package/template/src/merchant/pages/DisputesPage.tsx +3 -3
  40. package/template/src/merchant/pages/OrdersPage.tsx +1 -1
  41. package/template/src/merchant/pages/ProductsPage.tsx +1 -1
  42. package/template/src/merchant/pages/SettingsPage.tsx +1 -1
  43. package/template/src/server/__tests__/integration/isr-full-flow.test.ts +251 -0
  44. package/template/src/server/__tests__/integration/todos-api.test.ts +7 -4
  45. package/template/src/server/app.ts +6 -4
  46. package/template/src/server/core/__tests__/isr-cache.test.ts +96 -92
  47. package/template/src/server/core/__tests__/isr-invalidation.test.ts +29 -4
  48. package/template/src/server/core/__tests__/isr-registry.test.ts +215 -0
  49. package/template/src/server/core/__tests__/isr-renderer.test.ts +121 -0
  50. package/template/src/server/core/isr-cache.ts +6 -12
  51. package/template/src/server/core/isr-invalidation.ts +6 -12
  52. package/template/src/server/core/isr-registry.ts +104 -0
  53. package/template/src/server/core/isr-renderer.ts +75 -0
  54. package/template/src/server/db/schema/contents.ts +28 -20
  55. package/template/src/server/db/schema/disputes.ts +30 -22
  56. package/template/src/server/db/schema/notifications.ts +20 -14
  57. package/template/src/server/db/schema/orders.ts +23 -16
  58. package/template/src/server/db/schema/plugins.ts +56 -38
  59. package/template/src/server/db/schema/products.ts +24 -17
  60. package/template/src/server/db/schema/tickets.ts +44 -31
  61. package/template/src/server/db/schema/todos.ts +26 -18
  62. package/template/src/server/entries/cloudflare.ts +82 -19
  63. package/template/src/server/entries/node.ts +0 -2
  64. package/template/src/server/index.ts +0 -1
  65. package/template/src/server/isr-modules.ts +10 -0
  66. package/template/src/server/module-admin/__tests__/admin-service.test.ts +36 -12
  67. package/template/src/server/module-admin/routes/admin-notification-routes.ts +2 -1
  68. package/template/src/server/module-admin/routes/admin-routes.ts +3 -0
  69. package/template/src/server/module-admin/routes/dashboard-routes.ts +3 -0
  70. package/template/src/server/module-admin/services/admin-service.ts +57 -13
  71. package/template/src/server/module-auth/routes/auth-routes.ts +3 -0
  72. package/template/src/server/module-auth/routes/profile-routes.ts +3 -0
  73. package/template/src/server/module-captcha/routes/captcha-routes.ts +3 -0
  74. package/template/src/server/module-chat/routes/chat-routes.ts +4 -1
  75. package/template/src/server/module-content/__tests__/content-route.test.ts +2 -1
  76. package/template/src/server/module-content/__tests__/content-service.test.ts +2 -1
  77. package/template/src/server/module-content/__tests__/isr.test.ts +138 -0
  78. package/template/src/server/module-content/isr.ts +63 -0
  79. package/template/src/server/module-content/routes/content-routes.ts +10 -10
  80. package/template/src/server/module-content/routes/public-content-routes.ts +8 -4
  81. package/template/src/server/module-content/routes/topics-routes.ts +3 -0
  82. package/template/src/server/module-content/services/content-service.ts +23 -10
  83. package/template/src/server/module-dispute/__tests__/dispute-route.test.ts +2 -1
  84. package/template/src/server/module-dispute/__tests__/dispute-service.test.ts +2 -1
  85. package/template/src/server/module-dispute/routes/dispute-routes.ts +11 -10
  86. package/template/src/server/module-dispute/services/dispute-service.ts +15 -3
  87. package/template/src/server/module-file/routes/file-routes.ts +3 -0
  88. package/template/src/server/module-merchant/routes/merchant-routes.ts +3 -0
  89. package/template/src/server/module-merchant/services/merchant-service.ts +8 -8
  90. package/template/src/server/module-notifications/routes/notification-routes.ts +5 -1
  91. package/template/src/server/module-order/__tests__/order-route.test.ts +8 -8
  92. package/template/src/server/module-order/__tests__/order-service.test.ts +4 -3
  93. package/template/src/server/module-order/routes/cart-routes.ts +3 -0
  94. package/template/src/server/module-order/routes/order-routes.ts +9 -4
  95. package/template/src/server/module-order/routes/orders-mock-routes.ts +3 -0
  96. package/template/src/server/module-order/services/order-service.ts +25 -13
  97. package/template/src/server/module-permission/__tests__/audit-log-service.test.ts +464 -0
  98. package/template/src/server/module-permission/__tests__/role-service.test.ts +348 -0
  99. package/template/src/server/module-permission/routes/audit-log-routes.ts +3 -0
  100. package/template/src/server/module-permission/routes/permission-routes.ts +3 -0
  101. package/template/src/server/module-permission/routes/role-routes.ts +3 -0
  102. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +3 -0
  103. package/template/src/server/module-plugin/routes/plugin-routes.ts +3 -0
  104. package/template/src/server/module-plugin/services/admin-plugin-service.ts +10 -18
  105. package/template/src/server/module-plugin/services/admin-stats-service.ts +28 -9
  106. package/template/src/server/module-plugin/services/plugin-query-service.ts +42 -66
  107. package/template/src/server/module-tenant/routes/tenant-routes.ts +5 -1
  108. package/template/src/server/module-ticket/__tests__/ticket-route.test.ts +2 -1
  109. package/template/src/server/module-ticket/__tests__/ticket-service.test.ts +4 -3
  110. package/template/src/server/module-ticket/routes/ticket-routes.ts +11 -10
  111. package/template/src/server/module-ticket/services/ticket-service.ts +16 -5
  112. package/template/src/server/module-todos/__tests__/isr.test.ts +105 -0
  113. package/template/src/server/module-todos/__tests__/todo-service.test.ts +12 -9
  114. package/template/src/server/module-todos/__tests__/todos-route-rpc.test.ts +6 -6
  115. package/template/src/server/module-todos/isr.ts +41 -0
  116. package/template/src/server/module-todos/routes/todos-routes.ts +12 -5
  117. package/template/src/server/module-todos/services/todo-service.ts +31 -10
  118. package/template/src/server/route-registry.ts +0 -4
  119. package/template/src/server/rpc-merge.ts +27 -0
  120. package/template/src/server/rpc-surface.ts +117 -0
  121. package/template/src/server/rpc-type-canary.ts +80 -0
  122. package/template/src/server/test-utils/test-client.ts +7 -9
  123. package/template/src/server/test-utils/test-isr-helper.ts +140 -0
  124. package/template/src/shared/modules/content/schemas.ts +14 -0
  125. package/template/src/shared/modules/dispute/schemas.ts +14 -0
  126. package/template/src/shared/modules/order/schemas.ts +9 -1
  127. package/template/src/shared/modules/ticket/schemas.ts +14 -0
  128. package/template/src/shared/modules/todos/index.ts +4 -0
  129. package/template/src/shared/modules/todos/schemas.ts +14 -0
  130. package/template/src/shared/schemas/index.ts +18 -0
  131. package/template/tsup.config.ts +48 -1
  132. package/template/vitest.config.ts +19 -3
  133. package/template/vitest.setup.ts +68 -5
  134. package/template/drizzle/0000_rainy_boomer.sql +0 -101
  135. package/template/drizzle/0001_add_todo_attachments.sql +0 -12
  136. package/template/drizzle/0002_chilly_magneto.sql +0 -89
  137. package/template/drizzle/0003_ambiguous_magdalene.sql +0 -100
  138. package/template/drizzle/0004_add_merchants_products.sql +0 -30
  139. package/template/drizzle/meta/0000_snapshot.json +0 -652
  140. package/template/drizzle/meta/0001_snapshot.json +0 -735
  141. package/template/drizzle/meta/0002_snapshot.json +0 -1198
  142. package/template/drizzle/meta/0003_snapshot.json +0 -1837
  143. package/template/drizzle/meta/_journal.json +0 -41
  144. package/template/patches/typescript+5.9.3.patch +0 -24
  145. package/template/pnpm-lock.yaml +0 -7137
  146. /package/template/patches/{hono+4.12.16.patch → hono+4.12.34.patch} +0 -0
@@ -56,7 +56,7 @@ export const TicketsPage: React.FC = () => {
56
56
  const response = await apiClient.api.tickets.$get({ query: {} })
57
57
  const result = await response.json()
58
58
  if (result.success) {
59
- setTickets(result.data)
59
+ setTickets(result.data.tickets)
60
60
  } else {
61
61
  message.error(result.error || t('tickets.loadFailed'))
62
62
  }
@@ -76,7 +76,11 @@ describe('ContentPage', () => {
76
76
  beforeEach(() => {
77
77
  vi.clearAllMocks()
78
78
  mockContentsGet.mockResolvedValue({
79
- json: () => Promise.resolve({ success: true, data: mockContents }),
79
+ json: () =>
80
+ Promise.resolve({
81
+ success: true,
82
+ data: { contents: mockContents, total: mockContents.length, page: 1, limit: 20 },
83
+ }),
80
84
  })
81
85
  mockContentPost.mockResolvedValue({
82
86
  json: () => Promise.resolve({ success: true, data: { id: '3' } }),
@@ -24,14 +24,68 @@ vi.mock('@admin/services/apiClient', () => ({
24
24
  }))
25
25
 
26
26
  vi.mock('antd', async () => {
27
- const actual = await vi.importActual('antd')
27
+ const React = await import('react')
28
28
  return {
29
- ...actual,
29
+ Button: ({
30
+ children,
31
+ onClick,
32
+ loading,
33
+ ...rest
34
+ }: {
35
+ children: React.ReactNode
36
+ onClick?: () => void
37
+ loading?: boolean
38
+ [key: string]: unknown
39
+ }) => React.createElement('button', { onClick, 'data-loading': loading, ...rest }, children),
40
+ Select: ({
41
+ value,
42
+ onChange,
43
+ options,
44
+ ...rest
45
+ }: {
46
+ value: string
47
+ onChange?: (v: string) => void
48
+ options?: { value: string; label: string }[]
49
+ [key: string]: unknown
50
+ }) =>
51
+ React.createElement(
52
+ 'select',
53
+ {
54
+ value,
55
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => onChange?.(e.target.value),
56
+ 'data-testid': 'notification-type-select',
57
+ ...rest,
58
+ },
59
+ options?.map((o: { value: string; label: string }) =>
60
+ React.createElement('option', { key: o.value, value: o.value }, o.label)
61
+ )
62
+ ),
30
63
  message: {
31
64
  success: vi.fn(),
32
65
  error: vi.fn(),
33
66
  },
67
+ theme: {
68
+ useToken: () => ({
69
+ token: {
70
+ colorText: '#000',
71
+ colorTextSecondary: '#666',
72
+ colorBgContainer: '#fff',
73
+ colorBorderSecondary: '#eee',
74
+ },
75
+ }),
76
+ },
77
+ }
78
+ })
79
+
80
+ vi.mock('lucide-react', async () => {
81
+ const React = await import('react')
82
+ const icons = ['Activity', 'CheckCircle', 'Clock', 'TrendingUp', 'BellRing']
83
+ const mock: Record<string, React.FC<{ className?: string; style?: unknown }>> = {}
84
+ for (const name of icons) {
85
+ mock[name] = (props: { className?: string; style?: unknown }) =>
86
+ React.createElement('span', { 'data-testid': `icon-${name}`, ...props })
34
87
  }
88
+ return mock
35
89
  })
36
90
 
37
91
  describe('DashboardPage', () => {
@@ -55,7 +109,7 @@ describe('DashboardPage', () => {
55
109
  render(<DashboardPage />)
56
110
 
57
111
  await waitFor(() => {
58
- expect(screen.getByText('Dashboard')).toBeInTheDocument()
112
+ expect(screen.getByText('仪表盘')).toBeInTheDocument()
59
113
  })
60
114
  })
61
115
 
@@ -63,20 +117,20 @@ describe('DashboardPage', () => {
63
117
  mockStatsGet.mockReturnValue(new Promise(() => {}))
64
118
  render(<DashboardPage />)
65
119
 
66
- expect(screen.getByText('Loading...')).toBeInTheDocument()
120
+ expect(screen.getByText('加载中...')).toBeInTheDocument()
67
121
  })
68
122
 
69
123
  it('displays stats cards after data loads', async () => {
70
124
  render(<DashboardPage />)
71
125
 
72
126
  await waitFor(() => {
73
- expect(screen.getByText('Total Todos')).toBeInTheDocument()
127
+ expect(screen.getByText('总待办')).toBeInTheDocument()
74
128
  expect(screen.getByText('42')).toBeInTheDocument()
75
- expect(screen.getByText('Pending')).toBeInTheDocument()
129
+ expect(screen.getByText('待处理')).toBeInTheDocument()
76
130
  expect(screen.getByText('10')).toBeInTheDocument()
77
- expect(screen.getByText('Completed')).toBeInTheDocument()
131
+ expect(screen.getByText('已完成')).toBeInTheDocument()
78
132
  expect(screen.getByText('32')).toBeInTheDocument()
79
- expect(screen.getByText('Last Updated')).toBeInTheDocument()
133
+ expect(screen.getByText('最后更新')).toBeInTheDocument()
80
134
  expect(screen.getByText('2025-01-01')).toBeInTheDocument()
81
135
  })
82
136
  })
@@ -117,7 +117,14 @@ function createSuccessResponse(data: unknown) {
117
117
  describe('DisputesPage', () => {
118
118
  beforeEach(() => {
119
119
  vi.clearAllMocks()
120
- mockDisputesGet.mockReturnValue(createSuccessResponse(mockDisputes))
120
+ mockDisputesGet.mockReturnValue(
121
+ createSuccessResponse({
122
+ disputes: mockDisputes,
123
+ total: mockDisputes.length,
124
+ page: 1,
125
+ limit: 20,
126
+ })
127
+ )
121
128
  mockDisputeResolvePut.mockReturnValue(createSuccessResponse(null))
122
129
  })
123
130
 
@@ -114,7 +114,9 @@ function createSuccessResponse(data: unknown) {
114
114
  describe('OrdersPage', () => {
115
115
  beforeEach(() => {
116
116
  vi.clearAllMocks()
117
- mockOrdersGet.mockReturnValue(createSuccessResponse(mockOrders))
117
+ mockOrdersGet.mockReturnValue(
118
+ createSuccessResponse({ orders: mockOrders, total: mockOrders.length, page: 1, limit: 20 })
119
+ )
118
120
  mockOrderProcessPut.mockReturnValue(createSuccessResponse(null))
119
121
  mockOrderCancelPut.mockReturnValue(createSuccessResponse(null))
120
122
  })
@@ -153,7 +155,7 @@ describe('OrdersPage', () => {
153
155
  render(<OrdersPage />)
154
156
 
155
157
  await waitFor(() => {
156
- expect(mockOrdersGet).toHaveBeenCalledTimes(1)
158
+ expect(mockOrdersGet).toHaveBeenCalled()
157
159
  })
158
160
  })
159
161
 
@@ -34,6 +34,10 @@ vi.mock('antd', async () => {
34
34
  }
35
35
  })
36
36
 
37
+ function getSubmitButton() {
38
+ return screen.getByRole('button', { name: /注\s*册/ })
39
+ }
40
+
37
41
  describe('RegisterPage', () => {
38
42
  beforeEach(() => {
39
43
  vi.clearAllMocks()
@@ -42,19 +46,18 @@ describe('RegisterPage', () => {
42
46
  it('renders registration form', () => {
43
47
  render(<RegisterPage />)
44
48
 
45
- expect(screen.getByText('Admin Registration')).toBeInTheDocument()
46
- expect(screen.getByText('Register')).toBeInTheDocument()
49
+ expect(screen.getByText('管理员注册')).toBeInTheDocument()
50
+ expect(getSubmitButton()).toBeInTheDocument()
47
51
  })
48
52
 
49
53
  it('validates required fields', async () => {
50
54
  const user = userEvent.setup()
51
55
  render(<RegisterPage />)
52
56
 
53
- const submitButton = screen.getByRole('button', { name: /register/i })
54
- await user.click(submitButton)
57
+ await user.click(getSubmitButton())
55
58
 
56
59
  await waitFor(() => {
57
- expect(screen.getByText('Please input username!')).toBeInTheDocument()
60
+ expect(screen.getByText('请输入用户名!')).toBeInTheDocument()
58
61
  })
59
62
  })
60
63
 
@@ -62,16 +65,15 @@ describe('RegisterPage', () => {
62
65
  const user = userEvent.setup()
63
66
  render(<RegisterPage />)
64
67
 
65
- await user.type(screen.getByPlaceholderText('Username'), 'testuser')
66
- await user.type(screen.getByPlaceholderText('Email'), 'invalid-email')
67
- await user.type(screen.getByPlaceholderText('Password'), 'password123')
68
- await user.type(screen.getByPlaceholderText('Confirm Password'), 'password123')
68
+ await user.type(screen.getByPlaceholderText('用户名'), 'testuser')
69
+ await user.type(screen.getByPlaceholderText('邮箱'), 'invalid-email')
70
+ await user.type(screen.getByPlaceholderText('密码'), 'password123')
71
+ await user.type(screen.getByPlaceholderText('确认密码'), 'password123')
69
72
 
70
- const submitButton = screen.getByRole('button', { name: /register/i })
71
- await user.click(submitButton)
73
+ await user.click(getSubmitButton())
72
74
 
73
75
  await waitFor(() => {
74
- expect(screen.getByText('Please input valid email!')).toBeInTheDocument()
76
+ expect(screen.getByText('请输入有效的邮箱!')).toBeInTheDocument()
75
77
  })
76
78
  })
77
79
 
@@ -79,15 +81,14 @@ describe('RegisterPage', () => {
79
81
  const user = userEvent.setup()
80
82
  render(<RegisterPage />)
81
83
 
82
- await user.type(screen.getByPlaceholderText('Username'), 'testuser')
83
- await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
84
- await user.type(screen.getByPlaceholderText('Password'), '12345')
84
+ await user.type(screen.getByPlaceholderText('用户名'), 'testuser')
85
+ await user.type(screen.getByPlaceholderText('邮箱'), 'test@example.com')
86
+ await user.type(screen.getByPlaceholderText('密码'), '12345')
85
87
 
86
- const submitButton = screen.getByRole('button', { name: /register/i })
87
- await user.click(submitButton)
88
+ await user.click(getSubmitButton())
88
89
 
89
90
  await waitFor(() => {
90
- expect(screen.getByText('Password must be at least 6 characters!')).toBeInTheDocument()
91
+ expect(screen.getByText('密码至少6个字符!')).toBeInTheDocument()
91
92
  })
92
93
  })
93
94
 
@@ -95,16 +96,15 @@ describe('RegisterPage', () => {
95
96
  const user = userEvent.setup()
96
97
  render(<RegisterPage />)
97
98
 
98
- await user.type(screen.getByPlaceholderText('Username'), 'testuser')
99
- await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
100
- await user.type(screen.getByPlaceholderText('Password'), 'password123')
101
- await user.type(screen.getByPlaceholderText('Confirm Password'), 'different')
99
+ await user.type(screen.getByPlaceholderText('用户名'), 'testuser')
100
+ await user.type(screen.getByPlaceholderText('邮箱'), 'test@example.com')
101
+ await user.type(screen.getByPlaceholderText('密码'), 'password123')
102
+ await user.type(screen.getByPlaceholderText('确认密码'), 'different')
102
103
 
103
- const submitButton = screen.getByRole('button', { name: /register/i })
104
- await user.click(submitButton)
104
+ await user.click(getSubmitButton())
105
105
 
106
106
  await waitFor(() => {
107
- expect(screen.getByText('Passwords do not match!')).toBeInTheDocument()
107
+ expect(screen.getByText('两次密码不一致!')).toBeInTheDocument()
108
108
  })
109
109
  })
110
110
 
@@ -118,13 +118,12 @@ describe('RegisterPage', () => {
118
118
 
119
119
  render(<RegisterPage />)
120
120
 
121
- await user.type(screen.getByPlaceholderText('Username'), 'testuser')
122
- await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
123
- await user.type(screen.getByPlaceholderText('Password'), 'password123')
124
- await user.type(screen.getByPlaceholderText('Confirm Password'), 'password123')
121
+ await user.type(screen.getByPlaceholderText('用户名'), 'testuser')
122
+ await user.type(screen.getByPlaceholderText('邮箱'), 'test@example.com')
123
+ await user.type(screen.getByPlaceholderText('密码'), 'password123')
124
+ await user.type(screen.getByPlaceholderText('确认密码'), 'password123')
125
125
 
126
- const submitButton = screen.getByRole('button', { name: /register/i })
127
- await user.click(submitButton)
126
+ await user.click(getSubmitButton())
128
127
 
129
128
  await waitFor(() => {
130
129
  expect(mockPost).toHaveBeenCalled()
@@ -143,13 +142,12 @@ describe('RegisterPage', () => {
143
142
 
144
143
  render(<RegisterPage />)
145
144
 
146
- await user.type(screen.getByPlaceholderText('Username'), 'testuser')
147
- await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
148
- await user.type(screen.getByPlaceholderText('Password'), 'password123')
149
- await user.type(screen.getByPlaceholderText('Confirm Password'), 'password123')
145
+ await user.type(screen.getByPlaceholderText('用户名'), 'testuser')
146
+ await user.type(screen.getByPlaceholderText('邮箱'), 'test@example.com')
147
+ await user.type(screen.getByPlaceholderText('密码'), 'password123')
148
+ await user.type(screen.getByPlaceholderText('确认密码'), 'password123')
150
149
 
151
- const submitButton = screen.getByRole('button', { name: /register/i })
152
- await user.click(submitButton)
150
+ await user.click(getSubmitButton())
153
151
 
154
152
  await waitFor(() => {
155
153
  expect(message.error).toHaveBeenCalled()
@@ -164,13 +162,12 @@ describe('RegisterPage', () => {
164
162
 
165
163
  render(<RegisterPage />)
166
164
 
167
- await user.type(screen.getByPlaceholderText('Username'), 'testuser')
168
- await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
169
- await user.type(screen.getByPlaceholderText('Password'), 'password123')
170
- await user.type(screen.getByPlaceholderText('Confirm Password'), 'password123')
165
+ await user.type(screen.getByPlaceholderText('用户名'), 'testuser')
166
+ await user.type(screen.getByPlaceholderText('邮箱'), 'test@example.com')
167
+ await user.type(screen.getByPlaceholderText('密码'), 'password123')
168
+ await user.type(screen.getByPlaceholderText('确认密码'), 'password123')
171
169
 
172
- const submitButton = screen.getByRole('button', { name: /register/i })
173
- await user.click(submitButton)
170
+ await user.click(getSubmitButton())
174
171
 
175
172
  await waitFor(() => {
176
173
  expect(message.error).toHaveBeenCalled()
@@ -1,8 +1,46 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { render, screen } from '@testing-library/react'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
3
  import userEvent from '@testing-library/user-event'
4
4
  import { SettingsPage } from '../SettingsPage'
5
5
 
6
+ const mockApiFn = vi.fn()
7
+
8
+ // 页面里 apiClient.api.admin.settings.$get() 是急切求值——不 mock 传输层时
9
+ // 会发出真实 fetch(localhost:3000),孤儿 rejection 导致 vitest 以
10
+ // unhandled errors 退出(tests 全过也 exit 1)。
11
+ vi.mock('@admin/services/apiClient', () => {
12
+ const settingsResponse = () => Promise.resolve(new Response('{}'))
13
+ return {
14
+ api: (...args: unknown[]) => {
15
+ mockApiFn(...args)
16
+ return {
17
+ withLoading: () => ({
18
+ json: () =>
19
+ Promise.resolve({
20
+ siteName: 'Test',
21
+ siteDescription: '',
22
+ smtpHost: 'smtp.test.com',
23
+ smtpPort: 587,
24
+ emailFrom: 'test@test.com',
25
+ sessionTimeout: 30,
26
+ maxLoginAttempts: 5,
27
+ }),
28
+ }),
29
+ }
30
+ },
31
+ apiClient: {
32
+ api: {
33
+ admin: {
34
+ settings: {
35
+ $get: vi.fn(settingsResponse),
36
+ $put: vi.fn(settingsResponse),
37
+ },
38
+ },
39
+ },
40
+ },
41
+ }
42
+ })
43
+
6
44
  vi.mock('antd', async () => {
7
45
  const actual = await vi.importActual('antd')
8
46
  return {
@@ -19,48 +57,72 @@ describe('SettingsPage', () => {
19
57
  vi.clearAllMocks()
20
58
  })
21
59
 
22
- it('renders General Settings card with form', () => {
60
+ it('renders General Settings card with form', async () => {
23
61
  render(<SettingsPage />)
24
62
 
25
- expect(screen.getByText('Settings')).toBeInTheDocument()
26
- expect(screen.getByText('General Settings')).toBeInTheDocument()
27
- expect(screen.getByText('Site Name')).toBeInTheDocument()
28
- expect(screen.getByText('Site Description')).toBeInTheDocument()
63
+ await waitFor(() => {
64
+ expect(screen.getByText('系统设置')).toBeInTheDocument()
65
+ expect(screen.getByText('通用设置')).toBeInTheDocument()
66
+ expect(screen.getByText('站点名称')).toBeInTheDocument()
67
+ expect(screen.getByText('站点描述')).toBeInTheDocument()
68
+ })
29
69
  })
30
70
 
31
- it('renders Notification Settings card with toggles', () => {
71
+ it('renders Notification Settings card with toggles', async () => {
32
72
  render(<SettingsPage />)
33
73
 
34
- expect(screen.getByText('Notification Settings')).toBeInTheDocument()
35
- expect(screen.getByText('Email Notifications')).toBeInTheDocument()
36
- expect(screen.getByText('Push Notifications')).toBeInTheDocument()
74
+ await waitFor(() => {
75
+ expect(screen.getByText('通知设置')).toBeInTheDocument()
76
+ expect(screen.getByText('邮件通知')).toBeInTheDocument()
77
+ expect(screen.getByText('推送通知')).toBeInTheDocument()
78
+ })
37
79
  })
38
80
 
39
- it('renders Security Settings card', () => {
81
+ it('renders Security Settings card', async () => {
40
82
  render(<SettingsPage />)
41
83
 
42
- expect(screen.getByText('Security Settings')).toBeInTheDocument()
43
- expect(screen.getByText('Current Password')).toBeInTheDocument()
44
- expect(screen.getByText('New Password')).toBeInTheDocument()
45
- expect(screen.getByText('Confirm New Password')).toBeInTheDocument()
84
+ await waitFor(() => {
85
+ expect(screen.getByText('安全设置')).toBeInTheDocument()
86
+ expect(screen.getByText('会话超时')).toBeInTheDocument()
87
+ expect(screen.getByText('最大登录尝试次数')).toBeInTheDocument()
88
+ })
46
89
  })
47
90
 
48
91
  it('save button shows success message', async () => {
49
92
  const user = userEvent.setup()
50
93
  const { message } = await import('antd')
51
94
 
95
+ mockApiFn.mockReturnValue({
96
+ withLoading: () => ({
97
+ json: () => Promise.resolve({}),
98
+ }),
99
+ })
100
+
52
101
  render(<SettingsPage />)
53
102
 
54
- const saveButtons = screen.getAllByRole('button', { name: /save changes/i })
55
- await user.click(saveButtons[0])
103
+ await waitFor(() => {
104
+ expect(screen.getByText('系统设置')).toBeInTheDocument()
105
+ })
106
+
107
+ // 等待 fetchSettings 填充表单(必填字段有值后 onFinish 才会被触发)
108
+ await waitFor(() => {
109
+ expect((screen.getByLabelText('站点名称') as HTMLInputElement).value).toBe('Test')
110
+ })
111
+
112
+ const saveButton = screen.getByRole('button', { name: /保\s*存\s*更\s*改/ })
113
+ await user.click(saveButton)
56
114
 
57
- expect(message.success).toHaveBeenCalledWith('Settings saved successfully!')
115
+ await waitFor(() => {
116
+ expect(message.success).toHaveBeenCalledWith('设置保存成功!')
117
+ })
58
118
  })
59
119
 
60
- it('toggle switches are present and interactive', () => {
120
+ it('toggle switches are present and interactive', async () => {
61
121
  render(<SettingsPage />)
62
122
 
63
- const switches = screen.getAllByRole('switch')
64
- expect(switches.length).toBeGreaterThanOrEqual(2)
123
+ await waitFor(() => {
124
+ const switches = screen.getAllByRole('switch')
125
+ expect(switches.length).toBeGreaterThanOrEqual(2)
126
+ })
65
127
  })
66
128
  })
@@ -120,7 +120,9 @@ function createSuccessResponse(data: unknown) {
120
120
  describe('TicketsPage', () => {
121
121
  beforeEach(() => {
122
122
  vi.clearAllMocks()
123
- mockTicketsGet.mockReturnValue(createSuccessResponse(mockTickets))
123
+ mockTicketsGet.mockReturnValue(
124
+ createSuccessResponse({ tickets: mockTickets, total: mockTickets.length, page: 1, limit: 20 })
125
+ )
124
126
  mockTicketClosePut.mockReturnValue(createSuccessResponse(null))
125
127
  })
126
128
 
@@ -5,12 +5,11 @@
5
5
  * @impact loading 控制现在通过 api().withLoading() 链式调用实现
6
6
  */
7
7
 
8
- import { hc } from 'hono/client'
9
8
  import { WSClientImpl } from '@shared/core/ws-client'
10
9
  import { SSEClientImpl } from '@shared/core/sse-client'
11
10
  import { createRequestInterceptor } from './requestInterceptor'
12
11
  import { useCaptchaStore } from '../stores/captchaStore'
13
- import type { AdminApiType } from '@server/index'
12
+ import { createApiFacade } from '@server/rpc-surface'
14
13
 
15
14
  const baseUrl = import.meta.env.API_BASE_URL || window.location.origin
16
15
 
@@ -50,7 +49,7 @@ function createCustomFetch() {
50
49
  })
51
50
  }
52
51
 
53
- export const apiClient = hc<AdminApiType>(baseUrl, {
52
+ export const apiClient = createApiFacade(baseUrl, {
54
53
  fetch: createCustomFetch() as typeof fetch,
55
54
  webSocket: url => new WSClientImpl(url) as unknown as WebSocket,
56
55
  sse: url => {
@@ -8,14 +8,14 @@ export function registerContentCommands(site: SiteInstance) {
8
8
  description: 'List all contents',
9
9
  parameters: z.object({
10
10
  limit: z.coerce.number().default(20).describe('Limit results'),
11
- offset: z.coerce.number().default(0).describe('Offset'),
11
+ page: z.coerce.number().default(1).describe('Page number'),
12
12
  }),
13
13
  handler: async (params: unknown) => {
14
- const p = params as { limit: number; offset: number }
14
+ const p = params as { limit: number; page: number }
15
15
  try {
16
16
  const client = getClient()
17
17
  const res = await client.api.contents.$get({
18
- query: { limit: String(p.limit), offset: String(p.offset) },
18
+ query: { limit: String(p.limit), page: String(p.page) },
19
19
  })
20
20
  const data = await res.json()
21
21
  return ok(data)
@@ -8,14 +8,14 @@ export function registerDisputeCommands(site: SiteInstance) {
8
8
  description: 'List all disputes',
9
9
  parameters: z.object({
10
10
  limit: z.coerce.number().default(20).describe('Limit results'),
11
- offset: z.coerce.number().default(0).describe('Offset'),
11
+ page: z.coerce.number().default(1).describe('Page number'),
12
12
  }),
13
13
  handler: async (params: unknown) => {
14
- const p = params as { limit: number; offset: number }
14
+ const p = params as { limit: number; page: number }
15
15
  try {
16
16
  const client = getClient()
17
17
  const res = await client.api.disputes.$get({
18
- query: { limit: String(p.limit), offset: String(p.offset) },
18
+ query: { limit: String(p.limit), page: String(p.page) },
19
19
  })
20
20
  const data = await res.json()
21
21
  return ok(data)
@@ -8,14 +8,14 @@ export function registerTicketCommands(site: SiteInstance) {
8
8
  description: 'List all tickets',
9
9
  parameters: z.object({
10
10
  limit: z.coerce.number().default(20).describe('Limit results'),
11
- offset: z.coerce.number().default(0).describe('Offset'),
11
+ page: z.coerce.number().default(1).describe('Page number'),
12
12
  }),
13
13
  handler: async (params: unknown) => {
14
- const p = params as { limit: number; offset: number }
14
+ const p = params as { limit: number; page: number }
15
15
  try {
16
16
  const client = getClient()
17
17
  const res = await client.api.tickets.$get({
18
- query: { limit: String(p.limit), offset: String(p.offset) },
18
+ query: { limit: String(p.limit), page: String(p.page) },
19
19
  })
20
20
  const data = await res.json()
21
21
  return ok(data)
@@ -12,7 +12,7 @@ export function registerTodoCommands(site: SiteInstance) {
12
12
  handler: async () => {
13
13
  try {
14
14
  const client = getClient()
15
- const res = await client.api.todos.$get()
15
+ const res = await client.api.todos.$get({ query: {} })
16
16
  const data = await res.json()
17
17
  return ok(data)
18
18
  } catch (err) {
@@ -1,9 +1,8 @@
1
- import { hc } from 'hono/client'
2
- import type { AppType } from '@server/index'
1
+ import { createApiFacade, type ApiFacade } from '@server/rpc-surface'
3
2
  import { WSClientImpl } from '@shared/core/ws-client'
4
3
  import { SSEClientImpl } from '@shared/core/sse-client'
5
4
 
6
- export type { AppType }
5
+ export type { ApiFacade }
7
6
 
8
7
  /**
9
8
  * CLI RPC 请求扩展参数
@@ -16,7 +15,7 @@ export interface CliFetchExtendOptions {
16
15
  }
17
16
 
18
17
  export function createRPCClient(baseUrl: string) {
19
- return hc<AppType>(baseUrl, {
18
+ return createApiFacade(baseUrl, {
20
19
  webSocket: url => new WSClientImpl(url) as unknown as WebSocket,
21
20
  sse: url => new SSEClientImpl(url),
22
21
  })
@@ -1 +1 @@
1
- export { createRPCClient, type AppType } from './client'
1
+ export { createRPCClient, type ApiFacade } from './client'
@@ -1,46 +1,10 @@
1
- import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
2
- import { Layout } from './Layout'
3
- import { PresetProvider } from './contexts/PresetContext'
4
- import { getPresetUIConfig, type RouteDef } from './preset-ui-config'
1
+ import { BrowserRouter } from 'react-router-dom'
2
+ import { AppRoutes } from './AppRoutes'
5
3
 
6
4
  export const App: React.FC<{ presetId?: string }> = ({ presetId = 'todo' }) => {
7
- const config = getPresetUIConfig(presetId)
8
- const { theme, desktopNav, mobileTabs, defaultRoute, routes, layout, navigation } = config
9
-
10
5
  return (
11
6
  <BrowserRouter>
12
- <PresetProvider value={presetId}>
13
- <Layout
14
- preset={presetId}
15
- layout={layout}
16
- theme={theme}
17
- navigation={navigation}
18
- desktopNav={desktopNav}
19
- mobileTabs={mobileTabs}
20
- >
21
- <Routes>
22
- {defaultRoute !== '/' && (
23
- <Route path="/" element={<Navigate to={defaultRoute} replace />} />
24
- )}
25
- {routes
26
- .filter(
27
- (r): r is RouteDef & { component: NonNullable<RouteDef['component']> } =>
28
- r.component !== null
29
- )
30
- .map(route => (
31
- <Route key={route.path} path={route.path} element={<route.component />} />
32
- ))}
33
- <Route
34
- path="*"
35
- element={
36
- <div className="flex items-center justify-center min-h-[50vh] text-gray-400">
37
- 404 - Page not found
38
- </div>
39
- }
40
- />
41
- </Routes>
42
- </Layout>
43
- </PresetProvider>
7
+ <AppRoutes presetId={presetId} />
44
8
  </BrowserRouter>
45
9
  )
46
10
  }