create-fullstack-scaffold 0.4.23 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -104
- package/package.json +1 -1
- package/template/package.json +1 -0
- package/template/src/admin/i18n/locales/en-US.json +10 -1
- package/template/src/admin/i18n/locales/zh-CN.json +10 -1
- package/template/src/admin/pages/SettingsPage.tsx +96 -47
- package/template/src/server/module-admin/routes/system-routes.ts +53 -0
- package/template/src/server/module-auth/__tests__/profile-routes.test.ts +27 -17
- package/template/src/shared/modules/admin/schemas.ts +16 -0
- package/template/vite.config.ts +26 -1
package/README.md
CHANGED
|
@@ -1,154 +1,281 @@
|
|
|
1
|
-
# create-
|
|
1
|
+
# create-fullstack-scaffold
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/create-fullstack-scaffold)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Zero-config fullstack app generator with type-safe RPC, 15+ modules, and 8 production-ready presets. One command, zero `.env`, instant `npm run dev`.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Backend**: Hono with TypeScript
|
|
9
|
-
- **Database**: SQLite with Drizzle ORM
|
|
10
|
-
- **State Management**: Zustand
|
|
11
|
-
- **Real-time**: WebSocket + SSE support
|
|
12
|
-
- **Testing**: Vitest (unit + integration tests)
|
|
13
|
-
- **Code Quality**: ESLint, Prettier, pre-commit hooks
|
|
14
|
-
- **Type Safety**: End-to-end type safety with Hono RPC
|
|
7
|
+
## Quick Start
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
src/
|
|
20
|
-
├── client/ # React frontend
|
|
21
|
-
│ ├── components/ # UI components
|
|
22
|
-
│ ├── stores/ # Zustand state management
|
|
23
|
-
│ ├── services/ # API clients (apiClient)
|
|
24
|
-
│ ├── hooks/ # Custom hooks
|
|
25
|
-
│ ├── pages/ # Page components
|
|
26
|
-
│ └── App.tsx
|
|
27
|
-
├── server/ # Hono backend
|
|
28
|
-
│ ├── module-todos/ # Todo module
|
|
29
|
-
│ ├── module-chat/ # WebSocket chat module
|
|
30
|
-
│ ├── module-notifications/ # SSE notifications module
|
|
31
|
-
│ ├── core/ # Core services (runtime, realtime)
|
|
32
|
-
│ ├── middleware/ # Express middleware
|
|
33
|
-
│ ├── test-utils/ # Test utilities
|
|
34
|
-
│ └── entries/ # Entry points (node.ts, cloudflare.ts)
|
|
35
|
-
└── shared/ # Shared types
|
|
36
|
-
├── core/ # Framework layer (ws-client, sse-client)
|
|
37
|
-
├── modules/ # Business layer (chat, todos, notifications)
|
|
38
|
-
└── schemas/ # Unified exports
|
|
9
|
+
```bash
|
|
10
|
+
npx create-fullstack-scaffold@latest my-app
|
|
11
|
+
cd my-app && npm install && npm run dev
|
|
39
12
|
```
|
|
40
13
|
|
|
41
|
-
|
|
14
|
+
Open http://localhost:3010 — that's it. No `.env` required.
|
|
42
15
|
|
|
43
|
-
|
|
16
|
+
## Tech Stack
|
|
44
17
|
|
|
45
|
-
|
|
46
|
-
npm install
|
|
47
|
-
```
|
|
18
|
+
React + Hono + Vite + Zustand + TypeScript + Ant Design + Zod
|
|
48
19
|
|
|
49
|
-
|
|
20
|
+
| Layer | Technology | Purpose |
|
|
21
|
+
| ---------- | -------------------- | ----------------------------------- |
|
|
22
|
+
| Frontend | React 19 + Vite | Client SPA with HMR |
|
|
23
|
+
| Admin | Ant Design 5 | Admin/merchant/tenant dashboards |
|
|
24
|
+
| Backend | Hono (OpenAPI) | Type-safe RPC server |
|
|
25
|
+
| State | Zustand | Client-side state management |
|
|
26
|
+
| Validation | Zod | Shared schemas, end-to-end |
|
|
27
|
+
| DB | Drizzle ORM + SQLite | Pluggable data layer |
|
|
28
|
+
| Realtime | WebSocket + SSE | Built-in typed protocols |
|
|
29
|
+
| CLI | Commander | `biomimic` CLI for agent automation |
|
|
50
30
|
|
|
51
|
-
|
|
52
|
-
npm run dev
|
|
53
|
-
```
|
|
31
|
+
## Presets
|
|
54
32
|
|
|
55
|
-
|
|
33
|
+
8 presets. Each generates a different app by including/excluding modules.
|
|
56
34
|
|
|
57
|
-
|
|
35
|
+
| Preset | Modules | Use Case |
|
|
36
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
|
37
|
+
| `fullstack-admin` | todos, chat, notifications, file, captcha, permission, admin, auth, plugin, tenant, order, ticket, dispute, content, merchant (15) | Full-featured admin platform |
|
|
38
|
+
| `todo-app` | todos, chat, notifications, auth (4) | Learning / simple app |
|
|
39
|
+
| `ecommerce` | todos, chat, notifications, file, permission, order, ticket, dispute, content (9) | E-commerce store |
|
|
40
|
+
| `xbrowser-marketplace` | notifications, file, captcha, auth, permission, admin, plugin, order, ticket, dispute, content (11) | Plugin marketplace |
|
|
41
|
+
| `forum` | content, auth, permission, admin, notifications (5) | Community forum |
|
|
42
|
+
| `cli-only` | todos, chat, notifications, auth (4) | CLI agent / no browser UI |
|
|
43
|
+
| `minimal` | todos (1) | Bare minimum starting point |
|
|
44
|
+
| `saas` | todos, notifications, file, captcha, permission, auth, tenant, content (8) | Multi-tenant SaaS |
|
|
58
45
|
|
|
59
46
|
```bash
|
|
60
|
-
|
|
47
|
+
# Choose a preset
|
|
48
|
+
npx create-fullstack-scaffold@latest my-app --preset ecommerce
|
|
61
49
|
```
|
|
62
50
|
|
|
63
|
-
|
|
51
|
+
## Type-Safe RPC
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
Every API call is fully typed — zero code generation, powered by Hono RPC.
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
import { apiClient } from '@client/services/apiClient'
|
|
57
|
+
|
|
58
|
+
// HTTP — typed request + response
|
|
59
|
+
const res = await apiClient.api.todos.$get()
|
|
60
|
+
const { data } = await res.json() // data: Todo[]
|
|
68
61
|
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
// WebSocket — typed RPC + events
|
|
63
|
+
const ws = apiClient.api.chat.ws.$ws()
|
|
64
|
+
const result = await ws.call('echo', { message: 'hello' })
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
// SSE — typed server-push
|
|
67
|
+
const conn = await apiClient.api.notifications.stream.$sse()
|
|
68
|
+
conn.on('notification', n => console.log(n.title))
|
|
69
|
+
|
|
70
|
+
// Media — typed binary responses
|
|
71
|
+
const blob = await apiClient.api.avatar[':id'].$image({ param: { id: '123' } })
|
|
72
|
+
const svg = await apiClient.api.icon[':name'].$svg({ param: { name: 'home' } })
|
|
73
|
+
const file = await apiClient.api.export.$download()
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
| Protocol | Method | Return Type |
|
|
77
|
+
| ------------- | ------------------------------------------ | --------------------- |
|
|
78
|
+
| HTTP JSON | `$get()`, `$post()`, `$put()`, `$delete()` | `ClientResponse<T>` |
|
|
79
|
+
| WebSocket | `$ws()` | `WSClient<Protocol>` |
|
|
80
|
+
| SSE | `$sse()` | `SSEClient<Protocol>` |
|
|
81
|
+
| Image | `$image()` | `Promise<Blob>` |
|
|
82
|
+
| SVG | `$svg()` | `Promise<string>` |
|
|
83
|
+
| File Download | `$download()` | `Promise<Blob>` |
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
## Architecture
|
|
79
86
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
```
|
|
88
|
+
┌─────────────────────────────────────────────────────────┐
|
|
89
|
+
│ Generated App │
|
|
90
|
+
│ │
|
|
91
|
+
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
|
|
92
|
+
│ │ Client │ │ Admin │ │ Tenant │ │Merchant │ │
|
|
93
|
+
│ │ (React) │ │(Ant Design)│ │(Ant Design)│ │(Ant Design)│
|
|
94
|
+
│ │ index.html│ │admin.html│ │tenant.html│ │merchant │ │
|
|
95
|
+
│ └─────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │
|
|
96
|
+
│ │ │ │ │ │
|
|
97
|
+
│ └──────────────┴──────────────┴──────────────┘ │
|
|
98
|
+
│ │ │
|
|
99
|
+
│ apiClient (hc) │
|
|
100
|
+
│ type-safe RPC │
|
|
101
|
+
│ │ │
|
|
102
|
+
│ ┌───────────────────────────┴───────────────────────────┐ │
|
|
103
|
+
│ │ Hono Server (Single Port) │ │
|
|
104
|
+
│ │ │ │
|
|
105
|
+
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │ │
|
|
106
|
+
│ │ │module- │ │module- │ │module- │ │module- │ │ │
|
|
107
|
+
│ │ │todos │ │chat │ │notifi- │ │admin │ │ │
|
|
108
|
+
│ │ │ │ │ │ │cations │ │ │ │ │
|
|
109
|
+
│ │ │routes/ │ │routes/ │ │routes/ │ │routes/ │ │ │
|
|
110
|
+
│ │ │services/│ │services/│ │services/│ │services/ │ │ │
|
|
111
|
+
│ │ │__tests__/│ │__tests__/│ │__tests__/│ │__tests__/ │ │ │
|
|
112
|
+
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────────┘ │ │
|
|
113
|
+
│ │ + 11 more modules (order, ticket, dispute, ...) │ │
|
|
114
|
+
│ └───────────────────────────────────────────────────────┘ │
|
|
115
|
+
│ │ │
|
|
116
|
+
│ ┌────────┴────────┐ │
|
|
117
|
+
│ │ Shared / │ │
|
|
118
|
+
│ │ core/ (Zod) │ │
|
|
119
|
+
│ │ modules/ (types)│ │
|
|
120
|
+
│ └─────────────────┘ │
|
|
121
|
+
└─────────────────────────────────────────────────────────────┘
|
|
122
|
+
```
|
|
83
123
|
|
|
84
|
-
###
|
|
124
|
+
### Module System
|
|
85
125
|
|
|
86
|
-
|
|
126
|
+
15 modules with declarative manifests (`module.ts`). Each declares routes, dependencies, DB schemas, CLI commands, and pages.
|
|
87
127
|
|
|
88
|
-
|
|
128
|
+
| Category | Modules |
|
|
129
|
+
| ------------- | -------------------------------------------------------------------- |
|
|
130
|
+
| Core | `todos` |
|
|
131
|
+
| Communication | `chat`, `notifications` |
|
|
132
|
+
| System | `permission`, `admin`, `auth`, `captcha`, `file`, `tenant`, `plugin` |
|
|
133
|
+
| Business | `order`, `ticket`, `dispute`, `content`, `merchant` |
|
|
89
134
|
|
|
90
|
-
|
|
135
|
+
### Multi-Entry HTML
|
|
91
136
|
|
|
92
|
-
|
|
93
|
-
- **Business Layer** (`src/shared/modules/`): Business-specific schemas and protocols
|
|
137
|
+
Up to 4 independent SPAs, generated based on preset modules:
|
|
94
138
|
|
|
95
|
-
|
|
139
|
+
| Entry | File | Included When |
|
|
140
|
+
| -------- | --------------- | -------------------------------------- |
|
|
141
|
+
| Client | `index.html` | Always |
|
|
142
|
+
| Admin | `admin.html` | `admin` or `permission` module present |
|
|
143
|
+
| Tenant | `tenant.html` | `tenant` module present |
|
|
144
|
+
| Merchant | `merchant.html` | `merchant` module present |
|
|
96
145
|
|
|
97
|
-
|
|
146
|
+
Each entry has its own `App.tsx`, router, and layout — fully isolated.
|
|
98
147
|
|
|
99
|
-
|
|
100
|
-
import { apiClient } from '@client/services/apiClient'
|
|
148
|
+
### Module Dependency Graph
|
|
101
149
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
150
|
+
```
|
|
151
|
+
todos ──── (standalone)
|
|
152
|
+
chat ──── (standalone)
|
|
153
|
+
notifications ──── (standalone)
|
|
154
|
+
file ──── (standalone)
|
|
155
|
+
captcha ──── (standalone)
|
|
156
|
+
auth ──── (standalone)
|
|
157
|
+
permission ──── (standalone, foundational)
|
|
158
|
+
admin ────→ permission + notifications
|
|
159
|
+
plugin ────→ auth + permission + notifications
|
|
160
|
+
tenant ────→ auth + permission
|
|
161
|
+
order ────→ permission
|
|
162
|
+
ticket ────→ permission
|
|
163
|
+
dispute ────→ permission
|
|
164
|
+
content ────→ permission
|
|
165
|
+
merchant ────→ auth + permission
|
|
166
|
+
```
|
|
105
167
|
|
|
106
|
-
|
|
107
|
-
const ws = apiClient.api.chat.ws.$ws()
|
|
108
|
-
const result = await ws.call('echo', { message: 'hello' })
|
|
168
|
+
## Deployment
|
|
109
169
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
170
|
+
| Platform | Entry | Command |
|
|
171
|
+
| ---------------------- | ---------------------------------- | ------------------------------------------------------ |
|
|
172
|
+
| **Cloudflare Workers** | `src/server/entries/cloudflare.ts` | `wrangler deploy` |
|
|
173
|
+
| **Node.js** | `src/server/entries/node.ts` | `node dist/server/entries/node.js` |
|
|
174
|
+
| **shanbox** | Dockerfile included | `docker build -t app . && docker run -p 3010:3010 app` |
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Build for production
|
|
178
|
+
npm run build
|
|
179
|
+
|
|
180
|
+
# Preview production build
|
|
181
|
+
npm run preview
|
|
113
182
|
```
|
|
114
183
|
|
|
115
|
-
|
|
184
|
+
## Development
|
|
116
185
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
186
|
+
```bash
|
|
187
|
+
npm run dev # Start dev server on :3010 (no .env needed)
|
|
188
|
+
npm run build # Production build
|
|
189
|
+
npm run typecheck # TypeScript type check
|
|
190
|
+
npm run lint # ESLint
|
|
191
|
+
npm run test # Vitest (all tests)
|
|
192
|
+
npm run test:unit # Unit tests only
|
|
193
|
+
npm run test:integration # Integration tests only
|
|
194
|
+
npm run validate:modules # Validate module manifests
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Project Structure
|
|
122
198
|
|
|
123
|
-
|
|
199
|
+
```
|
|
200
|
+
template/src/
|
|
201
|
+
├── client/ # React SPA (index.html)
|
|
202
|
+
│ ├── components/ # UI components
|
|
203
|
+
│ ├── stores/ # Zustand state
|
|
204
|
+
│ ├── services/ # apiClient
|
|
205
|
+
│ ├── hooks/ # Custom hooks
|
|
206
|
+
│ └── pages/ # Page components
|
|
207
|
+
├── admin/ # Admin dashboard (admin.html, Ant Design)
|
|
208
|
+
│ ├── components/
|
|
209
|
+
│ ├── stores/
|
|
210
|
+
│ ├── layouts/
|
|
211
|
+
│ └── pages/
|
|
212
|
+
├── tenant/ # Tenant dashboard (tenant.html, Ant Design)
|
|
213
|
+
│ └── ...
|
|
214
|
+
├── merchant/ # Merchant dashboard (merchant.html, Ant Design)
|
|
215
|
+
│ └── ...
|
|
216
|
+
├── server/ # Hono backend
|
|
217
|
+
│ ├── module-{name}/ # Feature modules (15+)
|
|
218
|
+
│ │ ├── module.ts # Declarative manifest
|
|
219
|
+
│ │ ├── routes/ # API endpoints
|
|
220
|
+
│ │ ├── services/ # Business logic
|
|
221
|
+
│ │ └── __tests__/ # Module tests
|
|
222
|
+
│ ├── core/ # Runtime, realtime scanner
|
|
223
|
+
│ ├── middleware/ # Auth, CORS, logger, captcha
|
|
224
|
+
│ ├── db/ # Drizzle schema + migrations
|
|
225
|
+
│ ├── entries/ # node.ts, cloudflare.ts
|
|
226
|
+
│ └── test-utils/ # createTestClient, createTestServer
|
|
227
|
+
├── shared/ # Shared types (client + server)
|
|
228
|
+
│ ├── core/ # Framework: ws-client, sse-client, api-schemas
|
|
229
|
+
│ ├── modules/ # Business: todos, chat, notifications, ...
|
|
230
|
+
│ └── schemas/ # Unified re-exports
|
|
231
|
+
└── cli/ # CLI agent (biomimic command)
|
|
232
|
+
├── modules/ # todo, notification, config, ...
|
|
233
|
+
└── rpc/ # hc RPC client
|
|
234
|
+
```
|
|
124
235
|
|
|
125
|
-
|
|
236
|
+
### Path Aliases
|
|
126
237
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
238
|
+
| Alias | Resolves To |
|
|
239
|
+
| ----------- | -------------- |
|
|
240
|
+
| `@shared/*` | `src/shared/*` |
|
|
241
|
+
| `@client/*` | `src/client/*` |
|
|
242
|
+
| `@server/*` | `src/server/*` |
|
|
243
|
+
| `@admin/*` | `src/admin/*` |
|
|
130
244
|
|
|
131
|
-
|
|
245
|
+
## Testing
|
|
132
246
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
- `__tests__/` - Unit tests
|
|
247
|
+
```typescript
|
|
248
|
+
import { createTestClient } from '@server/test-utils/test-client'
|
|
136
249
|
|
|
137
|
-
|
|
250
|
+
const client = createTestClient() // No server needed for HTTP/SSE
|
|
138
251
|
|
|
139
|
-
|
|
252
|
+
const res = await client.api.todos.$get() // Fully typed
|
|
253
|
+
const { data } = await res.json()
|
|
254
|
+
```
|
|
140
255
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
256
|
+
| Test Type | Needs Server | Tool |
|
|
257
|
+
| --------- | ------------ | ---------------------------------------------- |
|
|
258
|
+
| HTTP API | No | `createTestClient()` |
|
|
259
|
+
| SSE | No | `$sse()` via `createTestClient()` |
|
|
260
|
+
| WebSocket | Yes | `createTestServer()` + `createTestClient(url)` |
|
|
261
|
+
| E2E | Yes | Playwright |
|
|
144
262
|
|
|
145
|
-
##
|
|
263
|
+
## Quality Gates
|
|
146
264
|
|
|
147
|
-
|
|
265
|
+
- **TypeScript strict mode** — no `any`, explicit return types
|
|
266
|
+
- **ESLint** — 17 custom rules (chain syntax, layer boundaries, no inline schemas, ...)
|
|
267
|
+
- **Pre-commit hooks** — typecheck + lint-staged + smart tests
|
|
268
|
+
- **Module validation** — `npm run validate:modules` checks manifests
|
|
269
|
+
- **Production verified** — all 7 client presets pass `typecheck` + `build`
|
|
148
270
|
|
|
149
271
|
## Documentation
|
|
150
272
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
273
|
+
| File | Content |
|
|
274
|
+
| -------------------- | ----------------------------------------- |
|
|
275
|
+
| `CLAUDE.md` | Development guidelines for AI agents |
|
|
276
|
+
| `.claude/rules/` | 20+ detailed development constraint files |
|
|
277
|
+
| `template/CLAUDE.md` | Template-specific development guide |
|
|
278
|
+
|
|
279
|
+
## License
|
|
280
|
+
|
|
281
|
+
MIT
|
package/package.json
CHANGED
package/template/package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"dev:forum": "VITE_PRESET=community vite",
|
|
16
16
|
"dev:saas": "VITE_PRESET=saas vite",
|
|
17
17
|
"build": "npm run build:client && npm run build:server",
|
|
18
|
+
"build:analyze": "ANALYZE=true vite build",
|
|
18
19
|
"build:client": "vite build",
|
|
19
20
|
"build:server": "tsup",
|
|
20
21
|
"build:cli": "tsup --config tsup.config.ts",
|
|
@@ -131,7 +131,16 @@
|
|
|
131
131
|
"confirmNewPassword": "Confirm New Password",
|
|
132
132
|
"confirmNewPasswordPlaceholder": "Confirm new password",
|
|
133
133
|
"saveChanges": "Save Changes",
|
|
134
|
-
"saved": "Settings saved successfully!"
|
|
134
|
+
"saved": "Settings saved successfully!",
|
|
135
|
+
"email": "Email Settings",
|
|
136
|
+
"smtpHost": "SMTP Host",
|
|
137
|
+
"smtpHostPlaceholder": "e.g. smtp.example.com",
|
|
138
|
+
"smtpPort": "SMTP Port",
|
|
139
|
+
"emailFrom": "From Address",
|
|
140
|
+
"emailFromPlaceholder": "e.g. noreply@example.com",
|
|
141
|
+
"sessionTimeout": "Session Timeout",
|
|
142
|
+
"maxLoginAttempts": "Max Login Attempts",
|
|
143
|
+
"minutes": "minutes"
|
|
135
144
|
},
|
|
136
145
|
"roles": {
|
|
137
146
|
"title": "Role Management",
|
|
@@ -131,7 +131,16 @@
|
|
|
131
131
|
"confirmNewPassword": "确认新密码",
|
|
132
132
|
"confirmNewPasswordPlaceholder": "请确认新密码",
|
|
133
133
|
"saveChanges": "保存更改",
|
|
134
|
-
"saved": "设置保存成功!"
|
|
134
|
+
"saved": "设置保存成功!",
|
|
135
|
+
"email": "邮件设置",
|
|
136
|
+
"smtpHost": "SMTP 主机",
|
|
137
|
+
"smtpHostPlaceholder": "例如 smtp.example.com",
|
|
138
|
+
"smtpPort": "SMTP 端口",
|
|
139
|
+
"emailFrom": "发件地址",
|
|
140
|
+
"emailFromPlaceholder": "例如 noreply@example.com",
|
|
141
|
+
"sessionTimeout": "会话超时",
|
|
142
|
+
"maxLoginAttempts": "最大登录尝试次数",
|
|
143
|
+
"minutes": "分钟"
|
|
135
144
|
},
|
|
136
145
|
"roles": {
|
|
137
146
|
"title": "角色管理",
|
|
@@ -1,73 +1,122 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Card, Form, Input, Button, Switch, Divider, message, InputNumber } from 'antd'
|
|
3
|
+
import { apiClient, api } from '../services/apiClient'
|
|
2
4
|
import { useLanguage } from '../i18n/useLanguage'
|
|
3
|
-
|
|
4
|
-
interface SettingsFormValues {
|
|
5
|
-
siteName?: string
|
|
6
|
-
siteDescription?: string
|
|
7
|
-
currentPassword?: string
|
|
8
|
-
newPassword?: string
|
|
9
|
-
confirmPassword?: string
|
|
10
|
-
}
|
|
5
|
+
import type { Settings } from '@shared/modules/admin'
|
|
11
6
|
|
|
12
7
|
export const SettingsPage: React.FC = () => {
|
|
13
8
|
const { t } = useLanguage()
|
|
14
|
-
const [form] = Form.useForm<
|
|
9
|
+
const [form] = Form.useForm<Settings>()
|
|
10
|
+
const [loading, setLoading] = useState(false)
|
|
11
|
+
|
|
12
|
+
const fetchSettings = useCallback(async () => {
|
|
13
|
+
try {
|
|
14
|
+
const data = await api(apiClient.api.admin.settings.$get())
|
|
15
|
+
.withLoading(t('common.loading'))
|
|
16
|
+
.json()
|
|
17
|
+
form.setFieldsValue(data)
|
|
18
|
+
} catch {
|
|
19
|
+
// handled by api-request
|
|
20
|
+
}
|
|
21
|
+
}, [form, t])
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
fetchSettings()
|
|
25
|
+
}, [fetchSettings])
|
|
26
|
+
|
|
27
|
+
const handleSave = async (values: Settings) => {
|
|
28
|
+
setLoading(true)
|
|
29
|
+
try {
|
|
30
|
+
await api(
|
|
31
|
+
apiClient.api.admin.settings.$put({
|
|
32
|
+
json: values,
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
.withLoading(t('common.loading'))
|
|
36
|
+
.json()
|
|
37
|
+
message.success(t('settings.saved'))
|
|
38
|
+
} catch {
|
|
39
|
+
// handled by api-request
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false)
|
|
42
|
+
}
|
|
18
43
|
}
|
|
19
44
|
|
|
20
45
|
return (
|
|
21
46
|
<div>
|
|
22
47
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">{t('settings.title')}</h1>
|
|
23
48
|
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
<Form.Item label={t('settings.siteName')} name="siteName">
|
|
49
|
+
<Form form={form} layout="vertical" onFinish={handleSave}>
|
|
50
|
+
<Card title={t('settings.general')} className="mb-6">
|
|
51
|
+
<Form.Item label={t('settings.siteName')} name="siteName" rules={[{ required: true }]}>
|
|
27
52
|
<Input placeholder={t('settings.siteNamePlaceholder')} />
|
|
28
53
|
</Form.Item>
|
|
29
54
|
<Form.Item label={t('settings.siteDescription')} name="siteDescription">
|
|
30
55
|
<Input.TextArea rows={4} placeholder={t('settings.siteDescriptionPlaceholder')} />
|
|
31
56
|
</Form.Item>
|
|
32
|
-
</
|
|
33
|
-
</Card>
|
|
57
|
+
</Card>
|
|
34
58
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
<Card title={t('settings.email')} className="mb-6">
|
|
60
|
+
<Form.Item label={t('settings.smtpHost')} name="smtpHost" rules={[{ required: true }]}>
|
|
61
|
+
<Input placeholder={t('settings.smtpHostPlaceholder')} />
|
|
62
|
+
</Form.Item>
|
|
63
|
+
<Form.Item label={t('settings.smtpPort')} name="smtpPort" rules={[{ required: true }]}>
|
|
64
|
+
<InputNumber min={1} max={65535} className="w-full" />
|
|
65
|
+
</Form.Item>
|
|
66
|
+
<Form.Item
|
|
67
|
+
label={t('settings.emailFrom')}
|
|
68
|
+
name="emailFrom"
|
|
69
|
+
rules={[{ required: true, type: 'email' }]}
|
|
70
|
+
>
|
|
71
|
+
<Input placeholder={t('settings.emailFromPlaceholder')} />
|
|
72
|
+
</Form.Item>
|
|
73
|
+
</Card>
|
|
74
|
+
|
|
75
|
+
<Card title={t('settings.notification')} className="mb-6">
|
|
76
|
+
<div className="space-y-4">
|
|
77
|
+
<div className="flex items-center justify-between">
|
|
78
|
+
<div>
|
|
79
|
+
<p className="font-medium">{t('settings.emailNotifications')}</p>
|
|
80
|
+
<p className="text-sm text-gray-500">{t('settings.emailNotificationsDesc')}</p>
|
|
81
|
+
</div>
|
|
82
|
+
<Form.Item name="emailNotifications" valuePropName="checked" noStyle>
|
|
83
|
+
<Switch />
|
|
84
|
+
</Form.Item>
|
|
41
85
|
</div>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<
|
|
86
|
+
<Divider />
|
|
87
|
+
<div className="flex items-center justify-between">
|
|
88
|
+
<div>
|
|
89
|
+
<p className="font-medium">{t('settings.pushNotifications')}</p>
|
|
90
|
+
<p className="text-sm text-gray-500">{t('settings.pushNotificationsDesc')}</p>
|
|
91
|
+
</div>
|
|
92
|
+
<Form.Item name="pushNotifications" valuePropName="checked" noStyle>
|
|
93
|
+
<Switch />
|
|
94
|
+
</Form.Item>
|
|
49
95
|
</div>
|
|
50
|
-
<Switch />
|
|
51
96
|
</div>
|
|
52
|
-
</
|
|
53
|
-
</Card>
|
|
97
|
+
</Card>
|
|
54
98
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
99
|
+
<Card title={t('settings.security')} className="mb-6">
|
|
100
|
+
<Form.Item
|
|
101
|
+
label={t('settings.sessionTimeout')}
|
|
102
|
+
name="sessionTimeout"
|
|
103
|
+
rules={[{ required: true }]}
|
|
104
|
+
>
|
|
105
|
+
<InputNumber min={5} max={1440} addonAfter={t('settings.minutes')} className="w-full" />
|
|
59
106
|
</Form.Item>
|
|
60
|
-
<Form.Item
|
|
61
|
-
|
|
107
|
+
<Form.Item
|
|
108
|
+
label={t('settings.maxLoginAttempts')}
|
|
109
|
+
name="maxLoginAttempts"
|
|
110
|
+
rules={[{ required: true }]}
|
|
111
|
+
>
|
|
112
|
+
<InputNumber min={1} max={20} className="w-full" />
|
|
62
113
|
</Form.Item>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
</Form>
|
|
70
|
-
</Card>
|
|
114
|
+
</Card>
|
|
115
|
+
|
|
116
|
+
<Button type="primary" htmlType="submit" loading={loading} size="large">
|
|
117
|
+
{t('settings.saveChanges')}
|
|
118
|
+
</Button>
|
|
119
|
+
</Form>
|
|
71
120
|
</div>
|
|
72
121
|
)
|
|
73
122
|
}
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
HealthCheckSchema,
|
|
10
10
|
RecentActivitySchema,
|
|
11
11
|
ClearTodosResultSchema,
|
|
12
|
+
SettingsSchema,
|
|
13
|
+
UpdateSettingsSchema,
|
|
12
14
|
} from '@shared/modules/admin'
|
|
13
15
|
|
|
14
16
|
const getStatsRoute = createRoute({
|
|
@@ -68,7 +70,58 @@ const clearAllTodosRoute = createRoute({
|
|
|
68
70
|
},
|
|
69
71
|
})
|
|
70
72
|
|
|
73
|
+
const getSettingsRoute = createRoute({
|
|
74
|
+
method: 'get',
|
|
75
|
+
path: '/admin/settings',
|
|
76
|
+
tags: ['admin'],
|
|
77
|
+
security: [{ Bearer: [] }],
|
|
78
|
+
middleware: [authMiddleware({ requiredRole: Role.SUPER_ADMIN })],
|
|
79
|
+
responses: {
|
|
80
|
+
200: successResponse(SettingsSchema, 'Get system settings'),
|
|
81
|
+
401: errorResponse('Unauthorized'),
|
|
82
|
+
403: errorResponse('Forbidden'),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const updateSettingsRoute = createRoute({
|
|
87
|
+
method: 'put',
|
|
88
|
+
path: '/admin/settings',
|
|
89
|
+
tags: ['admin'],
|
|
90
|
+
security: [{ Bearer: [] }],
|
|
91
|
+
middleware: [authMiddleware({ requiredRole: Role.SUPER_ADMIN })],
|
|
92
|
+
request: {
|
|
93
|
+
body: {
|
|
94
|
+
content: { 'application/json': { schema: UpdateSettingsSchema } },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
responses: {
|
|
98
|
+
200: successResponse(SettingsSchema, 'Settings updated'),
|
|
99
|
+
401: errorResponse('Unauthorized'),
|
|
100
|
+
403: errorResponse('Forbidden'),
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
let settingsState: z.infer<typeof SettingsSchema> = {
|
|
105
|
+
siteName: 'Biomimic Admin',
|
|
106
|
+
siteDescription: 'A full-stack admin dashboard',
|
|
107
|
+
smtpHost: 'smtp.example.com',
|
|
108
|
+
smtpPort: 587,
|
|
109
|
+
emailFrom: 'noreply@example.com',
|
|
110
|
+
sessionTimeout: 30,
|
|
111
|
+
maxLoginAttempts: 5,
|
|
112
|
+
emailNotifications: true,
|
|
113
|
+
pushNotifications: false,
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
export const systemRoutes = new OpenAPIHono<{ Variables: { authUser: AuthUser } }>()
|
|
117
|
+
.openapi(getSettingsRoute, async c => {
|
|
118
|
+
return c.json(success(settingsState), 200)
|
|
119
|
+
})
|
|
120
|
+
.openapi(updateSettingsRoute, async c => {
|
|
121
|
+
const body = c.req.valid('json')
|
|
122
|
+
settingsState = { ...settingsState, ...body }
|
|
123
|
+
return c.json(success(settingsState), 200)
|
|
124
|
+
})
|
|
72
125
|
.openapi(getStatsRoute, async c => {
|
|
73
126
|
const stats = await adminService.getSystemStats()
|
|
74
127
|
return c.json(success(stats), 200)
|
|
@@ -2,19 +2,36 @@
|
|
|
2
2
|
import { describe, it, expect } from 'vitest'
|
|
3
3
|
import { profileRoutes } from '../routes/profile-routes'
|
|
4
4
|
|
|
5
|
+
interface ProfileResponse {
|
|
6
|
+
success: boolean
|
|
7
|
+
data: {
|
|
8
|
+
id: string
|
|
9
|
+
username: string
|
|
10
|
+
email: string
|
|
11
|
+
bio: string
|
|
12
|
+
joinDate: string
|
|
13
|
+
stats: { posts: number; followers: number; following: number }
|
|
14
|
+
}
|
|
15
|
+
timestamp: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fetchProfile(): Promise<ProfileResponse> {
|
|
19
|
+
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
20
|
+
return (await res.json()) as ProfileResponse
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
describe('Profile Routes', () => {
|
|
6
24
|
describe('GET /profile', () => {
|
|
7
25
|
it('should return profile with 200', async () => {
|
|
8
26
|
const res = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
9
27
|
|
|
10
28
|
expect(res.status).toBe(200)
|
|
11
|
-
const data =
|
|
29
|
+
const data = await fetchProfile()
|
|
12
30
|
expect(data.success).toBe(true)
|
|
13
31
|
})
|
|
14
32
|
|
|
15
33
|
it('should return profile with required fields', async () => {
|
|
16
|
-
const
|
|
17
|
-
const data = (await res.json()) as Record<string, any>
|
|
34
|
+
const data = await fetchProfile()
|
|
18
35
|
|
|
19
36
|
expect(data.success).toBe(true)
|
|
20
37
|
expect(data.data.id).toBeDefined()
|
|
@@ -25,16 +42,14 @@ describe('Profile Routes', () => {
|
|
|
25
42
|
})
|
|
26
43
|
|
|
27
44
|
it('should return valid email format', async () => {
|
|
28
|
-
const
|
|
29
|
-
const data = (await res.json()) as Record<string, any>
|
|
45
|
+
const data = await fetchProfile()
|
|
30
46
|
|
|
31
47
|
expect(data.success).toBe(true)
|
|
32
48
|
expect(data.data.email).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
|
33
49
|
})
|
|
34
50
|
|
|
35
51
|
it('should return stats with numeric values', async () => {
|
|
36
|
-
const
|
|
37
|
-
const data = (await res.json()) as Record<string, any>
|
|
52
|
+
const data = await fetchProfile()
|
|
38
53
|
|
|
39
54
|
expect(data.success).toBe(true)
|
|
40
55
|
expect(typeof data.data.stats.posts).toBe('number')
|
|
@@ -43,18 +58,15 @@ describe('Profile Routes', () => {
|
|
|
43
58
|
})
|
|
44
59
|
|
|
45
60
|
it('should return a valid ISO date for joinDate', async () => {
|
|
46
|
-
const
|
|
47
|
-
const data = (await res.json()) as Record<string, any>
|
|
61
|
+
const data = await fetchProfile()
|
|
48
62
|
|
|
49
63
|
expect(data.success).toBe(true)
|
|
50
64
|
expect(data.data.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
51
65
|
})
|
|
52
66
|
|
|
53
67
|
it('should return consistent data across requests', async () => {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const res2 = await profileRoutes.fetch(new Request('http://localhost/profile'))
|
|
57
|
-
const data2 = (await res2.json()) as Record<string, any>
|
|
68
|
+
const data1 = await fetchProfile()
|
|
69
|
+
const data2 = await fetchProfile()
|
|
58
70
|
|
|
59
71
|
expect(data1.success).toBe(true)
|
|
60
72
|
expect(data2.success).toBe(true)
|
|
@@ -64,8 +76,7 @@ describe('Profile Routes', () => {
|
|
|
64
76
|
})
|
|
65
77
|
|
|
66
78
|
it('should include timestamp in response', async () => {
|
|
67
|
-
const
|
|
68
|
-
const data = (await res.json()) as Record<string, any>
|
|
79
|
+
const data = await fetchProfile()
|
|
69
80
|
|
|
70
81
|
expect(data.success).toBe(true)
|
|
71
82
|
expect(data.timestamp).toBeDefined()
|
|
@@ -73,8 +84,7 @@ describe('Profile Routes', () => {
|
|
|
73
84
|
})
|
|
74
85
|
|
|
75
86
|
it('should return bio field', async () => {
|
|
76
|
-
const
|
|
77
|
-
const data = (await res.json()) as Record<string, any>
|
|
87
|
+
const data = await fetchProfile()
|
|
78
88
|
|
|
79
89
|
expect(data.success).toBe(true)
|
|
80
90
|
expect(data.data.bio).toBeDefined()
|
|
@@ -79,6 +79,20 @@ export const ClearTodosResultSchema = z.object({
|
|
|
79
79
|
deletedCount: z.number(),
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
+
export const SettingsSchema = z.object({
|
|
83
|
+
siteName: z.string(),
|
|
84
|
+
siteDescription: z.string(),
|
|
85
|
+
smtpHost: z.string(),
|
|
86
|
+
smtpPort: z.number(),
|
|
87
|
+
emailFrom: z.string(),
|
|
88
|
+
sessionTimeout: z.number(),
|
|
89
|
+
maxLoginAttempts: z.number(),
|
|
90
|
+
emailNotifications: z.boolean(),
|
|
91
|
+
pushNotifications: z.boolean(),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export const UpdateSettingsSchema = SettingsSchema
|
|
95
|
+
|
|
82
96
|
export const AdminSuccessSchema = z.object({})
|
|
83
97
|
|
|
84
98
|
export const DownloadTokenSchema = z.object({
|
|
@@ -98,3 +112,5 @@ export type RegisterRequest = z.infer<typeof RegisterRequestSchema>
|
|
|
98
112
|
export type User = z.infer<typeof UserSchema>
|
|
99
113
|
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>
|
|
100
114
|
export type ClearTodosResult = z.infer<typeof ClearTodosResultSchema>
|
|
115
|
+
export type Settings = z.infer<typeof SettingsSchema>
|
|
116
|
+
export type UpdateSettings = z.infer<typeof UpdateSettingsSchema>
|
package/template/vite.config.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import path from 'path'
|
|
2
2
|
import { existsSync } from 'fs'
|
|
3
|
-
import { defineConfig } from 'vite'
|
|
3
|
+
import { defineConfig, type Plugin } from 'vite'
|
|
4
4
|
import devServer from '@hono/vite-dev-server'
|
|
5
5
|
import { websocketPlugin, dbPlugin } from './vite-plugins'
|
|
6
|
+
// Bundle analysis: npm install -D rollup-plugin-visualizer && npm run build:analyze
|
|
7
|
+
let visualizerPlugin: (() => Plugin) | undefined
|
|
8
|
+
if (process.env.ANALYZE === 'true') {
|
|
9
|
+
try {
|
|
10
|
+
// @ts-expect-error — optional dev dependency, installed via: npm install -D rollup-plugin-visualizer
|
|
11
|
+
const mod = await import('rollup-plugin-visualizer')
|
|
12
|
+
visualizerPlugin = () =>
|
|
13
|
+
mod.visualizer({
|
|
14
|
+
open: true,
|
|
15
|
+
filename: 'stats.html',
|
|
16
|
+
gzipSize: true,
|
|
17
|
+
brotliSize: true,
|
|
18
|
+
}) as Plugin
|
|
19
|
+
} catch {
|
|
20
|
+
// rollup-plugin-visualizer not installed — run: npm install -D rollup-plugin-visualizer
|
|
21
|
+
}
|
|
22
|
+
}
|
|
6
23
|
// Prerender is optional — only needed during production build with puppeteer + Chrome installed
|
|
7
24
|
let prerender: typeof import('@prerenderer/rollup-plugin').default | undefined
|
|
8
25
|
let puppeteerRenderer: typeof import('@prerenderer/renderer-puppeteer').default | undefined
|
|
@@ -85,6 +102,7 @@ export default defineConfig({
|
|
|
85
102
|
],
|
|
86
103
|
build: {
|
|
87
104
|
outDir: 'dist/client',
|
|
105
|
+
chunkSizeWarningLimit: 1000,
|
|
88
106
|
rollupOptions: {
|
|
89
107
|
input: {
|
|
90
108
|
main: path.resolve(__dirname, 'index.html'),
|
|
@@ -93,6 +111,12 @@ export default defineConfig({
|
|
|
93
111
|
merchant: path.resolve(__dirname, 'merchant.html'),
|
|
94
112
|
},
|
|
95
113
|
output: {
|
|
114
|
+
manualChunks: {
|
|
115
|
+
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
116
|
+
'vendor-antd': ['antd', '@ant-design/icons'],
|
|
117
|
+
'vendor-hono': ['hono'],
|
|
118
|
+
'vendor-zustand': ['zustand'],
|
|
119
|
+
},
|
|
96
120
|
plugins: [
|
|
97
121
|
...(prerender && puppeteerRenderer
|
|
98
122
|
? [
|
|
@@ -105,6 +129,7 @@ export default defineConfig({
|
|
|
105
129
|
}),
|
|
106
130
|
]
|
|
107
131
|
: []),
|
|
132
|
+
...(visualizerPlugin ? [visualizerPlugin()] : []),
|
|
108
133
|
],
|
|
109
134
|
},
|
|
110
135
|
onwarn(warning, defaultHandler) {
|