create-hazejs 0.0.0-stage → 0.1.2

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 (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -2
  3. package/bin/create-hazejs.mjs +2 -0
  4. package/dist/index.d.mts +1 -0
  5. package/dist/index.mjs +74 -0
  6. package/dist/scaffold.d.mts +29 -0
  7. package/dist/scaffold.mjs +76 -0
  8. package/package.json +77 -3
  9. package/templates/api/.env +4 -0
  10. package/templates/api/.env.test +1 -0
  11. package/templates/api/README.md +383 -0
  12. package/templates/api/_gitignore +9 -0
  13. package/templates/api/app/controller/AuthController.ts +45 -0
  14. package/templates/api/app/controller/GreetingController.ts +23 -0
  15. package/templates/api/app/controller/UserController.ts +38 -0
  16. package/templates/api/app/event/UserRegistered.ts +6 -0
  17. package/templates/api/app/exception/Handler.ts +13 -0
  18. package/templates/api/app/job/SendWelcomeEmail.ts +19 -0
  19. package/templates/api/app/listener/BroadcastRegistration.ts +7 -0
  20. package/templates/api/app/listener/QueueWelcomeEmail.ts +6 -0
  21. package/templates/api/app/mail/WelcomeMail.ts +17 -0
  22. package/templates/api/app/model/User.ts +11 -0
  23. package/templates/api/app/policy/UserPolicy.ts +14 -0
  24. package/templates/api/app/provider/AppProvider.ts +9 -0
  25. package/templates/api/app/provider/DatabaseProvider.ts +4 -0
  26. package/templates/api/app/response/Formatter.ts +17 -0
  27. package/templates/api/app/service/GreetingService.ts +6 -0
  28. package/templates/api/app/validate/auth.ts +20 -0
  29. package/templates/api/app/validate/greeting.ts +5 -0
  30. package/templates/api/app/validate/user.ts +12 -0
  31. package/templates/api/config/app.ts +7 -0
  32. package/templates/api/config/auth.ts +23 -0
  33. package/templates/api/config/cache.ts +23 -0
  34. package/templates/api/config/cors.ts +11 -0
  35. package/templates/api/config/database.ts +17 -0
  36. package/templates/api/config/filesystem.ts +32 -0
  37. package/templates/api/config/mail.ts +31 -0
  38. package/templates/api/config/middleware.ts +10 -0
  39. package/templates/api/config/queue.ts +21 -0
  40. package/templates/api/config/throttle.ts +12 -0
  41. package/templates/api/config/websocket.ts +12 -0
  42. package/templates/api/database/migrations/0000_create_users.sql +9 -0
  43. package/templates/api/database/migrations/0001_add_user_password.sql +1 -0
  44. package/templates/api/database/migrations/0002_add_user_role.sql +1 -0
  45. package/templates/api/database/migrations/meta/0000_snapshot.json +71 -0
  46. package/templates/api/database/migrations/meta/0001_snapshot.json +78 -0
  47. package/templates/api/database/migrations/meta/0002_snapshot.json +86 -0
  48. package/templates/api/database/migrations/meta/_journal.json +27 -0
  49. package/templates/api/database/schema/users.ts +17 -0
  50. package/templates/api/database/seeders/01-users.ts +15 -0
  51. package/templates/api/haze.config.ts +5 -0
  52. package/templates/api/package.json +38 -0
  53. package/templates/api/route/api.ts +22 -0
  54. package/templates/api/route/ws.ts +18 -0
  55. package/templates/api/schedule/app.ts +11 -0
  56. package/templates/api/tests/auth.test.ts +77 -0
  57. package/templates/api/tests/greeting.test.ts +29 -0
  58. package/templates/api/tests/user.test.ts +58 -0
  59. package/templates/api/tsconfig.json +34 -0
  60. package/templates/api/vitest.config.ts +4 -0
  61. package/templates/react/.env +8 -0
  62. package/templates/react/.env.test +1 -0
  63. package/templates/react/README.md +374 -0
  64. package/templates/react/_gitignore +9 -0
  65. package/templates/react/haze.config.ts +8 -0
  66. package/templates/react/index.html +12 -0
  67. package/templates/react/package.json +46 -0
  68. package/templates/react/server/app/controller/AuthController.ts +45 -0
  69. package/templates/react/server/app/controller/GreetingController.ts +23 -0
  70. package/templates/react/server/app/controller/UserController.ts +38 -0
  71. package/templates/react/server/app/event/UserRegistered.ts +6 -0
  72. package/templates/react/server/app/exception/Handler.ts +13 -0
  73. package/templates/react/server/app/job/SendWelcomeEmail.ts +19 -0
  74. package/templates/react/server/app/listener/BroadcastRegistration.ts +7 -0
  75. package/templates/react/server/app/listener/QueueWelcomeEmail.ts +6 -0
  76. package/templates/react/server/app/mail/WelcomeMail.ts +17 -0
  77. package/templates/react/server/app/model/User.ts +11 -0
  78. package/templates/react/server/app/policy/UserPolicy.ts +14 -0
  79. package/templates/react/server/app/provider/AppProvider.ts +9 -0
  80. package/templates/react/server/app/provider/DatabaseProvider.ts +4 -0
  81. package/templates/react/server/app/provider/SocketProvider.ts +4 -0
  82. package/templates/react/server/app/response/Formatter.ts +17 -0
  83. package/templates/react/server/app/service/GreetingService.ts +6 -0
  84. package/templates/react/server/app/socket/chat.ts +17 -0
  85. package/templates/react/server/app/validate/auth.ts +20 -0
  86. package/templates/react/server/app/validate/greeting.ts +5 -0
  87. package/templates/react/server/app/validate/user.ts +12 -0
  88. package/templates/react/server/config/app.ts +7 -0
  89. package/templates/react/server/config/auth.ts +23 -0
  90. package/templates/react/server/config/cache.ts +23 -0
  91. package/templates/react/server/config/database.ts +17 -0
  92. package/templates/react/server/config/filesystem.ts +32 -0
  93. package/templates/react/server/config/mail.ts +31 -0
  94. package/templates/react/server/config/middleware.ts +11 -0
  95. package/templates/react/server/config/queue.ts +21 -0
  96. package/templates/react/server/config/socketio.ts +13 -0
  97. package/templates/react/server/config/throttle.ts +12 -0
  98. package/templates/react/server/config/websocket.ts +12 -0
  99. package/templates/react/server/database/migrations/0000_create_users.sql +9 -0
  100. package/templates/react/server/database/migrations/0001_add_user_password.sql +1 -0
  101. package/templates/react/server/database/migrations/0002_add_user_role.sql +1 -0
  102. package/templates/react/server/database/migrations/meta/0000_snapshot.json +71 -0
  103. package/templates/react/server/database/migrations/meta/0001_snapshot.json +78 -0
  104. package/templates/react/server/database/migrations/meta/0002_snapshot.json +86 -0
  105. package/templates/react/server/database/migrations/meta/_journal.json +27 -0
  106. package/templates/react/server/database/schema/users.ts +17 -0
  107. package/templates/react/server/database/seeders/01-users.ts +15 -0
  108. package/templates/react/server/route/api.ts +24 -0
  109. package/templates/react/server/route/ws.ts +18 -0
  110. package/templates/react/server/schedule/app.ts +11 -0
  111. package/templates/react/src/App.css +22 -0
  112. package/templates/react/src/App.tsx +72 -0
  113. package/templates/react/src/api.ts +48 -0
  114. package/templates/react/src/main.tsx +9 -0
  115. package/templates/react/src/socket.ts +5 -0
  116. package/templates/react/src/vite-env.d.ts +5 -0
  117. package/templates/react/tests/auth.test.ts +77 -0
  118. package/templates/react/tests/greeting.test.ts +29 -0
  119. package/templates/react/tests/user.test.ts +58 -0
  120. package/templates/react/tsconfig.app.json +22 -0
  121. package/templates/react/tsconfig.json +7 -0
  122. package/templates/react/tsconfig.server.json +34 -0
  123. package/templates/react/vite.config.ts +18 -0
  124. package/templates/react/vitest.config.ts +8 -0
  125. package/templates/vue/.env +8 -0
  126. package/templates/vue/.env.test +1 -0
  127. package/templates/vue/README.md +374 -0
  128. package/templates/vue/_gitignore +9 -0
  129. package/templates/vue/haze.config.ts +8 -0
  130. package/templates/vue/index.html +12 -0
  131. package/templates/vue/package.json +44 -0
  132. package/templates/vue/server/app/controller/AuthController.ts +45 -0
  133. package/templates/vue/server/app/controller/GreetingController.ts +23 -0
  134. package/templates/vue/server/app/controller/UserController.ts +38 -0
  135. package/templates/vue/server/app/event/UserRegistered.ts +6 -0
  136. package/templates/vue/server/app/exception/Handler.ts +13 -0
  137. package/templates/vue/server/app/job/SendWelcomeEmail.ts +19 -0
  138. package/templates/vue/server/app/listener/BroadcastRegistration.ts +7 -0
  139. package/templates/vue/server/app/listener/QueueWelcomeEmail.ts +6 -0
  140. package/templates/vue/server/app/mail/WelcomeMail.ts +17 -0
  141. package/templates/vue/server/app/model/User.ts +11 -0
  142. package/templates/vue/server/app/policy/UserPolicy.ts +14 -0
  143. package/templates/vue/server/app/provider/AppProvider.ts +9 -0
  144. package/templates/vue/server/app/provider/DatabaseProvider.ts +4 -0
  145. package/templates/vue/server/app/provider/SocketProvider.ts +4 -0
  146. package/templates/vue/server/app/response/Formatter.ts +17 -0
  147. package/templates/vue/server/app/service/GreetingService.ts +6 -0
  148. package/templates/vue/server/app/socket/chat.ts +17 -0
  149. package/templates/vue/server/app/validate/auth.ts +20 -0
  150. package/templates/vue/server/app/validate/greeting.ts +5 -0
  151. package/templates/vue/server/app/validate/user.ts +12 -0
  152. package/templates/vue/server/config/app.ts +7 -0
  153. package/templates/vue/server/config/auth.ts +23 -0
  154. package/templates/vue/server/config/cache.ts +23 -0
  155. package/templates/vue/server/config/database.ts +17 -0
  156. package/templates/vue/server/config/filesystem.ts +32 -0
  157. package/templates/vue/server/config/mail.ts +31 -0
  158. package/templates/vue/server/config/middleware.ts +11 -0
  159. package/templates/vue/server/config/queue.ts +21 -0
  160. package/templates/vue/server/config/socketio.ts +13 -0
  161. package/templates/vue/server/config/throttle.ts +12 -0
  162. package/templates/vue/server/config/websocket.ts +12 -0
  163. package/templates/vue/server/database/migrations/0000_create_users.sql +9 -0
  164. package/templates/vue/server/database/migrations/0001_add_user_password.sql +1 -0
  165. package/templates/vue/server/database/migrations/0002_add_user_role.sql +1 -0
  166. package/templates/vue/server/database/migrations/meta/0000_snapshot.json +71 -0
  167. package/templates/vue/server/database/migrations/meta/0001_snapshot.json +78 -0
  168. package/templates/vue/server/database/migrations/meta/0002_snapshot.json +86 -0
  169. package/templates/vue/server/database/migrations/meta/_journal.json +27 -0
  170. package/templates/vue/server/database/schema/users.ts +17 -0
  171. package/templates/vue/server/database/seeders/01-users.ts +15 -0
  172. package/templates/vue/server/route/api.ts +24 -0
  173. package/templates/vue/server/route/ws.ts +18 -0
  174. package/templates/vue/server/schedule/app.ts +11 -0
  175. package/templates/vue/src/App.vue +92 -0
  176. package/templates/vue/src/api.ts +48 -0
  177. package/templates/vue/src/env.d.ts +5 -0
  178. package/templates/vue/src/main.ts +4 -0
  179. package/templates/vue/src/socket.ts +5 -0
  180. package/templates/vue/tests/auth.test.ts +77 -0
  181. package/templates/vue/tests/greeting.test.ts +29 -0
  182. package/templates/vue/tests/user.test.ts +58 -0
  183. package/templates/vue/tsconfig.app.json +22 -0
  184. package/templates/vue/tsconfig.json +7 -0
  185. package/templates/vue/tsconfig.server.json +34 -0
  186. package/templates/vue/vite.config.ts +18 -0
  187. package/templates/vue/vitest.config.ts +8 -0
@@ -0,0 +1,78 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "1138ad71-fa8c-4b78-a94a-5dbce374d676",
5
+ "prevId": "1b28388a-9186-40de-9d67-bb08f562b58c",
6
+ "tables": {
7
+ "users": {
8
+ "name": "users",
9
+ "columns": {
10
+ "id": {
11
+ "name": "id",
12
+ "type": "integer",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": true
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "email": {
25
+ "name": "email",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "password": {
32
+ "name": "password",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": false,
36
+ "autoincrement": false
37
+ },
38
+ "created_at": {
39
+ "name": "created_at",
40
+ "type": "integer",
41
+ "primaryKey": false,
42
+ "notNull": false,
43
+ "autoincrement": false
44
+ },
45
+ "updated_at": {
46
+ "name": "updated_at",
47
+ "type": "integer",
48
+ "primaryKey": false,
49
+ "notNull": false,
50
+ "autoincrement": false
51
+ }
52
+ },
53
+ "indexes": {
54
+ "users_email_unique": {
55
+ "name": "users_email_unique",
56
+ "columns": [
57
+ "email"
58
+ ],
59
+ "isUnique": true
60
+ }
61
+ },
62
+ "foreignKeys": {},
63
+ "compositePrimaryKeys": {},
64
+ "uniqueConstraints": {},
65
+ "checkConstraints": {}
66
+ }
67
+ },
68
+ "views": {},
69
+ "enums": {},
70
+ "_meta": {
71
+ "schemas": {},
72
+ "tables": {},
73
+ "columns": {}
74
+ },
75
+ "internal": {
76
+ "indexes": {}
77
+ }
78
+ }
@@ -0,0 +1,86 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "d41d3fc5-5742-40b2-9f99-4f4870f40ebf",
5
+ "prevId": "1138ad71-fa8c-4b78-a94a-5dbce374d676",
6
+ "tables": {
7
+ "users": {
8
+ "name": "users",
9
+ "columns": {
10
+ "id": {
11
+ "name": "id",
12
+ "type": "integer",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": true
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "email": {
25
+ "name": "email",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "password": {
32
+ "name": "password",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": false,
36
+ "autoincrement": false
37
+ },
38
+ "role": {
39
+ "name": "role",
40
+ "type": "text",
41
+ "primaryKey": false,
42
+ "notNull": true,
43
+ "autoincrement": false,
44
+ "default": "'member'"
45
+ },
46
+ "created_at": {
47
+ "name": "created_at",
48
+ "type": "integer",
49
+ "primaryKey": false,
50
+ "notNull": false,
51
+ "autoincrement": false
52
+ },
53
+ "updated_at": {
54
+ "name": "updated_at",
55
+ "type": "integer",
56
+ "primaryKey": false,
57
+ "notNull": false,
58
+ "autoincrement": false
59
+ }
60
+ },
61
+ "indexes": {
62
+ "users_email_unique": {
63
+ "name": "users_email_unique",
64
+ "columns": [
65
+ "email"
66
+ ],
67
+ "isUnique": true
68
+ }
69
+ },
70
+ "foreignKeys": {},
71
+ "compositePrimaryKeys": {},
72
+ "uniqueConstraints": {},
73
+ "checkConstraints": {}
74
+ }
75
+ },
76
+ "views": {},
77
+ "enums": {},
78
+ "_meta": {
79
+ "schemas": {},
80
+ "tables": {},
81
+ "columns": {}
82
+ },
83
+ "internal": {
84
+ "indexes": {}
85
+ }
86
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1793072281689,
9
+ "tag": "0000_create_users",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1793084099573,
16
+ "tag": "0001_add_user_password",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "6",
22
+ "when": 1793086076027,
23
+ "tag": "0002_add_user_role",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,17 @@
1
+ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
2
+
3
+ // 表结构定义(drizzle):此文件只依赖 drizzle-orm,不要使用 ~ 别名(drizzle-kit 需要直接读取)
4
+ // 修改后运行 haze db:generate 生成迁移,再 haze db:migrate
5
+ export const users = sqliteTable('users', {
6
+ id: integer('id').primaryKey({ autoIncrement: true }),
7
+ name: text('name').notNull(),
8
+ email: text('email').notNull().unique(),
9
+ // 密码哈希(Hash.make()),通过 /api/auth/register 注册的用户才有
10
+ password: text('password'),
11
+ // 角色:admin 可以管理所有用户(app/policy/UserPolicy.ts)
12
+ role: text('role', {
13
+ enum: ['member', 'admin'],
14
+ }).notNull().default('member'),
15
+ createdAt: integer('created_at', { mode: 'timestamp' }),
16
+ updatedAt: integer('updated_at', { mode: 'timestamp' }),
17
+ })
@@ -0,0 +1,15 @@
1
+ import { Hash } from '@hazejs/core'
2
+ import { User } from '~/app/model/User'
3
+
4
+ /** haze db:seed:按文件名顺序执行 database/seeders 下的文件 */
5
+ export default async function seed(): Promise<void> {
6
+ if (await User.where('email', 'admin@example.com').exists())
7
+ return
8
+ // 开发用账号,上线前请修改密码
9
+ await User.create({
10
+ name: 'Admin',
11
+ email: 'admin@example.com',
12
+ password: await Hash.make('password'),
13
+ role: 'admin',
14
+ })
15
+ }
@@ -0,0 +1,24 @@
1
+ import { defineRoutes } from '@hazejs/core'
2
+ import { AuthController } from '~/app/controller/AuthController'
3
+ import { GreetingController } from '~/app/controller/GreetingController'
4
+ import { UserController } from '~/app/controller/UserController'
5
+ import { GreetSchema } from '~/app/validate/greeting'
6
+ import { UserIdParams } from '~/app/validate/user'
7
+
8
+ export default defineRoutes((r) => {
9
+ r.group({ prefix: '/api', as: 'api.' }, (r) => {
10
+ r.get('/hello', [GreetingController, 'index']).name('hello')
11
+ // 下发 XSRF-TOKEN Cookie(前端首次发起修改类请求前调用,见 src/api.ts)
12
+ r.get('/csrf-cookie', ctx => ctx.noContent())
13
+ r.post('/hello', [GreetingController, 'store']).validate({ body: GreetSchema })
14
+ r.resource('/users', UserController).validateMember({ params: UserIdParams })
15
+
16
+ r.group({ prefix: '/auth', as: 'auth.' }, (r) => {
17
+ r.post('/register', [AuthController, 'register']).name('register')
18
+ // 登录限流:同一 IP 每分钟最多 10 次
19
+ r.post('/login', [AuthController, 'login']).middleware('throttle:10,60').name('login')
20
+ r.get('/me', [AuthController, 'me']).middleware('auth').name('me')
21
+ r.post('/avatar', [AuthController, 'avatar']).middleware(['auth', 'bodyLimit:3mb']).name('avatar')
22
+ })
23
+ })
24
+ })
@@ -0,0 +1,18 @@
1
+ import { defineRoutes } from '@hazejs/core'
2
+
3
+ /**
4
+ * WebSocket:ws://<host>/api/ws。升级请求同样经过中间件,需要登录时加 .middleware('auth')。
5
+ * 服务端任意位置用 Socket.publish('users', data) 推送给订阅者(多实例见 config/websocket.ts)
6
+ */
7
+ export default defineRoutes((r) => {
8
+ r.ws('/api/ws', {
9
+ open(peer) {
10
+ peer.subscribe('users')
11
+ peer.send(JSON.stringify({ type: 'connected' }))
12
+ },
13
+ message(peer, message) {
14
+ if (message.text() === 'ping')
15
+ peer.send(JSON.stringify({ type: 'pong' }))
16
+ },
17
+ })
18
+ })
@@ -0,0 +1,11 @@
1
+ import { defineSchedule } from '@hazejs/core'
2
+
3
+ /**
4
+ * 定时任务:haze schedule:work(开发 / 常驻),或系统 cron 每分钟执行 haze schedule:run;
5
+ * 构建产物:node dist/…/worker.mjs schedule,Workers 为 Cron Triggers,Lambda 为 index.schedule。
6
+ */
7
+ export default defineSchedule((s) => {
8
+ s.call('heartbeat', () => {
9
+ // 示例:替换为清理过期数据、生成报表等;onOneServer 保证多实例时只执行一次
10
+ }).dailyAt('03:00').onOneServer()
11
+ })
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, onUnmounted, ref } from 'vue'
3
+ import { api } from '@/api'
4
+ import { chat } from '@/socket'
5
+
6
+ const title = import.meta.env.VITE_APP_TITLE
7
+ const hello = ref('')
8
+ const name = ref('')
9
+ const reply = ref('')
10
+ const error = ref('')
11
+ const online = ref(0)
12
+
13
+ onMounted(async () => {
14
+ chat.on('online', (count: number) => (online.value = count))
15
+ chat.connect()
16
+ hello.value = (await api<{ message: string }>('/api/hello')).message
17
+ })
18
+
19
+ onUnmounted(() => {
20
+ chat.off('online')
21
+ chat.disconnect()
22
+ })
23
+
24
+ async function greet() {
25
+ error.value = ''
26
+ try {
27
+ const data = await api<{ message: string }>('/api/hello', {
28
+ method: 'POST',
29
+ body: JSON.stringify({ name: name.value }),
30
+ })
31
+ reply.value = data.message
32
+ }
33
+ catch (e) {
34
+ reply.value = ''
35
+ error.value = (e as Error).message
36
+ }
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <main class="page">
42
+ <h1>{{ title }}</h1>
43
+ <p class="hello">
44
+ {{ hello || '加载中…' }}
45
+ </p>
46
+ <p class="online">
47
+ 在线 {{ online }} 人(Socket.IO)
48
+ </p>
49
+
50
+ <form class="form" @submit.prevent="greet">
51
+ <input v-model="name" placeholder="输入名字" aria-label="名字">
52
+ <button type="submit">
53
+ 打招呼
54
+ </button>
55
+ </form>
56
+ <p v-if="reply" class="reply">
57
+ {{ reply }}
58
+ </p>
59
+ <p v-if="error" class="error">
60
+ {{ error }}
61
+ </p>
62
+
63
+ <p class="tip">
64
+ 修改 <code>server/app/service/GreetingService.ts</code>,刷新页面即可看到后端变化,无需重启。
65
+ </p>
66
+ </main>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .page {
71
+ max-width: 560px;
72
+ margin: 64px auto;
73
+ padding: 0 16px;
74
+ font-family: system-ui, sans-serif;
75
+ }
76
+ .form {
77
+ display: flex;
78
+ gap: 8px;
79
+ }
80
+ .form input {
81
+ flex: 1;
82
+ padding: 6px 10px;
83
+ }
84
+ .error {
85
+ color: #c0392b;
86
+ }
87
+ .tip {
88
+ margin-top: 32px;
89
+ color: #666;
90
+ font-size: 14px;
91
+ }
92
+ </style>
@@ -0,0 +1,48 @@
1
+ /** 后端统一响应格式 */
2
+ export interface ApiResponse<T> {
3
+ code: number | string
4
+ message: string
5
+ data: T
6
+ errors?: Array<{ path: string, message: string }>
7
+ }
8
+
9
+ export class ApiError extends Error {
10
+ constructor(readonly response: ApiResponse<unknown>) {
11
+ super(response.errors?.[0]?.message ?? response.message)
12
+ }
13
+ }
14
+
15
+ function readCookie(name: string): string | undefined {
16
+ const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
17
+ return match
18
+ ? decodeURIComponent(match[1])
19
+ : undefined
20
+ }
21
+
22
+ /** CSRF:修改类请求带上 XSRF-TOKEN(没有时先向后端取一次) */
23
+ async function csrfHeader(method: string): Promise<Record<string, string>> {
24
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method))
25
+ return {}
26
+ if (!readCookie('XSRF-TOKEN'))
27
+ await fetch('/api/csrf-cookie')
28
+ const token = readCookie('XSRF-TOKEN')
29
+ return token
30
+ ? { 'x-xsrf-token': token }
31
+ : {}
32
+ }
33
+
34
+ /** 开发时同源请求由 Vite 转给后端,生产环境由同一个 Node 进程处理,无需配置代理 */
35
+ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
36
+ const res = await fetch(path, {
37
+ ...init,
38
+ headers: {
39
+ 'content-type': 'application/json',
40
+ ...await csrfHeader((init.method ?? 'GET').toUpperCase()),
41
+ ...init.headers,
42
+ },
43
+ })
44
+ const body = await res.json() as ApiResponse<T>
45
+ if (body.code !== 0)
46
+ throw new ApiError(body)
47
+ return body.data
48
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_APP_TITLE: string
5
+ }
@@ -0,0 +1,4 @@
1
+ import { createApp } from 'vue'
2
+ import App from './App.vue'
3
+
4
+ createApp(App).mount('#app')
@@ -0,0 +1,5 @@
1
+ import type { Socket } from 'socket.io-client'
2
+ import { io } from 'socket.io-client'
3
+
4
+ /** Socket.IO 命名空间 /chat(server/app/socket/chat.ts):与页面同源,开发时由 Vite 开发服务器交给后端 */
5
+ export const chat: Socket = io('/chat', { autoConnect: false })
@@ -0,0 +1,77 @@
1
+ import { Events, Mail, MailFake, Queue, Storage } from '@hazejs/core'
2
+ import { refreshDatabase } from '@hazejs/orm'
3
+ import { useTestApp } from '@hazejs/testing'
4
+ import { beforeEach, describe, expect, it } from 'vitest'
5
+ import UserRegistered from '~/app/event/UserRegistered'
6
+ import SendWelcomeEmail from '~/app/job/SendWelcomeEmail'
7
+ import WelcomeMail from '~/app/mail/WelcomeMail'
8
+ import { User } from '~/app/model/User'
9
+
10
+ const t = useTestApp()
11
+ let fake: ReturnType<typeof Queue.fake>
12
+ // 队列改为记录投递,断言后可用 fake.drain() 执行
13
+ beforeEach(() => {
14
+ fake = Queue.fake()
15
+ })
16
+ beforeEach(() => refreshDatabase({ migrations: 'server/database/migrations' }))
17
+
18
+ const ada = { name: 'Ada', email: 'ada@example.com', password: 'correct horse' }
19
+
20
+ describe('auth', () => {
21
+ it('registers, logs in and reads the current user with a Bearer token', async () => {
22
+ const registered = await t.post('/api/auth/register', ada)
23
+ expect(registered.status).toBe(201)
24
+ expect(registered.body.data.user).toMatchObject({ name: 'Ada', email: 'ada@example.com' })
25
+ // 密码哈希不会出现在响应中
26
+ expect(registered.body.data.user).not.toHaveProperty('password')
27
+ // 注册事件的监听器投递欢迎邮件任务(app/listener/QueueWelcomeEmail.ts)
28
+ fake.assertDispatched(SendWelcomeEmail, payload => payload.userId === registered.body.data.user.id)
29
+ // 执行任务:发送欢迎邮件(Mail.fake() 只记录不投递)
30
+ const mail = Mail.fake()
31
+ await fake.drain()
32
+ mail.assertSent(WelcomeMail, sent => MailFake.hasRecipient(sent, ada.email) && sent.message.subject === '欢迎加入,Ada')
33
+
34
+ const login = await t.post('/api/auth/login', { email: ada.email, password: ada.password })
35
+ expect(login.body.data).toMatchObject({ type: 'Bearer', expiresIn: 7200 })
36
+
37
+ const me = await t.withHeaders({ authorization: `Bearer ${login.body.data.token}` }).get('/api/auth/me')
38
+ expect(me.body.data).toMatchObject({ email: 'ada@example.com' })
39
+ })
40
+
41
+ it('emits UserRegistered on registration', async () => {
42
+ const events = Events.fake([UserRegistered])
43
+ const registered = await t.post('/api/auth/register', ada)
44
+ events.assertEmitted(UserRegistered, payload => payload.userId === registered.body.data.user.id && payload.email === ada.email)
45
+ // 事件被 fake 后监听器不执行,任务也不会投递
46
+ fake.assertNotDispatched(SendWelcomeEmail)
47
+ })
48
+
49
+ it('rejects wrong passwords and anonymous requests with 401', async () => {
50
+ await t.post('/api/auth/register', ada)
51
+ const wrong = await t.post('/api/auth/login', { email: ada.email, password: 'nope' })
52
+ expect([wrong.status, wrong.body.message]).toEqual([401, '邮箱或密码错误'])
53
+ expect((await t.get('/api/auth/me')).status).toBe(401)
54
+ })
55
+
56
+ it('actingAs() logs a user in without going through the login endpoint', async () => {
57
+ const user = await User.create({ name: 'Bob', email: 'bob@example.com' })
58
+ const me = await (await t.actingAs(user)).get('/api/auth/me')
59
+ expect(me.body.data).toMatchObject({ id: user.id, email: 'bob@example.com' })
60
+ })
61
+
62
+ it('uploads an avatar to the public disk', async () => {
63
+ const disk = Storage.fake('public')
64
+ const user = await User.create({ name: 'Bob', email: 'bob@example.com' })
65
+ const form = new FormData()
66
+ form.set('avatar', new File([
67
+ new Uint8Array([137, 80, 78, 71]),
68
+ ], 'me.png', { type: 'image/png' }))
69
+ const res = await (await t.actingAs(user)).post('/api/auth/avatar', form)
70
+ expect(res.status).toBe(201)
71
+ expect(res.body.data.url).toBe(`/storage/${res.body.data.path}`)
72
+ await disk.assertExists(res.body.data.path)
73
+
74
+ form.set('avatar', new File(['text'], 'a.txt', { type: 'text/plain' }))
75
+ expect((await (await t.actingAs(user)).post('/api/auth/avatar', form)).status).toBe(422)
76
+ })
77
+ })
@@ -0,0 +1,29 @@
1
+ import { useTestApp } from '@hazejs/testing'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const t = useTestApp()
5
+
6
+ describe('greeting', () => {
7
+ it('says hello', async () => {
8
+ const res = await t.get('/api/hello')
9
+ expect(res.status).toBe(200)
10
+ expect(res.body).toEqual({
11
+ code: 0,
12
+ message: 'ok',
13
+ data: { app: '{{name}}', message: 'Hello, hazejs!' },
14
+ })
15
+ })
16
+
17
+ it('greets by name', async () => {
18
+ const res = await t.post('/api/hello', { name: ' Ada ' })
19
+ expect(res.body.data).toEqual({ message: 'Hello, Ada!' })
20
+ })
21
+
22
+ it('validates the request body', async () => {
23
+ const res = await t.post('/api/hello', { name: '' })
24
+ expect(res.status).toBe(422)
25
+ expect(res.body.errors).toEqual([
26
+ { target: 'body', path: 'name', message: 'name 不能为空' },
27
+ ])
28
+ })
29
+ })
@@ -0,0 +1,58 @@
1
+ import { refreshDatabase } from '@hazejs/orm'
2
+ import { useTestApp } from '@hazejs/testing'
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import { User } from '~/app/model/User'
5
+
6
+ const t = useTestApp()
7
+ // .env.test 中 DATABASE_URL=:memory:,每个用例都是执行过迁移的全新数据库
8
+ beforeEach(() => refreshDatabase({ migrations: 'server/database/migrations' }))
9
+
10
+ describe('users', () => {
11
+ it('creates, lists, updates and deletes users', async () => {
12
+ const created = await t.post('/api/users', { name: ' Ada ', email: 'ada@example.com' })
13
+ expect(created.status).toBe(201)
14
+ const ada = created.body.data
15
+ expect(ada).toMatchObject({ name: 'Ada', email: 'ada@example.com' })
16
+
17
+ const list = await t.get('/api/users')
18
+ expect(list.body.data).toMatchObject({
19
+ total: 1,
20
+ data: [
21
+ { id: ada.id },
22
+ ],
23
+ })
24
+
25
+ // 修改 / 删除需要登录且为本人(app/policy/UserPolicy.ts)
26
+ const asAda = await t.actingAs(await User.findOrFail(ada.id))
27
+ const updated = await asAda.patch(`/api/users/${ada.id}`, { name: 'Ada Lovelace' })
28
+ expect(updated.body.data.name).toBe('Ada Lovelace')
29
+
30
+ expect((await asAda.delete(`/api/users/${ada.id}`)).status).toBe(204)
31
+ expect((await t.get(`/api/users/${ada.id}`)).status).toBe(404)
32
+ })
33
+
34
+ it('only lets users change themselves, unless they are admins', async () => {
35
+ const ada = await User.create({ name: 'Ada', email: 'ada@example.com' })
36
+ const bob = await User.create({ name: 'Bob', email: 'bob@example.com' })
37
+ const admin = await User.create({ name: 'Admin', email: 'admin@example.com', role: 'admin' })
38
+
39
+ expect((await t.patch(`/api/users/${ada.id}`, { name: 'x' })).status).toBe(401)
40
+ const forbidden = await (await t.actingAs(bob)).patch(`/api/users/${ada.id}`, { name: 'x' })
41
+ expect([forbidden.status, forbidden.body.message]).toEqual([403, '只能修改自己的资料'])
42
+ expect((await (await t.actingAs(bob)).delete(`/api/users/${ada.id}`)).status).toBe(403)
43
+ expect((await (await t.actingAs(admin)).delete(`/api/users/${ada.id}`)).status).toBe(204)
44
+ })
45
+
46
+ it('rejects duplicate emails with 409', async () => {
47
+ await t.post('/api/users', { name: 'Ada', email: 'ada@example.com' })
48
+ const res = await t.post('/api/users', { name: 'Copy', email: 'ada@example.com' })
49
+ expect(res.status).toBe(409)
50
+ expect(res.body.message).toBe('数据已存在(违反唯一约束)')
51
+ })
52
+
53
+ it('validates input', async () => {
54
+ const res = await t.post('/api/users', { name: '', email: 'nope' })
55
+ expect(res.status).toBe(422)
56
+ expect(res.body.errors.map((e: { path: string }) => e.path)).toEqual(['name', 'email'])
57
+ })
58
+ })
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5
+ "target": "ES2022",
6
+ "jsx": "preserve",
7
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
8
+ "moduleDetection": "force",
9
+ "module": "ESNext",
10
+ "moduleResolution": "Bundler",
11
+ "paths": {
12
+ "@/*": ["./src/*"]
13
+ },
14
+ "types": ["vite/client"],
15
+ "strict": true,
16
+ "noEmit": true,
17
+ "isolatedModules": true,
18
+ "verbatimModuleSyntax": true,
19
+ "skipLibCheck": true
20
+ },
21
+ "include": ["src/**/*.ts", "src/**/*.vue"]
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "references": [
3
+ { "path": "./tsconfig.app.json" },
4
+ { "path": "./tsconfig.server.json" }
5
+ ],
6
+ "files": []
7
+ }