create-fullstack-scaffold 0.5.6 → 0.6.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.
- package/dist/cli/index.js +80 -22
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/package.json +10 -14
- package/template/src/cli/modules/tenant/index.ts +89 -0
- package/template/src/client/entry-server.tsx +7 -1
- package/template/src/client/main.tsx +0 -8
- package/template/src/client/pages/ContentDetailPage.tsx +1 -1
- package/template/src/client/pages/ContentListPage.tsx +1 -1
- package/template/src/client/pages/TodoPage.tsx +20 -4
- package/template/src/client/ssr-pages.ts +10 -21
- package/template/src/server/db/init.ts +0 -52
- package/template/src/server/db/schema/index.ts +3 -0
- package/template/src/server/db/schema/tenant-invitations.ts +33 -0
- package/template/src/server/db/schema/tenant-members.ts +38 -0
- package/template/src/server/db/schema/tenant-roles.ts +32 -0
- package/template/src/server/db/schema/todos.ts +4 -0
- package/template/src/server/db/test-setup.ts +128 -0
- package/template/src/server/entries/node.ts +38 -19
- package/template/src/server/index.ts +12 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
- package/template/src/server/middleware/auth.ts +17 -19
- package/template/src/server/middleware/tenant-isolation.ts +4 -2
- package/template/src/server/module-auth/module.ts +2 -1
- package/template/src/server/module-auth/services/auth-service.ts +39 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
- package/template/src/server/module-tenant/module.ts +1 -1
- package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
- package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
- package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
- package/template/src/server/module-todos/services/todo-service.ts +12 -2
- package/template/src/server/ssr-bridge.ts +10 -0
- package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
- package/template/src/server/utils/id-helpers.ts +13 -0
- package/template/src/shared/modules/index.ts +29 -6
- package/template/src/shared/modules/tenant/index.ts +30 -0
- package/template/src/shared/modules/tenant/permissions.ts +54 -0
- package/template/src/shared/modules/tenant/role-templates.ts +66 -0
- package/template/src/shared/modules/tenant/schemas.ts +113 -1
- package/template/src/shared/modules/todos/schemas.ts +1 -0
- package/template/src/shared/schemas/index.ts +28 -0
- package/template/src/tenant/App.tsx +30 -23
- package/template/src/tenant/components/TenantGuard.tsx +12 -2
- package/template/src/tenant/layouts/Header.tsx +6 -2
- package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
- package/template/src/tenant/pages/LoginPage.tsx +82 -0
- package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
- package/template/src/tenant/pages/UsersPage.tsx +134 -88
- package/template/src/tenant/services/tenantApi.ts +61 -0
- package/template/src/tenant/stores/tenantStore.ts +172 -49
- package/template/src/test/setup-db-path.ts +20 -0
- package/template/vite.config.ts +4 -73
- package/template/vitest.config.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-fullstack-scaffold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-fullstack-scaffold": "dist/cli/index.js"
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"dependencies": {
|
|
109
109
|
"@hono/node-server": "^1.19.15",
|
|
110
110
|
"@hono/zod-openapi": "1.2.2",
|
|
111
|
-
"@hono/zod-validator": "0.
|
|
111
|
+
"@hono/zod-validator": "0.8.0",
|
|
112
112
|
"@inquirer/prompts": "^8.4.2",
|
|
113
113
|
"@libsql/client": "^0.17.0",
|
|
114
114
|
"chalk": "^5.6.2",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"fs-extra": "^11.3.5",
|
|
119
119
|
"hono": "4.12.34",
|
|
120
120
|
"jsonwebtoken": "^9.0.3",
|
|
121
|
-
"lucide-react": "^
|
|
121
|
+
"lucide-react": "^1.16.0",
|
|
122
122
|
"ora": "^9.4.0",
|
|
123
123
|
"pino": "^9.7.0",
|
|
124
124
|
"pino-pretty": "^13.0.0",
|
|
@@ -131,12 +131,12 @@
|
|
|
131
131
|
"zustand": "^5.0.2"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
|
-
"@cloudflare/workers-types": "^
|
|
135
|
-
"@commitlint/cli": "^
|
|
136
|
-
"@commitlint/config-conventional": "^
|
|
134
|
+
"@cloudflare/workers-types": "^5.20260910.1",
|
|
135
|
+
"@commitlint/cli": "^21.0.0",
|
|
136
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
137
137
|
"@eslint/js": "^9.39.4",
|
|
138
138
|
"@hono/vite-dev-server": "^0.23.0",
|
|
139
|
-
"@playwright/test": "^1.
|
|
139
|
+
"@playwright/test": "^1.60.0",
|
|
140
140
|
"@tailwindcss/postcss": "^4.2.1",
|
|
141
141
|
"@testing-library/dom": "^10.4.1",
|
|
142
142
|
"@testing-library/jest-dom": "^6.8.0",
|
|
@@ -161,20 +161,20 @@
|
|
|
161
161
|
"globals": "^16.0.0",
|
|
162
162
|
"husky": "^9.1.7",
|
|
163
163
|
"jsdom": "^26.0.3",
|
|
164
|
-
"lint-staged": "^
|
|
164
|
+
"lint-staged": "^17.0.5",
|
|
165
165
|
"minimatch": "^10.2.4",
|
|
166
166
|
"patch-package": "^8.0.1",
|
|
167
|
-
"playwright": "^1.
|
|
167
|
+
"playwright": "^1.60.0",
|
|
168
168
|
"postcss": "^8.5.6",
|
|
169
169
|
"prettier": "^3.5.3",
|
|
170
170
|
"tailwindcss": "^4.2.1",
|
|
171
171
|
"ts-import": "^5.0.0-beta.1",
|
|
172
172
|
"tsup": "^8.5.0",
|
|
173
173
|
"typescript": "^5.8.3",
|
|
174
|
-
"typescript-eslint": "^8.
|
|
174
|
+
"typescript-eslint": "^8.59.4",
|
|
175
175
|
"vite": "^6.2.3",
|
|
176
176
|
"vitest": "^4.0.16",
|
|
177
|
-
"wrangler": "^4.
|
|
177
|
+
"wrangler": "^4.131.0"
|
|
178
178
|
},
|
|
179
179
|
"lint-staged": {
|
|
180
180
|
"*.{ts,tsx,js,jsx}": [
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"*.{json,md,css}": "prettier --write"
|
|
186
186
|
},
|
|
187
187
|
"engines": {
|
|
188
|
-
"node": ">=
|
|
188
|
+
"node": ">=22.0.0"
|
|
189
189
|
},
|
|
190
190
|
"overrides": {
|
|
191
191
|
"esbuild": "^0.25.0"
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "biomimic-todo-app",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"biomimic": "./dist/cli/index.js"
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"@dyyz1993/xcli-core": "^0.7.4",
|
|
76
76
|
"@hono/node-server": "^1.19.15",
|
|
77
77
|
"@hono/zod-openapi": "1.2.2",
|
|
78
|
-
"@hono/zod-validator": "0.
|
|
78
|
+
"@hono/zod-validator": "0.8.0",
|
|
79
79
|
"@ant-design/icons": "^5.6.1",
|
|
80
80
|
"@libsql/client": "^0.17.0",
|
|
81
81
|
"antd": "^5.24.0",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"drizzle-orm": "^0.45.1",
|
|
86
86
|
"hono": "4.12.34",
|
|
87
87
|
"jsonwebtoken": "^9.0.3",
|
|
88
|
-
"lucide-react": "^
|
|
88
|
+
"lucide-react": "^1.16.0",
|
|
89
89
|
"pino": "^9.7.0",
|
|
90
90
|
"pino-pretty": "^13.0.0",
|
|
91
91
|
"react": "^18.3.1",
|
|
@@ -99,16 +99,12 @@
|
|
|
99
99
|
"zustand": "^5.0.2"
|
|
100
100
|
},
|
|
101
101
|
"devDependencies": {
|
|
102
|
-
"@cloudflare/workers-types": "^
|
|
103
|
-
"@commitlint/cli": "^
|
|
104
|
-
"@commitlint/config-conventional": "^
|
|
102
|
+
"@cloudflare/workers-types": "^5.20260910.1",
|
|
103
|
+
"@commitlint/cli": "^21.0.0",
|
|
104
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
105
105
|
"@eslint/js": "^9.39.4",
|
|
106
106
|
"@hono/vite-dev-server": "^0.23.0",
|
|
107
107
|
"@playwright/test": "^1.60.0",
|
|
108
|
-
"@prerenderer/renderer-jsdom": "^1.1.9",
|
|
109
|
-
"@prerenderer/renderer-puppeteer": "^1.2.4",
|
|
110
|
-
"puppeteer": "^24.43.1",
|
|
111
|
-
"@prerenderer/rollup-plugin": "^0.3.12",
|
|
112
108
|
"@tailwindcss/postcss": "^4.2.1",
|
|
113
109
|
"@testing-library/dom": "^10.4.1",
|
|
114
110
|
"@testing-library/jest-dom": "^6.8.0",
|
|
@@ -133,7 +129,7 @@
|
|
|
133
129
|
"globals": "^16.0.0",
|
|
134
130
|
"husky": "^9.1.7",
|
|
135
131
|
"jsdom": "^26.0.3",
|
|
136
|
-
"lint-staged": "^
|
|
132
|
+
"lint-staged": "^17.0.5",
|
|
137
133
|
"minimatch": "^10.2.4",
|
|
138
134
|
"patch-package": "^8.0.0",
|
|
139
135
|
"playwright": "^1.60.0",
|
|
@@ -143,10 +139,10 @@
|
|
|
143
139
|
"tsup": "^8.5.0",
|
|
144
140
|
"tsx": "^4.21.0",
|
|
145
141
|
"typescript": "^5.8.3",
|
|
146
|
-
"typescript-eslint": "^8.
|
|
142
|
+
"typescript-eslint": "^8.59.4",
|
|
147
143
|
"vite": "^6.2.3",
|
|
148
144
|
"vitest": "^4.0.16",
|
|
149
|
-
"wrangler": "^4.
|
|
145
|
+
"wrangler": "^4.131.0"
|
|
150
146
|
},
|
|
151
147
|
"lint-staged": {
|
|
152
148
|
"*.{ts,tsx,js,jsx}": [
|
|
@@ -159,7 +155,7 @@
|
|
|
159
155
|
]
|
|
160
156
|
},
|
|
161
157
|
"engines": {
|
|
162
|
-
"node": ">=
|
|
158
|
+
"node": ">=22.0.0"
|
|
163
159
|
},
|
|
164
160
|
"overrides": {
|
|
165
161
|
"esbuild": "^0.25.0"
|
|
@@ -139,4 +139,93 @@ export function registerTenantCommands(site: SiteInstance) {
|
|
|
139
139
|
}
|
|
140
140
|
},
|
|
141
141
|
})
|
|
142
|
+
|
|
143
|
+
site.command('members', {
|
|
144
|
+
description: 'List members of a tenant',
|
|
145
|
+
parameters: z.object({
|
|
146
|
+
id: z.string().describe('Tenant ID'),
|
|
147
|
+
}),
|
|
148
|
+
handler: async (params: unknown) => {
|
|
149
|
+
const p = params as { id: string }
|
|
150
|
+
try {
|
|
151
|
+
const client = getClient()
|
|
152
|
+
const res = await client.api.tenants[':tenantId'].members.$get({
|
|
153
|
+
param: { tenantId: p.id },
|
|
154
|
+
})
|
|
155
|
+
const data = await res.json()
|
|
156
|
+
return ok(data)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return fail(err instanceof Error ? err.message : 'Failed to list members')
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
site.command('roles', {
|
|
164
|
+
description: 'List roles of a tenant',
|
|
165
|
+
parameters: z.object({
|
|
166
|
+
id: z.string().describe('Tenant ID'),
|
|
167
|
+
}),
|
|
168
|
+
handler: async (params: unknown) => {
|
|
169
|
+
const p = params as { id: string }
|
|
170
|
+
try {
|
|
171
|
+
const client = getClient()
|
|
172
|
+
const res = await client.api.tenants[':tenantId'].roles.$get({
|
|
173
|
+
param: { tenantId: p.id },
|
|
174
|
+
})
|
|
175
|
+
const data = await res.json()
|
|
176
|
+
return ok(data)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return fail(err instanceof Error ? err.message : 'Failed to list roles')
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
site.command('invite', {
|
|
184
|
+
description: 'Invite a member to a tenant (returns a 7-day invitation link)',
|
|
185
|
+
parameters: z.object({
|
|
186
|
+
id: z.string().describe('Tenant ID'),
|
|
187
|
+
email: z.string().describe('Invitee email'),
|
|
188
|
+
'role-id': z.string().describe('Role ID (see tenant roles command)'),
|
|
189
|
+
}),
|
|
190
|
+
handler: async (params: unknown) => {
|
|
191
|
+
const p = params as { id: string; email: string; 'role-id': string }
|
|
192
|
+
try {
|
|
193
|
+
const client = getClient()
|
|
194
|
+
const res = await client.api.tenants[':tenantId'].members.invite.$post({
|
|
195
|
+
param: { tenantId: p.id },
|
|
196
|
+
json: { email: p.email, roleId: p['role-id'] },
|
|
197
|
+
})
|
|
198
|
+
const data = await res.json()
|
|
199
|
+
if (data.success && data.data?.token) {
|
|
200
|
+
return ok({
|
|
201
|
+
...data,
|
|
202
|
+
data: { ...data.data, inviteLink: `/tenant/invite/${data.data.token}` },
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
return ok(data)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return fail(err instanceof Error ? err.message : 'Failed to invite member')
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
site.command('invitation', {
|
|
213
|
+
description: 'Show invitation detail by token (public)',
|
|
214
|
+
parameters: z.object({
|
|
215
|
+
token: z.string().describe('Invitation token'),
|
|
216
|
+
}),
|
|
217
|
+
handler: async (params: unknown) => {
|
|
218
|
+
const p = params as { token: string }
|
|
219
|
+
try {
|
|
220
|
+
const client = getClient()
|
|
221
|
+
const res = await client.api.tenants.invitations[':token'].$get({
|
|
222
|
+
param: { token: p.token },
|
|
223
|
+
})
|
|
224
|
+
const data = await res.json()
|
|
225
|
+
return ok(data)
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return fail(err instanceof Error ? err.message : 'Failed to get invitation')
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
})
|
|
142
231
|
}
|
|
@@ -50,6 +50,11 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
|
|
|
50
50
|
// 4. Render with helmet context to extract head tags
|
|
51
51
|
const helmetContext: Record<string, unknown> = {}
|
|
52
52
|
|
|
53
|
+
// 服务端把 ISR 数据挂到 globalThis:组件的 ssrInitial*() 在 renderToString
|
|
54
|
+
// 期间与浏览器水合同源读取(globalThis === window),首帧即渲染真实内容
|
|
55
|
+
const globalData = globalThis as { __SSR_DATA__?: unknown }
|
|
56
|
+
globalData.__SSR_DATA__ = data
|
|
57
|
+
|
|
53
58
|
try {
|
|
54
59
|
// 页面级 SSR:ISR 路由有静态同构组件(ssr-pages)——渲染真实页面
|
|
55
60
|
// 内容而非 lazy Loading 壳(SEO 核心);其余路由回退 AppRoutes 壳。
|
|
@@ -78,7 +83,8 @@ export function renderSSR(pathname: string, data: SSRData): SSRRenderResult {
|
|
|
78
83
|
|
|
79
84
|
return { html, helmet: { title: titleStr, meta: metaStr } }
|
|
80
85
|
} finally {
|
|
81
|
-
// 6. Restore store state
|
|
86
|
+
// 6. Restore store state + 清理服务端数据挂载(防跨请求泄漏)
|
|
82
87
|
restoreEntryStores(snapshot)
|
|
88
|
+
delete globalData.__SSR_DATA__
|
|
83
89
|
}
|
|
84
90
|
}
|
|
@@ -7,7 +7,7 @@ import { apiClient } from '@client/services/apiClient'
|
|
|
7
7
|
// SSR 首帧数据:ISR 服务端写入 __SSR_DATA__.content(详情页按 id 匹配)
|
|
8
8
|
function ssrInitialContent(id?: string): Content | null {
|
|
9
9
|
try {
|
|
10
|
-
const d = (
|
|
10
|
+
const d = (globalThis as { __SSR_DATA__?: { content?: Content | null } }).__SSR_DATA__
|
|
11
11
|
const c = d?.content ?? null
|
|
12
12
|
return c && (!id || c.id === id) ? c : null
|
|
13
13
|
} catch {
|
|
@@ -17,7 +17,7 @@ const CATEGORIES: { value: ContentCategory | ''; label: string }[] = [
|
|
|
17
17
|
// 直接用它渲染(水合一致、爬虫可见真实内容),effect 再刷新
|
|
18
18
|
function ssrInitialContents(): Content[] {
|
|
19
19
|
try {
|
|
20
|
-
const d = (
|
|
20
|
+
const d = (globalThis as { __SSR_DATA__?: { contents?: Content[] } }).__SSR_DATA__
|
|
21
21
|
return d?.contents ?? []
|
|
22
22
|
} catch {
|
|
23
23
|
return []
|
|
@@ -22,6 +22,19 @@ import type { Todo } from '@shared/schemas'
|
|
|
22
22
|
|
|
23
23
|
type FilterType = 'all' | 'pending' | 'in_progress' | 'completed'
|
|
24
24
|
|
|
25
|
+
// SSR 首帧数据:ISR 管线把 registry fetch 的数据写进 __SSR_DATA__,
|
|
26
|
+
// 服务端 renderToString 与浏览器水合从同一 globalThis 读取。zustand v5 的
|
|
27
|
+
// 服务端快照固定为 getInitialState(setState 种子对 SSR 不可见),首帧
|
|
28
|
+
// 列表必须从这里兜底,挂载后 effect 再走 store 刷新
|
|
29
|
+
function ssrInitialTodos(): Todo[] {
|
|
30
|
+
try {
|
|
31
|
+
const d = (globalThis as { __SSR_DATA__?: { todos?: Todo[] } }).__SSR_DATA__
|
|
32
|
+
return d?.todos ?? []
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
export const TodoPage: React.FC = () => {
|
|
26
39
|
const todos = useTodoStore(state => state.todos)
|
|
27
40
|
const loading = useTodoStore(state => state.loading)
|
|
@@ -37,6 +50,9 @@ export const TodoPage: React.FC = () => {
|
|
|
37
50
|
|
|
38
51
|
const [newTodoTitle, setNewTodoTitle] = useState('')
|
|
39
52
|
const [newTodoDescription, setNewTodoDescription] = useState('')
|
|
53
|
+
const [ssrTodos] = useState<Todo[]>(ssrInitialTodos)
|
|
54
|
+
// store 一旦有数据(挂载后 fetch 完成)即以 store 为准,首帧用 SSR 数据兜底
|
|
55
|
+
const visibleTodos = todos.length > 0 ? todos : ssrTodos
|
|
40
56
|
const [filter, setFilter] = useState<FilterType>('all')
|
|
41
57
|
const [expandedTodoId, setExpandedTodoId] = useState<number | null>(null)
|
|
42
58
|
const fileInputRefs = useRef<Map<number, HTMLInputElement>>(new Map())
|
|
@@ -95,7 +111,7 @@ export const TodoPage: React.FC = () => {
|
|
|
95
111
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
96
112
|
}
|
|
97
113
|
|
|
98
|
-
const filteredTodos =
|
|
114
|
+
const filteredTodos = visibleTodos.filter(todo => {
|
|
99
115
|
if (filter === 'all') return true
|
|
100
116
|
return todo.status === filter
|
|
101
117
|
})
|
|
@@ -174,17 +190,17 @@ export const TodoPage: React.FC = () => {
|
|
|
174
190
|
</div>
|
|
175
191
|
)}
|
|
176
192
|
|
|
177
|
-
{loading &&
|
|
193
|
+
{loading && visibleTodos.length === 0 && (
|
|
178
194
|
<div className="flex items-center justify-center py-12" data-testid="loading-indicator">
|
|
179
195
|
<LoadingSpinner size="lg" />
|
|
180
196
|
</div>
|
|
181
197
|
)}
|
|
182
198
|
|
|
183
|
-
{
|
|
199
|
+
{visibleTodos.length > 0 && (
|
|
184
200
|
<div className="mb-6 flex items-center justify-between">
|
|
185
201
|
<div className="flex items-center gap-2" data-testid="todo-count">
|
|
186
202
|
<span className="text-sm text-gray-500">Total:</span>
|
|
187
|
-
<span className="text-sm font-medium text-gray-900">{
|
|
203
|
+
<span className="text-sm font-medium text-gray-900">{visibleTodos.length}</span>
|
|
188
204
|
</div>
|
|
189
205
|
<div className="flex items-center gap-2">
|
|
190
206
|
<button
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSR
|
|
2
|
+
* SSR 静态页面注册表(模板全量版;CLI 按 preset 重新生成,只导入实际存在的页面)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Loading 壳。这里用静态 import 提供同构组件映射,页面内容在服务端
|
|
7
|
-
* 真正渲染进 HTML(ISR/SEO 的核心价值所在)。
|
|
8
|
-
*
|
|
9
|
-
* 客户端不走这里(CSR 仍用 lazy 代码分割),水合由 entry-server 渲染的
|
|
10
|
-
* HTML + main.tsx 的 hydrateRoot 完成——两侧组件树来自同一源码,静态
|
|
11
|
-
* 导入版多打一份包只进 server bundle(tsup 的 CF 构建),不影响 client
|
|
12
|
-
* chunk 体积。
|
|
4
|
+
* renderToString 解析不了 React.lazy——ISR 路由的页面级 SSR 依赖这里的
|
|
5
|
+
* 静态同构组件,页面内容才真正渲染进 HTML(SEO/首屏的核心价值)。
|
|
13
6
|
*/
|
|
14
7
|
|
|
15
8
|
import type { ComponentType } from 'react'
|
|
16
|
-
|
|
17
|
-
// 按需静态导入 ISR 场景的页面(内容型、需要 SEO 的页面)
|
|
18
9
|
import { TodoPage } from './pages/TodoPage'
|
|
19
10
|
import { ContentListPage } from './pages/ContentListPage'
|
|
20
11
|
import { ContentDetailPage } from './pages/ContentDetailPage'
|
|
@@ -22,11 +13,6 @@ import { TopicsPage } from './pages/TopicsPage'
|
|
|
22
13
|
|
|
23
14
|
export type SsrPageComponent = ComponentType
|
|
24
15
|
|
|
25
|
-
/**
|
|
26
|
-
* 路由 → 同构组件映射。
|
|
27
|
-
* 未列出的路由(管理后台、登录页等)SSR 输出布局壳——它们本来就不
|
|
28
|
-
* 需要 SEO(登录后才可见),客户端 lazy 照常工作。
|
|
29
|
-
*/
|
|
30
16
|
export const SSR_PAGES: Record<string, SsrPageComponent> = {
|
|
31
17
|
'/todos': TodoPage,
|
|
32
18
|
'/content': ContentListPage,
|
|
@@ -34,11 +20,14 @@ export const SSR_PAGES: Record<string, SsrPageComponent> = {
|
|
|
34
20
|
'/topics': TopicsPage,
|
|
35
21
|
}
|
|
36
22
|
|
|
37
|
-
|
|
23
|
+
const SSR_PATTERNS: Array<{ prefix: string; component: SsrPageComponent }> = [
|
|
24
|
+
{ prefix: '/content/', component: ContentDetailPage },
|
|
25
|
+
]
|
|
26
|
+
|
|
38
27
|
export function getSsrPage(pathname: string): SsrPageComponent | null {
|
|
39
28
|
if (SSR_PAGES[pathname]) return SSR_PAGES[pathname]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
for (const p of SSR_PATTERNS) {
|
|
30
|
+
if (pathname.startsWith(p.prefix)) return p.component
|
|
31
|
+
}
|
|
43
32
|
return null
|
|
44
33
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { getDb } from './driver'
|
|
2
2
|
import { permissions, roles, rolePermissions } from './schema'
|
|
3
|
-
import { developers } from './schema/developers'
|
|
4
3
|
import { logger } from '../utils/logger'
|
|
5
|
-
import { generateUUID } from '../utils/uuid'
|
|
6
|
-
import { hash } from 'bcryptjs'
|
|
7
4
|
|
|
8
5
|
const log = logger.db()
|
|
9
6
|
|
|
@@ -354,53 +351,6 @@ const initialRolePermissions = [
|
|
|
354
351
|
{ roleId: 'role_user', permissionId: 'perm_order_view' },
|
|
355
352
|
]
|
|
356
353
|
|
|
357
|
-
async function seedDevelopersIfEmpty() {
|
|
358
|
-
try {
|
|
359
|
-
const db = await getDb()
|
|
360
|
-
|
|
361
|
-
const existing = await db.select().from(developers)
|
|
362
|
-
if (existing.length > 0) return
|
|
363
|
-
|
|
364
|
-
log.info({}, 'Seeding developers...')
|
|
365
|
-
|
|
366
|
-
const demoPasswordHash = await hash('demo123', 10)
|
|
367
|
-
const adminPasswordHash = await hash('admin123', 10)
|
|
368
|
-
|
|
369
|
-
const sampleDevelopers = [
|
|
370
|
-
{
|
|
371
|
-
id: generateUUID(),
|
|
372
|
-
username: 'demo',
|
|
373
|
-
email: 'demo@biomimic.app',
|
|
374
|
-
passwordHash: demoPasswordHash,
|
|
375
|
-
role: 'developer' as const,
|
|
376
|
-
apiKey: generateUUID(),
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
id: generateUUID(),
|
|
380
|
-
username: 'superadmin',
|
|
381
|
-
email: 'admin@biomimic.app',
|
|
382
|
-
passwordHash: adminPasswordHash,
|
|
383
|
-
role: 'admin' as const,
|
|
384
|
-
apiKey: generateUUID(),
|
|
385
|
-
},
|
|
386
|
-
]
|
|
387
|
-
|
|
388
|
-
await db.insert(developers).values(sampleDevelopers)
|
|
389
|
-
|
|
390
|
-
log.info({}, 'Developers seeding complete!')
|
|
391
|
-
} catch (error) {
|
|
392
|
-
// 如果表不存在,忽略错误(可能在某些模板中不需要 developers 表)
|
|
393
|
-
if (
|
|
394
|
-
error instanceof Error &&
|
|
395
|
-
(error.message.includes('no such table') || error.message.includes('does not exist'))
|
|
396
|
-
) {
|
|
397
|
-
log.debug({}, 'Developers table not found, skipping seed')
|
|
398
|
-
return
|
|
399
|
-
}
|
|
400
|
-
throw error
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
354
|
export async function initializeDatabase() {
|
|
405
355
|
const db = await getDb()
|
|
406
356
|
|
|
@@ -445,8 +395,6 @@ export async function initializeDatabase() {
|
|
|
445
395
|
)
|
|
446
396
|
}
|
|
447
397
|
|
|
448
|
-
await seedDevelopersIfEmpty()
|
|
449
|
-
|
|
450
398
|
log.info({}, 'Database initialization complete!')
|
|
451
399
|
|
|
452
400
|
log.info({}, 'Seeding module data...')
|
|
@@ -15,5 +15,8 @@ export * from './contents'
|
|
|
15
15
|
export * from './developers'
|
|
16
16
|
export * from './plugins'
|
|
17
17
|
export * from './tenants'
|
|
18
|
+
export * from './tenant-roles'
|
|
19
|
+
export * from './tenant-members'
|
|
20
|
+
export * from './tenant-invitations'
|
|
18
21
|
export * from './merchants'
|
|
19
22
|
export * from './products'
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import { tenants } from './tenants'
|
|
3
|
+
import { tenantRoles } from './tenant-roles'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 租户邀请(email + 一次性 token,默认 7 天过期)。
|
|
7
|
+
* token 随邀请链接分发:/invite/:token 前端页凭 token 查详情并接受。
|
|
8
|
+
*/
|
|
9
|
+
export const tenantInvitations = sqliteTable('tenant_invitations', {
|
|
10
|
+
id: text('id').primaryKey(),
|
|
11
|
+
tenantId: integer('tenant_id')
|
|
12
|
+
.notNull()
|
|
13
|
+
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
14
|
+
email: text('email').notNull(),
|
|
15
|
+
roleId: text('role_id')
|
|
16
|
+
.notNull()
|
|
17
|
+
.references(() => tenantRoles.id, { onDelete: 'cascade' }),
|
|
18
|
+
inviterId: text('inviter_id').notNull(),
|
|
19
|
+
token: text('token').notNull().unique(),
|
|
20
|
+
status: text('status', {
|
|
21
|
+
enum: ['pending', 'accepted', 'declined', 'expired', 'cancelled'],
|
|
22
|
+
})
|
|
23
|
+
.notNull()
|
|
24
|
+
.default('pending'),
|
|
25
|
+
expiresAt: text('expires_at').notNull(),
|
|
26
|
+
acceptedAt: text('accepted_at'),
|
|
27
|
+
createdAt: text('created_at').notNull(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export type TenantInvitationTable = typeof tenantInvitations.$inferSelect
|
|
31
|
+
export type NewTenantInvitation = typeof tenantInvitations.$inferInsert
|
|
32
|
+
|
|
33
|
+
export type InvitationStatus = 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import { tenants } from './tenants'
|
|
3
|
+
import { tenantRoles } from './tenant-roles'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 租户成员(用户与租户的归属关系)。userId 指向认证体系(developers.id /
|
|
7
|
+
* dev token 用户 id),删除为软删(status='left')保留审计轨迹。
|
|
8
|
+
*/
|
|
9
|
+
export const tenantMembers = sqliteTable(
|
|
10
|
+
'tenant_members',
|
|
11
|
+
{
|
|
12
|
+
id: text('id').primaryKey(),
|
|
13
|
+
tenantId: integer('tenant_id')
|
|
14
|
+
.notNull()
|
|
15
|
+
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
16
|
+
userId: text('user_id').notNull(),
|
|
17
|
+
roleId: text('role_id')
|
|
18
|
+
.notNull()
|
|
19
|
+
.references(() => tenantRoles.id, { onDelete: 'cascade' }),
|
|
20
|
+
status: text('status', {
|
|
21
|
+
enum: ['active', 'pending', 'suspended', 'left'],
|
|
22
|
+
})
|
|
23
|
+
.notNull()
|
|
24
|
+
.default('active'),
|
|
25
|
+
invitedBy: text('invited_by'),
|
|
26
|
+
invitedAt: text('invited_at'),
|
|
27
|
+
joinedAt: text('joined_at').notNull(),
|
|
28
|
+
lastActiveAt: text('last_active_at'),
|
|
29
|
+
},
|
|
30
|
+
table => ({
|
|
31
|
+
userTenantUnique: unique().on(table.userId, table.tenantId),
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
export type TenantMemberTable = typeof tenantMembers.$inferSelect
|
|
36
|
+
export type NewTenantMember = typeof tenantMembers.$inferInsert
|
|
37
|
+
|
|
38
|
+
export type TenantMemberStatus = 'active' | 'pending' | 'suspended' | 'left'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'
|
|
2
|
+
import { tenants } from './tenants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 租户内自定义角色(每租户独立,与平台级 roles 表区分)。
|
|
6
|
+
* permissions 存 TenantPermission 枚举数组的 JSON 序列化。
|
|
7
|
+
*/
|
|
8
|
+
export const tenantRoles = sqliteTable(
|
|
9
|
+
'tenant_roles',
|
|
10
|
+
{
|
|
11
|
+
id: text('id').primaryKey(),
|
|
12
|
+
tenantId: integer('tenant_id')
|
|
13
|
+
.notNull()
|
|
14
|
+
.references(() => tenants.id, { onDelete: 'cascade' }),
|
|
15
|
+
code: text('code').notNull(),
|
|
16
|
+
name: text('name').notNull(),
|
|
17
|
+
label: text('label').notNull(),
|
|
18
|
+
description: text('description'),
|
|
19
|
+
permissions: text('permissions').notNull(),
|
|
20
|
+
isSystem: integer('is_system', { mode: 'boolean' }).notNull().default(false),
|
|
21
|
+
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
|
22
|
+
sortOrder: integer('sort_order').notNull().default(0),
|
|
23
|
+
createdAt: text('created_at').notNull(),
|
|
24
|
+
updatedAt: text('updated_at').notNull(),
|
|
25
|
+
},
|
|
26
|
+
table => ({
|
|
27
|
+
tenantCodeUnique: unique().on(table.tenantId, table.code),
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
export type TenantRoleTable = typeof tenantRoles.$inferSelect
|
|
32
|
+
export type NewTenantRole = typeof tenantRoles.$inferInsert
|
|
@@ -8,6 +8,9 @@ export const todos = sqliteTable(
|
|
|
8
8
|
'todos',
|
|
9
9
|
{
|
|
10
10
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
11
|
+
// 租户归属(saas 等含 tenant 模块的 preset 由隔离中间件填充;其他
|
|
12
|
+
// preset 恒为 null,列对它们无害)
|
|
13
|
+
tenantId: integer('tenant_id'),
|
|
11
14
|
title: text('title').notNull(),
|
|
12
15
|
description: text('description'),
|
|
13
16
|
status: text('status', { enum: todoStatus }).notNull().default('pending'),
|
|
@@ -20,6 +23,7 @@ export const todos = sqliteTable(
|
|
|
20
23
|
},
|
|
21
24
|
table => ({
|
|
22
25
|
statusIdx: index('todos_status_idx').on(table.status),
|
|
26
|
+
tenantIdx: index('todos_tenant_idx').on(table.tenantId),
|
|
23
27
|
createdAtIdx: index('todos_created_at_idx').on(table.createdAt),
|
|
24
28
|
updatedAtIdx: index('todos_updated_at_idx').on(table.updatedAt),
|
|
25
29
|
})
|