weifuwu 0.38.0 → 0.38.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.
package/README.md CHANGED
@@ -1,288 +1,665 @@
1
1
  # weifuwu
2
2
 
3
- **全栈框架 — 后端 `(req, ctx) => Response` + 前端 `(props, ctx) => VNode` + 纯 CSS 布局系统**
3
+ **全栈框架 — 后端 HTTP 路由 + 前端 VDOM 框架 + 纯 CSS 布局系统**
4
4
 
5
5
  ```bash
6
6
  npm install weifuwu
7
7
  ```
8
8
 
9
- 一个包,零上游依赖。后端提供 HTTP 路由、数据库、中间件;前端提供 VDOM + Proxy 驱动的前端框架;布局提供纯 CSS 原语 + 主题 Token
9
+ 一个包 = 后端 (`weifuwu`) + 前端 (`weifuwu/client`) + 组件库 (`weifuwu/components`) + 布局系统 (`weifuwu/layout`)
10
10
 
11
11
  ---
12
12
 
13
13
  ## 模块总览
14
14
 
15
- | 模块 | 导入路径 | 用途 | 依赖 |
16
- |------|---------|------|------|
17
- | **Router** | `weifuwu` | HTTP 路由 + 中间件链 + WebSocket + GraphQL | — |
18
- | **serve** | `weifuwu` | HTTP 服务器 | `Router` |
19
- | **cors** | `weifuwu` | CORS 跨域中间件 | `Router` |
20
- | **serveStatic** | `weifuwu` | 静态文件服务 | `Router` |
21
- | **postgres** | `weifuwu` | PostgreSQL 客户端 → `ctx.sql` | `Router` |
22
- | **redis** | `weifuwu` | Redis 客户端 → `ctx.redis` | `Router` |
23
- | **ui** | `weifuwu` | SSR 渲染 + 动态 JS/CSS 编译 → `ctx.ui` | `Router` |
24
- | **graphql** | `weifuwu` | GraphQL 端点 | `Router` |
25
- | **client** | `weifuwu/client` | 前端 VDOM + Proxy 框架 + i18n + ErrorBoundary | — |
26
- | **components** | `weifuwu/components` | 36 HTML 原语组件(Button/Table/Modal/...) | `client` |
27
- | **layout** | `weifuwu/layout` | CSS 布局原语 + 主题 Token | — |
15
+ | 导入路径 | 模块 | 用途 | 依赖 |
16
+ |---------|------|------|------|
17
+ | `weifuwu` | **Router** | Trie 路由 + 中间件链 + WebSocket + GraphQL | — |
18
+ | `weifuwu` | **serve** | HTTP 服务器 | Router |
19
+ | `weifuwu` | **cors** | CORS 跨域中间件 | Router |
20
+ | `weifuwu` | **serveStatic** | 静态文件服务(ETag/304/目录索引) | Router |
21
+ | `weifuwu` | **postgres** | PostgreSQL 连接池 → `ctx.sql` | Router, DATABASE_URL |
22
+ | `weifuwu` | **redis** | Redis 客户端 → `ctx.redis` | Router, REDIS_URL |
23
+ | `weifuwu` | **ui** | SSR 渲染 + esbuild JS/CSS 动态编译 → `ctx.ui` | Router |
24
+ | `weifuwu` | **graphql** | GraphQL 端点(支持 GraphiQL) | Router |
25
+ | `weifuwu` | **createMiddleware** | 类型安全中间件工厂 | — |
26
+ | `weifuwu` | **response** | HTTP 响应辅助函数(ok/badRequest/...) | |
27
+ | `weifuwu` | **parseBody** | JSON 请求体安全解析 | — |
28
+ | `weifuwu/client` | **createApp** | 应用引导 + VDOM 渲染引擎 | — |
29
+ | `weifuwu/client` | **router / RouteView** | 前端路由(history/hash 模式) | createApp |
30
+ | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
31
+ | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
32
+ | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
33
+ | `weifuwu/client` | **confirm** | Promise 化确认对话框 | createApp |
34
+ | `weifuwu/client** | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
35
+ | `weifuwu/components` | **38 个组件** | Button/Table/Modal/Toast/... | weifuwu/client |
36
+ | `weifuwu/layout` | **CSS 布局** | 35 个布局原语 + 72 个主题 Token | — |
28
37
 
29
38
  ---
30
39
 
31
- ## 核心理念
32
-
33
- 前后端共享同一模式:**中间件向 `ctx` 注入字段,handler/组件从 `ctx` 读取。**
34
-
35
- ```
36
- 后端: 前端:
37
- Request → Middleware → Handler createApp() → Middleware → Component
38
- │ │
39
- ▼ ▼
40
- ctx.sql ctx.ws
41
- ctx.redis ctx.route
42
- ctx.ui ctx.api / ctx.auth
43
- ctx.i18n
44
- ```
45
-
46
- ---
47
-
48
- ## 快速开始 —— 全栈应用
40
+ ## 快速开始
49
41
 
50
42
  ```ts
51
- import { serve, Router, ui } from 'weifuwu'
43
+ // server.ts
44
+ import { serve, Router, ui, cors, serveStatic } from 'weifuwu'
52
45
 
53
46
  const app = new Router()
47
+ app.use(cors())
54
48
  app.use(ui())
55
49
 
56
- // 前端 TSX → JS bundle(动态编译,零构建步骤)
57
- app.get('/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
58
-
59
- // CSS(PostCSS + Tailwind 编译)
60
- app.get('/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
61
-
62
50
  // SPA 入口
63
- app.get('/', async (req, ctx) => ctx.ui.html`
64
- <!DOCTYPE html>
65
- <html>
66
- <head><link rel="stylesheet" href="/style.css"></head>
67
- <body><div id="root"></div><script src="/app.js"></script></body>
68
- </html>
51
+ app.get('/', (req, ctx) => ctx.ui.html`
52
+ <!doctype html><html><body>
53
+ <div id="root"></div>
54
+ <script src="/app.js"></script>
55
+ </body></html>
69
56
  `)
70
57
 
58
+ // 动态编译前端 TSX(零构建步骤)
59
+ app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx'))
60
+ app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css'))
61
+
62
+ // API
63
+ app.get('/api/hello', () => Response.json({ msg: 'world' }))
64
+
71
65
  serve(app, { port: 3000 })
72
66
  ```
73
67
 
74
68
  ```tsx
75
- // src/main.tsx —— 前端
76
- import { createApp, router, RouteView, i18n } from 'weifuwu/client'
77
- import type { WfuiContext, RouteDef } from 'weifuwu/client'
69
+ // src/main.tsx
70
+ import { createApp, router, RouteView } from 'weifuwu/client'
78
71
 
79
- function Home(_props: {}, ctx: WfuiContext) {
80
- return <h1>Hello weifuwu</h1>
81
- }
72
+ function Home() { return <h1>Hello weifuwu</h1> }
82
73
 
83
74
  createApp()
84
- .use(router({ routes: [{ path: '/', component: Home }], mode: 'history' }))
85
- .mount('#root', () => <Home />)
75
+ .use(router({ routes: [{ path: '/', component: Home }] }))
76
+ .mount('#root', () => <RouteView />)
86
77
  ```
87
78
 
88
79
  ---
89
80
 
90
- ## 后端
81
+ # 后端 API (`weifuwu`)
82
+
83
+ ## Router
91
84
 
92
- ### Router
85
+ Trie 路由,支持 URL 参数、通配符、中间件链、WebSocket、GraphQL。
93
86
 
94
87
  ```ts
95
88
  import { Router } from 'weifuwu'
96
89
 
97
90
  const app = new Router()
91
+ ```
98
92
 
99
- // 中间件
100
- app.use(cors())
93
+ ### 路由方法
101
94
 
102
- // 路由
103
- app.get('/api/users', async (req: Request, ctx: Context) => {
104
- return Response.json(users)
95
+ 每个路由方法接受 `path, ...middlewares[], handler`:
96
+
97
+ ```ts
98
+ app.get('/users', handler)
99
+ app.post('/users', handler)
100
+ app.put('/users/:id', handler)
101
+ app.patch('/users/:id', handler) // PATCH
102
+ app.delete('/users/:id', handler) // DELETE
103
+ app.head('/users', handler) // HEAD
104
+ app.options('/users', handler) // OPTIONS
105
+ app.all('/users', handler) // 所有 HTTP 方法
106
+ ```
107
+
108
+ | 参数 | 类型 | 说明 |
109
+ |------|------|------|
110
+ | `path` | `string` | 路由路径,支持 `:param` 和 `*` 通配符 |
111
+ | `...middlewares` | `Middleware[]` | 路由级中间件(可选) |
112
+ | `handler` | `Handler` 或 `Router` | 处理器或子路由器 |
113
+
114
+ ### 路由级中间件
115
+
116
+ ```ts
117
+ declare module 'weifuwu' { interface Context { auth: { userId: string } } }
118
+
119
+ const requireAuth: Middleware = (req, ctx, next) => {
120
+ if (!req.headers.get('authorization')) return new Response('Unauthorized', { status: 401 })
121
+ ;(ctx as any).auth = { userId: '123' }
122
+ return next(req, ctx)
123
+ }
124
+
125
+ app.get('/users/:id', requireAuth, (req, ctx) => {
126
+ return Response.json({ id: ctx.params.id, auth: ctx.auth })
105
127
  })
128
+ ```
129
+
130
+ ### 参数与查询
106
131
 
107
- app.post('/api/users', async (req: Request, ctx: Context) => {
108
- const body = await req.json()
109
- return Response.json({ id: 1, ...body }, { status: 201 })
132
+ ```ts
133
+ app.get('/posts/:category/:slug', (req, ctx) => {
134
+ ctx.params.category // URL 参数
135
+ ctx.params.slug // URL 参数
136
+ ctx.query.page // 查询参数 ?page=1
137
+ return Response.json(ctx.params)
110
138
  })
139
+ ```
140
+
141
+ ### 通配符
111
142
 
112
- // URL 参数
113
- app.get('/users/:id', async (req: Request, ctx: Context) => {
114
- const id = ctx.params.id
115
- return Response.json({ id, name: 'User ' + id })
143
+ ```ts
144
+ app.get('/files/*', (req, ctx) => {
145
+ ctx.params['*'] // 剩余路径 "a/b/c.txt"
146
+ return Response.json({ path: ctx.params['*'] })
116
147
  })
117
148
  ```
118
149
 
119
- | 方法 | 路由 | 说明 |
120
- |------|------|------|
121
- | `app.get(path, handler)` | 任意 | GET 请求 |
122
- | `app.post(path, handler)` | 任意 | POST 请求 |
123
- | `app.put(path, handler)` | 任意 | PUT 请求 |
124
- | `app.patch(path, handler)` | 任意 | PATCH 请求 |
125
- | `app.delete(path, handler)` | 任意 | DELETE 请求 |
126
- | `app.use(middleware)` | 全路由 | 中间件 |
127
- | `app.ws(path, handler)` | 任意 | WebSocket |
128
- | `app.graphql(options)` | 自动 | GraphQL 端点 |
129
- | `app.onError(handler)` | 全局 | 错误处理 |
150
+ ### 子路由挂载
130
151
 
131
- ### serve —— HTTP 服务器
152
+ ```ts
153
+ const users = new Router()
154
+ users.get('/', listUsers)
155
+ users.get('/:id', getUser)
156
+
157
+ app.mount('/api/users', users)
158
+ // → GET /api/users, GET /api/users/:id
159
+ ```
160
+
161
+ ### 插件模式
162
+
163
+ ```ts
164
+ app.plugin(app => {
165
+ app.get('/health', () => Response.json({ ok: true }))
166
+ app.use(cors())
167
+ })
168
+ ```
169
+
170
+ ### 类型安全中间件工厂
171
+
172
+ ```ts
173
+ import { createMiddleware } from 'weifuwu'
174
+ declare module 'weifuwu' { interface Context { greeting: string } }
175
+
176
+ const greet = createMiddleware({
177
+ injects: ['greeting'], // 注入的 ctx 字段
178
+ depends: ['sql'], // 前置依赖(可选)
179
+ setup: async (ctx) => ({
180
+ greeting: 'Hello ' + ctx.params.name
181
+ }),
182
+ })
183
+
184
+ app.use(greet)
185
+ app.get('/hello/:name', (req, ctx) => Response.json({ msg: ctx.greeting }))
186
+ ```
187
+
188
+ `createMiddleware` 自动生成 `__meta` 元数据用于运行时依赖检查。
189
+
190
+ ### 查看路由表
191
+
192
+ ```ts
193
+ console.log(app.routes())
194
+ // → [
195
+ // "GET /users",
196
+ // "POST /users",
197
+ // "WS /chat",
198
+ // "MIDDLEWARE [2 global]"
199
+ // ]
200
+ ```
201
+
202
+ ### 路由方法速查
203
+
204
+ | 方法 | 说明 |
205
+ |------|------|
206
+ | `app.get(path, ...mws, handler)` | GET |
207
+ | `app.post(path, ...mws, handler)` | POST |
208
+ | `app.put(path, ...mws, handler)` | PUT |
209
+ | `app.patch(path, ...mws, handler)` | PATCH |
210
+ | `app.delete(path, ...mws, handler)` | DELETE |
211
+ | `app.head(path, ...mws, handler)` | HEAD |
212
+ | `app.options(path, ...mws, handler)` | OPTIONS |
213
+ | `app.all(path, ...mws, handler)` | 任意方法 |
214
+ | `app.use(mw)` | 全局中间件 |
215
+ | `app.ws(path, ...mws, handler)` | WebSocket |
216
+ | `app.graphql(path?, handler)` | GraphQL |
217
+ | `app.mount(path, subRouter)` | 挂载子路由 |
218
+ | `app.plugin(fn)` | 插件扩展现有 Router |
219
+ | `app.onError(handler)` | 全局错误处理 |
220
+ | `app.wsHub(hub)` | 注入自定义 Hub(多进程 WebSocket) |
221
+ | `app.onClose(closeable)` | 注册关闭回调 |
222
+ | `app.routes()` | 打印路由表 |
223
+ | `app.close()` | 关闭所有注册的 Closeable 资源 |
224
+
225
+ ---
226
+
227
+ ## serve — HTTP 服务器
132
228
 
133
229
  ```ts
134
230
  import { serve, Router } from 'weifuwu'
135
231
 
136
- const router = new Router()
137
- serve(router, { port: 3000 })
232
+ const app = new Router()
233
+ const server = serve(app, { port: 3000 })
234
+
235
+ // 等待就绪
236
+ await server.ready
237
+ console.log(server.port) // 实际端口
238
+
239
+ // 停止
240
+ await server.close()
241
+ // 或
242
+ await server.stop(2000) // 超时毫秒
138
243
  ```
139
244
 
140
- | 参数 | 类型 | 默认值 | 说明 |
245
+ | 选项 | 类型 | 默认值 | 说明 |
141
246
  |------|------|--------|------|
142
- | `port` | `number` | `0`(随机端口) | 监听端口 |
247
+ | `port` | `number` | `0`(随机) | 监听端口 |
143
248
  | `hostname` | `string` | `'0.0.0.0'` | 监听地址 |
144
- | `timeout` | `number` | `30000` | 连接超时(ms)|
145
- | `maxBodySize` | `number` | `10MB` | 请求体上限 |
249
+ | `signal` | `AbortSignal` | | 通过信号停止 |
250
+ | `maxBodySize` | `number` | `10MB` | 请求体上限(0=无限) |
251
+ | `timeout` | `number` | `30000` | Socket 超时(ms) |
252
+ | `keepAliveTimeout` | `number` | `5000` | Keep-Alive 超时 |
253
+ | `headersTimeout` | `number` | `6000` | 请求头超时 |
146
254
  | `shutdown` | `boolean` | `true` | 自动注册 SIGTERM/SIGINT |
147
255
 
148
- ### cors —— CORS 中间件
256
+ | 属性/方法 | 类型/签名 | 说明 |
257
+ |-----------|----------|------|
258
+ | `server.port` | `number` | 实际监听端口(未就绪时 0) |
259
+ | `server.hostname` | `string` | 监听地址 |
260
+ | `server.ready` | `Promise<void>` | 服务器就绪 |
261
+ | `server.close(timeoutMs?)` | `() => Promise<void>` | 优雅关闭 |
262
+ | `server.stop(timeoutMs?)` | `() => Promise<void>` | `close` 别名 |
263
+
264
+ `sig-server` 自动注册 SIGTERM/SIGINT → `server.closeAllConnections()` → `router.close()` → `process.exit(0)`。
265
+
266
+ ---
267
+
268
+ ## cors — CORS 中间件
149
269
 
150
270
  ```ts
271
+ import { cors } from 'weifuwu'
272
+
273
+ // 默认:允许所有来源
151
274
  app.use(cors())
152
275
 
276
+ // 自定义
153
277
  app.use(cors({
154
- origin: ['https://app.example.com'],
278
+ origin: 'https://app.example.com',
155
279
  methods: ['GET', 'POST'],
280
+ allowedHeaders: ['Content-Type', 'Authorization'],
281
+ credentials: true,
282
+ exposedHeaders: ['X-Total-Count'],
283
+ maxAge: 86400,
156
284
  }))
157
285
  ```
158
286
 
159
- | 参数 | 默认值 |
160
- |------|--------|
161
- | `origin` | `*` |
162
- | `methods` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` |
163
- | `allowedHeaders` | `Content-Type, Authorization` |
287
+ | 选项 | 类型 | 默认值 | 说明 |
288
+ |------|------|--------|------|
289
+ | `origin` | `string \| string[] \| (origin) => string` | `'*'` | `credentials: true` 时自动回显请求 origin |
290
+ | `methods` | `string[]` | `GET,HEAD,PUT,PATCH,POST,DELETE` | 允许的方法 |
291
+ | `allowedHeaders` | `string[]` | `Content-Type, Authorization` | 允许的请求头 |
292
+ | `exposedHeaders` | `string[]` | — | 暴露的响应头 |
293
+ | `credentials` | `boolean` | — | 是否允许凭据 |
294
+ | `maxAge` | `number` | — | 预检缓存秒数 |
295
+
296
+ ---
164
297
 
165
- ### serveStatic —— 静态文件
298
+ ## serveStatic 静态文件服务
166
299
 
167
300
  ```ts
168
301
  import { serveStatic } from 'weifuwu'
169
302
 
170
- // 单一路径
171
- app.get('/static/*', serveStatic('./public'))
303
+ // 作为全局中间件(未匹配到文件时走下一个中间件)
304
+ app.use(serveStatic('./public'))
172
305
 
173
- // 多目录
174
- app.get('/uploads/*', serveStatic('./uploads'))
306
+ // 或挂载到特定路径
307
+ app.get('/assets/*', serveStatic('./assets', {
308
+ index: 'index.html',
309
+ maxAge: 31536000,
310
+ immutable: true,
311
+ }))
175
312
  ```
176
313
 
177
- ### postgres —— PostgreSQL
314
+ | 选项 | 类型 | 默认值 | 说明 |
315
+ |------|------|--------|------|
316
+ | `index` | `string` | `'index.html'` | 目录索引文件名 |
317
+ | `maxAge` | `number` | `0` | Cache-Control max-age(秒) |
318
+ | `immutable` | `boolean` | — | 添加 `immutable` 指令(需 maxAge) |
319
+
320
+ 特性:
321
+ - ETag/304 缓存协商(`if-none-match` + `if-modified-since`)
322
+ - MIME 类型自动检测(支持 30+ 扩展名)
323
+ - 目录遍历保护(`..` / symlink 逃逸 → 403)
324
+ - 目录自动跳转到 index 文件
325
+
326
+ ---
327
+
328
+ ## postgres — PostgreSQL 客户端
178
329
 
179
330
  ```ts
180
- import { postgres } from 'weifuwu'
331
+ import { postgres, MIGRATIONS_TABLE } from 'weifuwu'
181
332
 
333
+ // 注入 ctx.sql — postgres.js 客户端
182
334
  app.use(postgres())
183
335
 
184
- // 然后在 handler 中使用 ctx.sql
336
+ // 使用 ctx.sql
185
337
  app.get('/users', async (req, ctx) => {
186
- const users = await ctx.sql`SELECT * FROM users`
338
+ const users = await ctx.sql`SELECT * FROM users WHERE active = ${true}`
187
339
  return Response.json(users)
188
340
  })
341
+
342
+ // 事务
343
+ app.post('/transfer', async (req, ctx) => {
344
+ const result = await ctx.sql.begin(async sql => {
345
+ await sql`UPDATE accounts SET balance = balance - 100 WHERE id = 1`
346
+ await sql`UPDATE accounts SET balance = balance + 100 WHERE id = 2`
347
+ })
348
+ return Response.json({ ok: true })
349
+ })
189
350
  ```
190
351
 
191
352
  | 选项 | 类型 | 默认值 | 说明 |
192
353
  |------|------|--------|------|
193
354
  | `url` | `string` | `DATABASE_URL` 环境变量 | 连接字符串 |
194
355
  | `max` | `number` | `10` | 连接池大小 |
356
+ | `idleTimeout` | `number` | `30` | 空闲连接超时(秒)|
357
+ | `maxLifetime` | `number` | `3600` | 连接最大生存时间(秒)|
195
358
 
196
- ### redis —— Redis
359
+ | ctx 注入 | 类型 | 说明 |
360
+ |----------|------|------|
361
+ | `ctx.sql` | `postgres.Sql` | postgres.js 客户端(模板标签)|
362
+ | `ctx.sql.close()` | `() => Promise<void>` | 关闭连接池 |
363
+
364
+ ```ts
365
+ // 关闭
366
+ const pg = postgres()
367
+ app.use(pg)
368
+ // 关闭时框架自动调用 pg.close()
369
+ ```
370
+
371
+ ---
372
+
373
+ ## redis — Redis 客户端
197
374
 
198
375
  ```ts
199
376
  import { redis } from 'weifuwu'
200
377
 
201
378
  app.use(redis())
202
379
 
203
- // 使用 ctx.redis
204
- app.get('/cache', async (req, ctx) => {
205
- const cached = await ctx.redis.get('key')
206
- return Response.json({ cached })
380
+ app.get('/cache/:key', async (req, ctx) => {
381
+ const val = await ctx.redis.get(ctx.params.key)
382
+ if (!val) return Response.json({ miss: true })
383
+ return Response.json({ value: val })
384
+ })
385
+
386
+ app.post('/cache/:key', async (req, ctx) => {
387
+ const { value } = await req.json()
388
+ await ctx.redis.set(ctx.params.key, JSON.stringify(value), 'EX', 3600)
389
+ return Response.json({ ok: true })
207
390
  })
208
391
  ```
209
392
 
210
393
  | 选项 | 类型 | 默认值 | 说明 |
211
394
  |------|------|--------|------|
212
395
  | `url` | `string` | `REDIS_URL` 环境变量 | 连接字符串 |
396
+ | `options` | `RedisOptions` | — | ioredis 配置选项 |
397
+
398
+ | ctx 注入 | 类型 | 说明 |
399
+ |----------|------|------|
400
+ | `ctx.redis` | `ioredis.Redis` | ioredis 实例 |
401
+ | `ctx.redis.close()` | `() => Promise<void>` | 关闭连接 |
213
402
 
214
- ### ui —— SSR + SPA 渲染
403
+ 支持全部 ioredis API:`get`, `set`, `del`, `hget`, `hset`, `lpush`, `publish` 等。
404
+
405
+ ---
406
+
407
+ ## ui — SSR 渲染 + JS/CSS 编译
215
408
 
216
409
  ```ts
410
+ import { ui } from 'weifuwu'
411
+
217
412
  app.use(ui())
413
+ ```
414
+
415
+ | ctx 注入 | 签名 | 说明 |
416
+ |----------|------|------|
417
+ | `ctx.ui.html` | `` (strings, ...values) => Response `` | HTML 模板 (转义防 XSS) |
418
+ | `ctx.ui.html.unsafe(str)` | `(string) => string` | 插入原始 HTML |
419
+ | `ctx.ui.js(entryPath)` | `(string) => Promise<Response>` | esbuild 编译 TSX → JS bundle |
420
+ | `ctx.ui.css(entryPath)` | `(string) => Promise<Response>` | 读取/编译 CSS(自动 PostCSS + Tailwind) |
218
421
 
219
- // SSR 页面
220
- app.get('/page', async (req, ctx) => ctx.ui.html`
221
- <h1>${title}</h1>
222
- <p>${body}</p>
422
+ ### ctx.ui.html — HTML 模板
423
+
424
+ 模板插值自动转义(`& < > "` → 实体),防 XSS:
425
+
426
+ ```ts
427
+ app.get('/page', (req, ctx) => ctx.ui.html`
428
+ <h1>${title}</h1> <!-- 自动转义 -->
429
+ <div>${ctx.ui.html.unsafe(richHtml)}</div> <!-- 不转义 -->
223
430
  `)
431
+ ```
224
432
 
225
- // 动态 JS 编译(esbuild,零构建步骤)
226
- app.get('/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
433
+ ### ctx.ui.js — 编译 TSX → JS
227
434
 
228
- // CSS 编译(PostCSS + Tailwind)
229
- app.get('/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
435
+ ```ts
436
+ app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx'))
230
437
  ```
231
438
 
232
- | 方法 | 用途 |
233
- |------|------|
234
- | `ctx.ui.html\`...\`` | 渲染 HTML 模板(转义变量防 XSS) |
235
- | `ctx.ui.html.unsafe(str)` | 插入原始 HTML |
236
- | `ctx.ui.js(entryPath)` | 编译 TSX → JS bundle |
237
- | `ctx.ui.css(entryPath)` | 编译 CSS(PostCSS + Tailwind) |
439
+ 使用 esbuild 编译:
440
+ - `bundle: true`, `format: 'esm'`, `platform: 'browser'`
441
+ - `jsx: 'automatic'`, `jsxImportSource: 'weifuwu/client'`
442
+ - mtime 缓存验证(开发时编辑文件后自动失效)
238
443
 
239
- ### graphql —— GraphQL
444
+ ### ctx.ui.css CSS 编译
240
445
 
241
446
  ```ts
242
- app.graphql({
243
- schema: `type Query { hello: String }`,
244
- resolvers: { Query: { hello: () => 'world' } },
447
+ app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css'))
448
+ ```
449
+
450
+ - 纯 CSS 文件直接返回
451
+ - 检测到 `postcss` + `@tailwindcss/postcss` 时自动编译 Tailwind CSS
452
+ - 带 mtime 缓存验证
453
+
454
+ ---
455
+
456
+ ## graphql — GraphQL 端点
457
+
458
+ ```ts
459
+ import type { GraphQLHandler } from 'weifuwu'
460
+
461
+ const handler: GraphQLHandler = async (req, ctx) => ({
462
+ schema: `
463
+ type Query {
464
+ hello: String
465
+ users: [User]
466
+ }
467
+ type User { id: ID, name: String }
468
+ `,
469
+ resolvers: {
470
+ Query: {
471
+ hello: () => 'world',
472
+ users: () => [{ id: 1, name: 'Alice' }],
473
+ },
474
+ },
475
+ rootValue: {},
476
+ context: (req, ctx) => ({ user: ctx.user }),
245
477
  graphiql: true,
478
+ maxDepth: 10,
479
+ timeout: 30000,
246
480
  })
481
+
482
+ // 挂载到 /
483
+ app.graphql(handler)
484
+
485
+ // 或挂载到自定义路径
486
+ app.graphql('/graphql', handler)
247
487
  ```
248
488
 
249
- ### WebSocket
489
+ | 选项 | 类型 | 默认值 | 说明 |
490
+ |------|------|--------|------|
491
+ | `schema` | `string \| GraphQLSchema` | — | SDL 字符串或 Schema 对象 |
492
+ | `resolvers` | `any` | — | 解析器(schema 为字符串时必填)|
493
+ | `rootValue` | `any` | — | 根值 |
494
+ | `context` | `(req, ctx) => object` | — | 上下文工厂 |
495
+ | `graphiql` | `boolean` | — | 启用 GraphiQL IDE |
496
+ | `maxDepth` | `number` | `10` | 查询深度限制(0=关闭)|
497
+ | `timeout` | `number` | `30000` | 执行超时(ms,0=关闭)|
498
+
499
+ GET 请求支持 query 参数查询;POST 支持 JSON body。启用 `graphiql: true` 时,GET 无 `?query=` 参数返回 GraphiQL IDE 页面。
500
+
501
+ ---
502
+
503
+ ## WebSocket
250
504
 
251
505
  ```ts
252
- app.ws('/ws', {
253
- open(ws, ctx) { ws.send('connected') },
254
- message(ws, ctx, data) { /* data: string | Buffer */ },
255
- close(ws, ctx) { /* cleanup */ },
256
- error(ws, ctx, err) { /* log */ },
506
+ app.ws('/chat/:room', {
507
+ open(ws, ctx) {
508
+ ws.send(`欢迎加入 ${ctx.params.room} 房间`)
509
+ ctx.hub?.join(ctx.params.room, ws)
510
+ },
511
+ message(ws, ctx, data) {
512
+ // data: string | Buffer
513
+ ctx.hub?.send(ctx.params.room, `用户: ${data}`)
514
+ },
515
+ close(ws, ctx) {
516
+ ctx.hub?.leave(ws)
517
+ },
518
+ error(ws, ctx, error) {
519
+ console.error('WS error:', error)
520
+ },
257
521
  })
258
522
  ```
259
523
 
260
- ### 错误处理
524
+ | 回调 | 参数 | 说明 |
525
+ |------|------|------|
526
+ | `open(ws, ctx)` | `WebSocket`, `Context` | 连接建立 |
527
+ | `message(ws, ctx, data)` | `WebSocket`, `Context`, `string \| Buffer` | 收到消息 |
528
+ | `close(ws, ctx)` | `WebSocket`, `Context` | 连接关闭 |
529
+ | `error(ws, ctx, error)` | `WebSocket`, `Context`, `Error` | 错误 |
530
+
531
+ ### Hub — WebSocket 房间
261
532
 
262
533
  ```ts
263
- app.onError((err, req, ctx) => {
264
- if (err instanceof HttpError) {
265
- return new Response(err.message, { status: err.status })
266
- }
267
- console.error(err)
268
- return new Response('Internal Server Error', { status: 500 })
534
+ // 注入 hub ctx.hub
535
+ app.ws('/chat/:room', {
536
+ open(ws, ctx) { ctx.hub.join(ctx.params.room, ws) },
537
+ message(ws, ctx, data) { ctx.hub.send(ctx.params.room, String(data)) },
538
+ close(ws, ctx) { ctx.hub.leave(ws) },
269
539
  })
540
+
541
+ // 自定义 Hub(Redis 后端)
542
+ import type { Hub } from 'weifuwu'
543
+ const redisHub: Hub = { ... }
544
+ app.wsHub(redisHub)
270
545
  ```
271
546
 
272
- | 类/常量 | 说明 |
273
- |---------|------|
274
- | `HttpError` | HTTP 错误 `new HttpError(msg, status)` |
275
- | `DEFAULT_MAX_BODY` | 默认请求体上限 10MB |
547
+ | Hub 方法 | 说明 |
548
+ |----------|------|
549
+ | `join(key, ws)` | WebSocket 加入房间 |
550
+ | `leave(ws)` | WebSocket 离开所有房间 |
551
+ | `send(key, message)` | 向房间广播消息 |
552
+ | `close()` | 关闭 Hub |
276
553
 
277
- ### 后端类型
554
+ WebSocket 原生 `ws.send()` 发送,`ws.on('message', cb)` WebSocket 接收。
278
555
 
279
- `Context`, `Handler`, `Middleware`, `ErrorHandler`, `WebSocket`, `WebSocketHandler`, `ServeOptions`, `Server`, `CORSOptions`, `ServeStaticOptions`, `PostgresOptions`, `PostgresClient`, `PostgresInjected`, `RedisOptions`, `RedisClient`, `RedisInjected`, `GraphQLOptions`, `GraphQLHandler`
556
+ ---
557
+
558
+ ## HttpError — HTTP 错误
559
+
560
+ ```ts
561
+ import { HttpError } from 'weifuwu'
562
+
563
+ app.get('/secure', () => {
564
+ if (!condition) throw new HttpError('Forbidden', 403)
565
+ // serve() 自动捕获并返回对应状态码
566
+ })
567
+ ```
568
+
569
+ | API | 说明 |
570
+ |-----|------|
571
+ | `new HttpError(msg, status)` | 创建 HTTP 错误,name = 'HttpError' |
572
+ | `DEFAULT_MAX_BODY` | `10 * 1024 * 1024` (10MB) |
280
573
 
281
574
  ---
282
575
 
283
- ## 前端 (`weifuwu/client`)
576
+ ## 响应辅助函数
284
577
 
285
- 零外部依赖。组件模型:**纯函数 `(props, ctx) => VNode`**。
578
+ 消除 `Response.json(...)` 重复模式:
579
+
580
+ ```ts
581
+ import { ok, created, noContent, badRequest, unauthorized, forbidden, notFound, conflict, unprocessable, tooManyRequests, serverError, redirect } from 'weifuwu'
582
+
583
+ app.get('/users/:id', async (req, ctx) => {
584
+ const user = await findUser(ctx.params.id)
585
+ if (!user) return notFound('用户不存在')
586
+ return ok(user)
587
+ })
588
+
589
+ app.post('/users', async (req, ctx) => {
590
+ const body = await parseBody(req)
591
+ const user = await createUser(body)
592
+ return created(user)
593
+ })
594
+ ```
595
+
596
+ | 函数 | 状态码 | Content-Type |
597
+ |------|--------|-------------|
598
+ | `ok(data, init?)` | 200 | `application/json` |
599
+ | `created(data, init?)` | 201 | `application/json` |
600
+ | `noContent(init?)` | 204 | — |
601
+ | `badRequest(msg?)` | 400 | `application/json` |
602
+ | `unauthorized(msg?)` | 401 | `application/json` |
603
+ | `forbidden(msg?)` | 403 | `application/json` |
604
+ | `notFound(msg?)` | 404 | `application/json` |
605
+ | `conflict(msg?)` | 409 | `application/json` |
606
+ | `unprocessable(msg?)` | 422 | `application/json` |
607
+ | `tooManyRequests(msg?)` | 429 | `application/json` |
608
+ | `serverError(msg?)` | 500 | `application/json` |
609
+ | `redirect(url, status?)` | 302 (默认) | — |
610
+
611
+ ---
612
+
613
+ ## parseBody — 请求体解析
614
+
615
+ ```ts
616
+ import { parseBody } from 'weifuwu'
617
+
618
+ app.post('/users', async (req, ctx) => {
619
+ const body = await parseBody<{ name: string; email: string }>(req)
620
+ // JSON 解析失败自动 throw HttpError(400)
621
+ return ok(body)
622
+ })
623
+ ```
624
+
625
+ | 行为 | 说明 |
626
+ |------|------|
627
+ | JSON 格式正确 | 返回解析后的数据 |
628
+ | JSON 格式错误 | `throw new HttpError('Invalid JSON body', 400)` |
629
+ | GET/HEAD 请求 | 返回 `{}` |
630
+
631
+ ---
632
+
633
+ ## 后端类型
634
+
635
+ ```ts
636
+ import type { Context, Handler, Middleware, ErrorHandler, User, Closeable } from 'weifuwu'
637
+ import type { HttpError } from 'weifuwu'
638
+ import type { ServeOptions, Server } from 'weifuwu'
639
+ import type { Hub, WebSocketHandler } from 'weifuwu'
640
+ import type { WebSocket } from 'weifuwu'
641
+ import type { CORSOptions } from 'weifuwu'
642
+ import type { ServeStaticOptions } from 'weifuwu'
643
+ import type { PostgresOptions, PostgresClient, PostgresInjected } from 'weifuwu'
644
+ import type { RedisOptions, RedisClient, RedisInjected } from 'weifuwu'
645
+ import type { GraphQLOptions, GraphQLHandler } from 'weifuwu'
646
+ ```
647
+
648
+ | 类型 | 签名 | 说明 |
649
+ |------|------|------|
650
+ | `Context` | `interface` | `{ params, query, mountPath, user, loaderData?, env?, [key]: unknown }` |
651
+ | `Handler<T>` | `(req: Request, ctx: T) => Response \| Promise<Response>` | 请求处理器 |
652
+ | `Middleware<In, Out>` | `(req, ctx: In, next) => Response` | 中间件,含 `__meta` |
653
+ | `ErrorHandler<T>` | `(error, req, ctx: T) => Response` | 错误处理器 |
654
+ | `Closeable` | `interface` | `{ close(): Promise<void> }` |
655
+ | `User` | `interface` | `{ id, role?, tenant?, [key]: unknown }` |
656
+ | `HttpError` | `class` | `extends Error`,含 `status` 属性 |
657
+
658
+ ---
659
+
660
+ # 前端 API (`weifuwu/client`)
661
+
662
+ 零外部 npm 运行时依赖。组件模型:纯函数 `(props, ctx) => VNode`。
286
663
 
287
664
  构建配置(esbuild):
288
665
 
@@ -294,11 +671,46 @@ esbuild.build({
294
671
  })
295
672
  ```
296
673
 
297
- ### 组件
674
+ ---
675
+
676
+ ## createApp — 应用引导
677
+
678
+ ```tsx
679
+ import { createApp } from 'weifuwu/client'
680
+
681
+ const app = createApp()
682
+
683
+ // 注册中间件
684
+ app.use(middleware1)
685
+ app.use(middleware2)
686
+
687
+ // 挂载到 DOM
688
+ app.mount('#root', RootComponent)
689
+
690
+ // 获取当前 ctx
691
+ console.log(app.ctx)
692
+
693
+ // 销毁
694
+ app.destroy()
695
+ ```
696
+
697
+ | 方法 | 说明 |
698
+ |------|------|
699
+ | `createApp()` | 创建应用实例 |
700
+ | `app.use(mw)` | 注册 AppMiddleware |
701
+ | `app.mount(selector, RootComponent)` | 挂载到 DOM |
702
+ | `app.destroy()` | 卸载应用 |
703
+ | `app.ctx` | 当前 WfuiContext |
704
+
705
+ ---
706
+
707
+ ## 组件模型
298
708
 
299
709
  ```tsx
710
+ import type { Component, WfuiContext } from 'weifuwu/client'
711
+
300
712
  // 组件 = 纯函数 (props, ctx) => VNode
301
- function Greeting(props: { name: string }, _ctx: WfuiContext) {
713
+ const Greeting: Component<{ name: string }> = (props, ctx) => {
302
714
  return <div>Hello, {props.name}!</div>
303
715
  }
304
716
 
@@ -306,14 +718,61 @@ function Greeting(props: { name: string }, _ctx: WfuiContext) {
306
718
  <Greeting name="world" />
307
719
  ```
308
720
 
309
- ### 状态 —— 深度 Proxy
721
+ | 规则 | 说明 |
722
+ |------|------|
723
+ | 组件签名 | `(props: P, ctx: WfuiContext) => VNode \| null` |
724
+ | 无 class | 无 `this`,无实例方法 |
725
+ | 无 hook | 无 `useState` / `useEffect` / `useMemo` |
726
+ | 无生命周期 | 替代方案:`ref` 回调管理 mount/unmount |
727
+
728
+ ### JSX 工厂
729
+
730
+ ```tsx
731
+ // 由 esbuild 自动调用(jsxImportSource: 'weifuwu/client')
732
+ import { h, jsx, jsxs, jsxDEV, Fragment } from 'weifuwu/client'
733
+
734
+ // h 支持 variadic children
735
+ h('div', { class: 'x' }, child1, child2)
736
+
737
+ // Fragment
738
+ <><div>A</div><div>B</div></>
739
+ ```
740
+
741
+ | 导出 | 用途 |
742
+ |------|------|
743
+ | `h(type, props, ...children)` | hyperscript |
744
+ | `jsx` / `jsxs` / `jsxDEV` | JSX 编译目标 |
745
+ | `Fragment` | 片段 |
746
+
747
+ ### VNode 结构
748
+
749
+ ```ts
750
+ interface VNode {
751
+ type: string | Component | typeof Fragment
752
+ props: Record<string, any>
753
+ key?: string
754
+ el?: Node // 对应 DOM(框架内部)
755
+ _$?: Record<string, any> // 组件状态(框架内部)
756
+ _child?: any // 子 VNode 缓存
757
+ _cleanup?: (() => void) // ref 清理函数
758
+ }
759
+ ```
760
+
761
+ ---
762
+
763
+ ## 状态管理 — ctx.ui.$
764
+
765
+ `ctx.ui.$` 是**组件级深度 Proxy**:
310
766
 
311
- `ctx.ui.$` 是**深度 Proxy**:任何属性/数组/对象写入自动触发渲染,无需手动调用。
767
+ - `$.x = val` 自动标记 dirty → 下个微任务批量 VDOM patch
768
+ - `$.arr.push(val)` → Proxy 拦截数组突变 → 自动渲染
769
+ - `$.arr[0].x = y` → 深度拦截嵌套对象 → 自动渲染
770
+ - 每个组件实例有独立 Proxy(`vnode._$`),同名变量不冲突
312
771
 
313
772
  ```tsx
314
773
  function Counter(_props: {}, ctx: WfuiContext) {
315
774
  const $ = ctx.ui.$
316
- if (!ctx.ui.ready) $.count = 0
775
+ if (!ctx.ui.ready) $.count = 0 // 初始化(仅首次执行)
317
776
 
318
777
  return (
319
778
  <div>
@@ -325,14 +784,17 @@ function Counter(_props: {}, ctx: WfuiContext) {
325
784
  ```
326
785
 
327
786
  | API | 说明 |
328
- |------|------|
329
- | `ctx.ui.$` | 深度 Proxy,所有写入自动触发渲染 |
330
- | `$.x = val` | 顶层属性赋值 自动渲染 |
331
- | `$.items.push(val)` | 数组突变 自动渲染 |
332
- | `$.items[0].x = val` | 对象属性突变 → 自动渲染 |
333
- | `ctx.ui.dirty()` | 仅当绕过 Proxy 直接操作底层对象时使用 |
787
+ |-----|------|
788
+ | `ctx.ui.$` | 组件级深度 Proxy |
789
+ | `ctx.ui.ready` | `boolean`,首次执行后为 `true` |
790
+ | `ctx.ui.dirty()` | 手动标记脏状态(仅绕过 Proxy 时使用) |
791
+ | `ctx.ui.render()` | 手动触发渲染(框架内部使用,页面代码不用) |
334
792
 
335
- ### 条件与列表
793
+ **重要规则**:render 函数内部不写 `$`。`$` 的写入只在事件回调(onClick/onInput)、ref 回调、或 `if (!ctx.ui.ready)` 初始化块中进行。
794
+
795
+ ---
796
+
797
+ ## 条件与列表
336
798
 
337
799
  使用原生 JS 控制流:
338
800
 
@@ -341,23 +803,25 @@ function Counter(_props: {}, ctx: WfuiContext) {
341
803
  {cond ? <A /> : <B />}
342
804
  {cond && <A />}
343
805
 
344
- // 列表
345
- {items.map(item => <div key={item.id}>{item.name}</div>)}
806
+ // 列表 — 必须指定 key
807
+ {items.map(item => (
808
+ <div key={item.id}>{item.name}</div>
809
+ ))}
346
810
  ```
347
811
 
348
- ### 生命周期 —— ref 回调
812
+ ---
813
+
814
+ ## 生命周期 — ref 回调
349
815
 
350
- `ref` 回调在 mount 时触发,接收 DOM 元素。返回的清理函数在 unmount 时由框架保证调用。
816
+ `ref` 回调在 mount 时触发,接收 DOM 元素。返回的清理函数在 unmount 时由框架保证调用:
351
817
 
352
818
  ```tsx
353
- function MyComponent(_props: {}, ctx: WfuiContext) {
819
+ function Timer(_props: {}, ctx: WfuiContext) {
354
820
  return (
355
821
  <div ref={el => {
356
- // mount: el DOM 元素
357
- el.addEventListener('scroll', handler)
358
- // 返回清理函数,unmount 时框架保证调用
359
- return () => el.removeEventListener('scroll', handler)
360
- }} />
822
+ const timer = setInterval(() => console.log('tick'), 1000)
823
+ return () => { clearInterval(timer); console.log('cleanup') }
824
+ }}>Timer</div>
361
825
  )
362
826
  }
363
827
  ```
@@ -365,44 +829,40 @@ function MyComponent(_props: {}, ctx: WfuiContext) {
365
829
  | 场景 | 写法 |
366
830
  |------|------|
367
831
  | 事件监听 | `ref={el => { el.addEventListener(...); return () => el.removeEventListener(...) }}` |
368
- | 定时器 | `ref={el => { const t = setInterval(f, 1000); return () => clearInterval(t) }}` |
832
+ | 定时器 | `ref={el => { const t = setInterval(...); return () => clearInterval(t) }}` |
369
833
  | 第三方库 | `ref={el => { const c = new Chart(el); return () => c.destroy() }}` |
370
- | 仅 mount | `ref={el => { init(el) }}` |
371
-
372
- ### 应用 —— createApp
834
+ | 仅 mount | `ref={el => { init(el) }}`(无返回) |
373
835
 
374
- ```tsx
375
- import { createApp } from 'weifuwu/client'
376
-
377
- const app = createApp()
378
- app.use(middleware1)
379
- app.use(middleware2)
380
- app.mount('#root', RootComponent)
381
- app.destroy()
382
- ```
836
+ ---
383
837
 
384
- ### 路由 —— router + RouteView
838
+ ## router + RouteView — 前端路由
385
839
 
386
840
  ```tsx
387
- import { router, RouteView } from 'weifuwu/client'
841
+ import { createApp, router, RouteView } from 'weifuwu/client'
842
+ import type { RouteDef, WfuiContext } from 'weifuwu/client'
843
+
844
+ const routes: RouteDef[] = [
845
+ { path: '/', component: Home },
846
+ { path: '/users', component: UserList },
847
+ { path: '/users/:id', component: UserDetail },
848
+ ]
388
849
 
389
850
  createApp()
390
851
  .use(router({
391
- routes: [
392
- { path: '/', component: Home },
393
- { path: '/users', component: UserList },
394
- { path: '/users/:id', component: UserDetail },
395
- ],
396
- notFound: NotFound,
852
+ routes,
397
853
  mode: 'history', // 或 'hash'
854
+ notFound: NotFoundPage,
398
855
  }))
399
- .mount('#root', AppShell)
856
+ .mount('#root', () => <RouteView />)
857
+ ```
858
+
859
+ ### 嵌套布局
400
860
 
401
- // 嵌套布局
861
+ ```tsx
402
862
  const routes = [
403
863
  {
404
864
  path: '/dashboard',
405
- layout: DashboardLayout, // 持久布局
865
+ layout: DashboardLayout, // 持久布局(包含 RouteView)
406
866
  children: [
407
867
  { path: '/overview', component: Overview },
408
868
  { path: '/settings', component: Settings },
@@ -410,125 +870,277 @@ const routes = [
410
870
  },
411
871
  ]
412
872
 
413
- // 在 layout 中放置 RouteView 渲染子路由
414
873
  function DashboardLayout(_props: {}, ctx: WfuiContext) {
415
874
  return (
416
- <div class="wf-split">
417
- <aside>sidebar</aside>
418
- <main><RouteView /></main>
875
+ <div style="display:flex">
876
+ <aside>导航菜单</aside>
877
+ <main><RouteView /></main> {/* 渲染子路由 */}
419
878
  </div>
420
879
  )
421
880
  }
422
881
  ```
423
882
 
424
- | API | 说明 |
425
- |------|------|
426
- | `ctx.route.path` | 当前路由路径 |
427
- | `ctx.route.params` | URL 参数(如 `:id`)|
428
- | `ctx.route.query` | 查询参数对象 |
429
- | `ctx.app.navigate(path)` | 编程式导航 |
883
+ ### 编程式导航
884
+
885
+ ```tsx
886
+ // 在任意组件中
887
+ ctx.app?.navigate('/users/123?tab=profile')
888
+ ```
889
+
890
+ | ctx 注入 | 类型 | 说明 |
891
+ |----------|------|------|
892
+ | `ctx.route.path` | `string` | 当前路由路径 |
893
+ | `ctx.route.params` | `Record<string, string>` | URL 参数 |
894
+ | `ctx.route.query` | `Record<string, string>` | 查询参数 |
895
+ | `ctx.app.navigate(path)` | `(string) => void` | 编程式导航 |
896
+
897
+ | RouterOptions | 类型 | 默认值 | 说明 |
898
+ |---------------|------|--------|------|
899
+ | `routes` | `RouteDef[]` | — | 路由定义 |
900
+ | `mode` | `'history' \| 'hash'` | `'history'` | 路由模式 |
901
+ | `notFound` | `Component` | — | 404 页面 |
902
+
903
+ | RouteDef | 类型 | 说明 |
904
+ |----------|------|------|
905
+ | `path` | `string` | 路径(支持 `:param`) |
906
+ | `component` | `Component` | 页面组件 |
907
+ | `layout` | `Component` | 布局组件(内含 `<RouteView />`) |
908
+ | `children` | `RouteDef[]` | 子路由 |
909
+ | `auth` | `boolean` | 是否需要认证(配合 auth 中间件) |
910
+ | `title` | `string` | 页面标题(自动设置 `document.title`) |
430
911
 
431
- ### 中间件
912
+ ---
432
913
 
433
- **ws —— WebSocket 客户端**
914
+ ## api HTTP 客户端中间件
434
915
 
435
916
  ```tsx
436
- app.use(ws())
917
+ import { createApp, api } from 'weifuwu/client'
437
918
 
438
- // 发送消息
439
- ctx.ws?.send({ type: 'chat', body: 'hello' })
919
+ createApp()
920
+ .use(api({ baseURL: '/api' }))
921
+ .mount('#root', App)
440
922
 
441
- // 接收消息
442
- ctx.ws?.onMessage((msg) => { console.log(msg) })
923
+ // 在组件中使用
924
+ async function loadUsers(ctx: WfuiContext) {
925
+ const users = await ctx.api?.get<User[]>('/users')
926
+ const user = await ctx.api?.get<User>('/users/1')
927
+ const created = await ctx.api?.post<User>('/users', { name: 'Alice' })
928
+ await ctx.api?.put('/users/1', { name: 'Bob' })
929
+ await ctx.api?.patch('/users/1', { name: 'Bob' })
930
+ await ctx.api?.delete('/users/1')
931
+ }
443
932
  ```
444
933
 
445
- **api —— HTTP 客户端**
446
-
447
- ```tsx
448
- app.use(api({ baseURL: '/api' }))
934
+ | 选项 | 类型 | 默认值 | 说明 |
935
+ |------|------|--------|------|
936
+ | `baseURL` | `string` | `''` | API 基础路径 |
937
+ | `headers` | `Record<string, string>` | `{ 'Content-Type': 'application/json' }` | 默认请求头 |
938
+ | `onRequest` | `(req) => { url, init }` | — | 请求拦截器 |
939
+ | `onResponse` | `(res) => Promise<T>` | — | 响应拦截器 |
940
+ | `timeout` | `number` | `0`(无超时) | 请求超时(ms)|
941
+
942
+ | ctx.api 方法 | 签名 | 说明 |
943
+ |-------------|------|------|
944
+ | `api.get(url, opts?)` | `<T>(string, ApiRequestOptions?) => Promise<T>` | GET |
945
+ | `api.post(url, body?, opts?)` | `<T>(string, unknown?, ApiRequestOptions?) => Promise<T>` | POST |
946
+ | `api.put(url, body?, opts?)` | `<T>(string, unknown?, ApiRequestOptions?) => Promise<T>` | PUT |
947
+ | `api.patch(url, body?, opts?)` | `<T>(string, unknown?, ApiRequestOptions?) => Promise<T>` | PATCH |
948
+ | `api.delete(url, opts?)` | `<T>(string, ApiRequestOptions?) => Promise<T>` | DELETE |
449
949
 
450
- // 自动携带 Authorization header
451
- const user = await ctx.api?.get('/users/1')
452
- const res = await ctx.api?.post('/users', { name: 'Alice' })
950
+ ```ts
951
+ // 错误处理
952
+ try {
953
+ await ctx.api!.get('/users')
954
+ } catch (e) {
955
+ if (e instanceof ApiError) {
956
+ console.log(e.status, e.body) // e.g. 404, 'Not Found'
957
+ }
958
+ }
453
959
  ```
454
960
 
455
- | 方法 | 说明 |
456
- |------|------|
457
- | `ctx.api.get(url, opts?)` | GET |
458
- | `ctx.api.post(url, body?, opts?)` | POST |
459
- | `ctx.api.put(url, body?, opts?)` | PUT |
460
- | `ctx.api.patch(url, body?, opts?)` | PATCH |
461
- | `ctx.api.delete(url, opts?)` | DELETE |
961
+ `ApiError`:`{ status: number, body: string }`,继承 `Error`。
962
+
963
+ | ApiRequestOptions | 类型 | 说明 |
964
+ |-------------------|------|------|
965
+ | `headers` | `Record<string, string>` | 本次请求自定义请求头 |
966
+ | `signal` | `AbortSignal` | 取消请求 |
967
+
968
+ ---
462
969
 
463
- **auth —— 认证状态管理**
970
+ ## auth 认证中间件
464
971
 
465
972
  ```tsx
466
- app.use(auth())
973
+ import { createApp, auth } from 'weifuwu/client'
974
+
975
+ createApp()
976
+ .use(auth())
977
+ .mount('#root', App)
978
+
979
+ // 在组件中
980
+ function Profile(_props: {}, ctx: WfuiContext) {
981
+ if (!ctx.auth?.isLoggedIn) return <p>请登录</p>
982
+ return <p>欢迎, {ctx.auth?.user?.name}</p>
983
+ }
467
984
 
468
985
  // 登录
469
- ctx.auth?.login(token, user)
986
+ ctx.auth?.login(token, { id: 1, name: 'Alice' }, refreshToken)
470
987
 
471
988
  // 登出
472
989
  ctx.auth?.logout()
473
990
 
474
- // 状态
475
- if (ctx.auth?.isLoggedIn) { ... }
991
+ // 更新用户信息
992
+ ctx.auth?.setUser({ id: 1, name: 'Bob' })
993
+
994
+ // 刷新 token
995
+ await ctx.auth?.refresh() // → boolean
476
996
  ```
477
997
 
478
- | API | 说明 |
479
- |------|------|
480
- | `ctx.auth.token` | JWT token |
481
- | `ctx.auth.user` | 用户对象 |
482
- | `ctx.auth.isLoggedIn` | 是否已登录 |
483
- | `ctx.auth.login(token, user, refreshToken?)` | 登录 |
484
- | `ctx.auth.logout()` | 登出 |
998
+ | 选项 | 类型 | 默认值 | 说明 |
999
+ |------|------|--------|------|
1000
+ | `storage` | `Storage` | `localStorage` | 存储方式 |
1001
+ | `tokenKey` | `string` | `'weifuwu_token'` | Token 存储 key |
1002
+ | `userKey` | `string` | `'weifuwu_user'` | 用户信息存储 key |
1003
+ | `refreshTokenKey` | `string` | `'weifuwu_refresh'` | Refresh token 存储 key |
1004
+ | `refreshEndpoint` | `string` | `'/api/auth/refresh'` | 刷新端点 |
1005
+
1006
+ | ctx.auth | 类型 | 说明 |
1007
+ |----------|------|------|
1008
+ | `.token` | `string \| null` | JWT token |
1009
+ | `.user` | `any` | 用户对象 |
1010
+ | `.isLoggedIn` | `boolean` | 是否已登录(基于 token 存在) |
1011
+ | `.login(token, user, refreshToken?)` | `void` | 登录 |
1012
+ | `.logout()` | `void` | 登出(清除存储) |
1013
+ | `.setUser(user)` | `void` | 更新用户信息 |
1014
+ | `.refresh()` | `Promise<boolean>` | 刷新 token(自动检测过期) |
1015
+
1016
+ 启动时自动检测 token 是否过期(JWT `exp` 提前 30 秒),过期则自动调用 `refresh()`。
485
1017
 
486
- ### ErrorBoundary
1018
+ ---
1019
+
1020
+ ## ws — WebSocket 客户端中间件
487
1021
 
488
1022
  ```tsx
489
- import { ErrorBoundary } from 'weifuwu/client'
1023
+ import { createApp, ws } from 'weifuwu/client'
490
1024
 
491
- <ErrorBoundary fallback={<p>出错了</p>}>
492
- <UserProfile />
493
- </ErrorBoundary>
1025
+ createApp()
1026
+ .use(ws({ url: '/ws' }))
1027
+ .mount('#root', App)
1028
+
1029
+ // 发送消息
1030
+ ctx.ws?.send({ type: 'chat', body: 'hello' })
1031
+
1032
+ // 接收消息 — 返回 unsubscribe 函数
1033
+ const unsubscribe = ctx.ws?.onMessage((msg) => {
1034
+ console.log('收到:', msg)
1035
+ })
1036
+
1037
+ // 清理
1038
+ unsubscribe?.()
494
1039
  ```
495
1040
 
496
- ### 工具
1041
+ | 选项 | 类型 | 默认值 | 说明 |
1042
+ |------|------|--------|------|
1043
+ | `url` | `string` | `'/ws'` | WebSocket 连接地址 |
1044
+ | `reconnectInterval` | `number` | `3000` | 重连间隔(ms) |
1045
+ | `maxReconnect` | `number` | `10` | 最大重连次数 |
1046
+ | `pingInterval` | `number` | `30000` | 心跳发送间隔 |
1047
+ | `pingTimeout` | `number` | `10000` | 心跳超时断开 |
497
1048
 
498
- | 函数 | 用途 |
499
- |------|------|
500
- | `extendCtx(ctx, fields)` | 创建新 ctx,继承原 ctx getter |
1049
+ | ctx.ws | 类型 | 说明 |
1050
+ |--------|------|------|
1051
+ | `.send(msg)` | `(unknown) => void` | 发送 JSON 消息 |
1052
+ | `.onMessage(fn)` | `(fn) => () => void` | 订阅消息,返回 unsubscribe |
1053
+ | `.isConnected` | `boolean` | 连接状态 |
1054
+ | `.close()` | `() => void` | 断开连接 |
501
1055
 
502
- ### 国际化 — `ctx.i18n`
1056
+ 自动重连(指数退避)、心跳保活、JSON 序列化/反序列化。
503
1057
 
504
- `i18n()` 中间件注入 `ctx.i18n`,支持运行时语言切换:
1058
+ ---
505
1059
 
506
- ```ts
1060
+ ## i18n — 国际化中间件
1061
+
1062
+ ```tsx
507
1063
  import { createApp, i18n } from 'weifuwu/client'
508
1064
 
509
1065
  createApp()
510
1066
  .use(i18n({
511
1067
  locale: 'zh-CN',
512
- messages: { 'users.title': '用户管理' },
1068
+ messages: {
1069
+ 'title': '仪表盘',
1070
+ 'welcome': '欢迎, {name}',
1071
+ },
513
1072
  }))
514
- .mount('#root', () => <App />)
1073
+ .mount('#root', App)
515
1074
 
516
- // 页面中使用
517
- ctx.i18n?.t('users.title') // → '用户管理'
1075
+ // 组件中使用
1076
+ <h1>{ctx.i18n?.t('title')}</h1>
1077
+ <p>{ctx.i18n?.t('welcome')}</p>
518
1078
 
519
- // 运行时切换
520
- ctx.i18n?.setLocale('en-US') // → 自动触发重渲染
1079
+ // 运行时切换语言
1080
+ ctx.i18n?.setLocale('en-US')
1081
+ // → 自动触发全应用重渲染
521
1082
  ```
522
1083
 
523
- 内置语言包:`zh-CN`(默认)、`en-US`。组件文案(Button `加载中...`、FileUpload `点击或拖拽上传文件`)随语言自动切换。组件通过 `ctx.i18n?.components?.ComponentName.field` 读取,支持 `props.locale` 局部覆盖。
1084
+ | I18nOptions | 类型 | 默认值 | 说明 |
1085
+ |-------------|------|--------|------|
1086
+ | `locale` | `string` | `'zh-CN'` | 初始语言 |
1087
+ | `messages` | `Record<string, string>` | `{}` | 翻译键值对 |
1088
+ | `components` | `Record<string, Record<string, string>>` | `{}` | 组件文案覆盖 |
1089
+
1090
+ | ctx.i18n | 类型 | 说明 |
1091
+ |----------|------|------|
1092
+ | `.t(key, fallback?)` | `(string, string?) => string` | 翻译 |
1093
+ | `.locale` | `string` | 当前语言 |
1094
+ | `.setLocale(lang)` | `(string) => void` | 切换语言(触发重渲染) |
1095
+ | `.components` | `Record<string, Record<string, string>>` | 组件文案映射 |
1096
+
1097
+ 内置语言包:
524
1098
 
525
1099
  ```ts
526
- import { i18n, zhCN, enUS } from 'weifuwu/client'
1100
+ import { zhCN, enUS } from 'weifuwu/client'
527
1101
  ```
528
1102
 
529
- ### confirm —— 确认对话框
1103
+ - `zh-CN`:默认中文
1104
+ - `en-US`:英文
530
1105
 
531
- ```ts
1106
+ 组件文案(Button 的 `加载中...`、FileUpload 的 `点击或拖拽上传文件` 等)随语言自动切换。组件内部通过 `ctx.i18n?.components?.ComponentName.field` 读取。
1107
+
1108
+ 组件支持 `props.locale` 局部覆盖语言。
1109
+
1110
+ ---
1111
+
1112
+ ## ErrorBoundary — 错误边界
1113
+
1114
+ ```tsx
1115
+ import { ErrorBoundary } from 'weifuwu/client'
1116
+
1117
+ <ErrorBoundary fallback={<p>出错了,请刷新页面</p>}>
1118
+ <UserProfile />
1119
+ </ErrorBoundary>
1120
+
1121
+ // fallback 也可以是一个接收 error 的函数
1122
+ <ErrorBoundary fallback={({ error }) => (
1123
+ <div>
1124
+ <p>出错了: {String(error)}</p>
1125
+ <button onClick={() => ctx.ui.$.error = null}>重试</button>
1126
+ </div>
1127
+ )}>
1128
+ <UserProfile />
1129
+ </ErrorBoundary>
1130
+ ```
1131
+
1132
+ | ErrorBoundaryProps | 类型 | 默认值 | 说明 |
1133
+ |--------------------|------|--------|------|
1134
+ | `fallback` | `VNode \| ((props: { error }) => VNode) \| null` | `null` | 错误时渲染的内容 |
1135
+ | `children` | `any` | — | 子组件 |
1136
+
1137
+ 捕获子组件 render 时的错误 → `$.error` → 渲染 fallback。清除 `$.error` 即可重试。
1138
+
1139
+ ---
1140
+
1141
+ ## confirm — 确认对话框
1142
+
1143
+ ```tsx
532
1144
  import { createApp, confirm } from 'weifuwu/client'
533
1145
 
534
1146
  createApp()
@@ -536,25 +1148,37 @@ createApp()
536
1148
  .mount('#root', App)
537
1149
 
538
1150
  // 在组件中使用
539
- async function handleDelete(ctx: WfuiContext) {
540
- const ok = await ctx.confirm?.('确定删除?', {
541
- title: '确认',
1151
+ const $ = ctx.ui.$
1152
+ async function handleDelete() {
1153
+ const ok = await ctx.confirm?.('确定删除这条记录?', {
1154
+ title: '确认删除',
542
1155
  confirmText: '删除',
543
1156
  cancelText: '取消',
544
- variant: 'danger', // 'default' | 'danger'
1157
+ variant: 'danger', // 'primary' | 'danger'
545
1158
  })
546
- if (ok) { /* 执行 */ }
1159
+ if (ok) {
1160
+ $.deleted = true
1161
+ }
547
1162
  }
548
1163
  ```
549
1164
 
550
- - 直接 DOM 渲染(不依赖 VDOM)
551
- - 返回 `Promise<boolean>` — `await ctx.confirm(msg, opts?)`
552
- - 自动锁定背景滚动(ScrollLock)
553
- - ESC / 点击遮罩关闭
1165
+ | ConfirmOptions | 类型 | 默认值 | 说明 |
1166
+ |----------------|------|--------|------|
1167
+ | `title` | `string` | `'确认操作'` | 对话框标题 |
1168
+ | `confirmText` | `string` | `'确定'` | 确认按钮文字 |
1169
+ | `cancelText` | `string` | `'取消'` | 取消按钮文字 |
1170
+ | `variant` | `'primary' \| 'danger'` | `'primary'` | 按钮样式变体 |
554
1171
 
555
- ### ScrollLock / FocusTrap 工具
1172
+ - 直接 DOM 渲染(不经过 VDOM)
1173
+ - 返回 `Promise<boolean>`
1174
+ - ESC / 点击遮罩 → resolve(false)
1175
+ - 自动锁定背景滚动
556
1176
 
557
- ```ts
1177
+ ---
1178
+
1179
+ ## ScrollLock / FocusTrap
1180
+
1181
+ ```tsx
558
1182
  import { lockScroll, unlockScroll } from 'weifuwu/client'
559
1183
  import { trapFocus } from 'weifuwu/client'
560
1184
 
@@ -562,266 +1186,318 @@ import { trapFocus } from 'weifuwu/client'
562
1186
  lockScroll()
563
1187
  unlockScroll()
564
1188
 
565
- // 焦点陷阱,返回 cleanup 函数
566
- const cleanup = trapFocus(containerEl)
567
- // 组件卸载时
568
- cleanup() // 恢复焦点
1189
+ // 焦点陷阱 — 返回 cleanup 函数
1190
+ const cleanup = trapFocus(containerElement)
1191
+ cleanup() // 恢复之前的焦点
569
1192
  ```
570
1193
 
571
- - `lockScroll()`: 多层级可嵌套锁定,iOS Safari `position:fixed` 兼容
572
- - `trapFocus(el)`: Tab/Shift+Tab 在容器内循环,restore 之前焦点
573
-
574
- ### 前端类型
575
-
576
- `VNode`, `VNodeType`, `Component`, `WfuiContext`, `AppMiddleware`, `RouteDef`, `ApiClient`, `ApiOptions`, `ApiRequestOptions`, `ApiError`, `AuthClient`, `AuthOptions`, `ErrorBoundaryProps`, `I18nOptions`, `I18nState`, `LocalePackage`
1194
+ | API | 说明 |
1195
+ |-----|------|
1196
+ | `lockScroll()` | 锁定 body 滚动(iOS 兼容) |
1197
+ | `unlockScroll()` | 解锁滚动,恢复滚动位置 |
1198
+ | `trapFocus(el)` | Tab/Shift+Tab 在容器内循环,返回 cleanup |
577
1199
 
578
1200
  ---
579
1201
 
580
- ## 布局 & 主题 (`weifuwu/layout`)
1202
+ ## extendCtx 上下文扩展
581
1203
 
582
- 纯 CSS 布局原语 + 主题 Token。不绑定任何 JS 框架。
1204
+ ```tsx
1205
+ import { extendCtx } from 'weifuwu/client'
583
1206
 
584
- ```ts
585
- // 服务端编译
586
- app.get('/layout.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/dist/layout/weifuwu-layout.css'))
1207
+ // 在 AppMiddleware 中创建新 ctx,原 ctx getter 通过原型链继承
1208
+ function myMw(ctx: WfuiContext): WfuiContext {
1209
+ return extendCtx(ctx, { myField: 'value' })
1210
+ }
587
1211
  ```
588
1212
 
589
- ```html
590
- <!-- 或直接引入 -->
591
- <link rel="stylesheet" href="/layout.css">
1213
+ `extendCtx` 使用 `Object.create(ctx)` 保持原型链,再用 `Object.assign` 添加新字段。保证 getter 不被快照化。
1214
+
1215
+ ---
1216
+
1217
+ ## 前端类型
1218
+
1219
+ ```tsx
1220
+ import type { VNode, VNodeType, Component, WfuiContext, AppMiddleware, RouteDef } from 'weifuwu/client'
1221
+ import type { ApiClient, ApiOptions, ApiRequestOptions, ApiError } from 'weifuwu/client'
1222
+ import type { AuthClient, AuthOptions } from 'weifuwu/client'
1223
+ import type { ErrorBoundaryProps } from 'weifuwu/client'
1224
+ import type { I18nOptions, I18nState, LocalePackage } from 'weifuwu/client'
1225
+ import type { ConfirmOptions, ConfirmState } from 'weifuwu/client'
1226
+ import type { RouterOptions } from 'weifuwu/client'
592
1227
  ```
593
1228
 
594
- ### 使用示例
1229
+ | 类型 | 说明 |
1230
+ |------|------|
1231
+ | `VNode` | `{ type, props, key?, el?, _$?, _child?, _cleanup? }` |
1232
+ | `VNodeType` | `string \| Component \| typeof Fragment` |
1233
+ | `Component<P>` | `(props: P, ctx: WfuiContext) => VNode \| null` |
1234
+ | `WfuiContext` | `{ ui, route?, app?, ws?, api?, auth?, i18n?, confirm?, [key]: unknown }` |
1235
+ | `AppMiddleware` | `(ctx: WfuiContext) => WfuiContext` |
1236
+ | `RouteDef` | `{ path, component?, layout?, children?, auth?, title? }` |
1237
+ | `ApiClient` | `{ get, post, put, patch, delete }` |
1238
+ | `ApiError` | `class { status, body } extends Error` |
1239
+ | `AuthClient` | `{ token, user, isLoggedIn, login, logout, setUser, refresh }` |
1240
+ | `I18nOptions` | `{ locale?, messages?, components? }` |
1241
+ | `I18nState` | `{ locale, t, setLocale, components }` |
1242
+ | `ErrorBoundaryProps` | `{ fallback?, children? }` |
1243
+ | `ConfirmOptions` | `{ title?, confirmText?, cancelText?, variant? }` |
595
1244
 
596
- ```html
597
- <div class="wf-stack" style="--wf-gap: 24px">
598
- <div class="wf-split">
599
- <h2 style="color: var(--wf-color-text)">仪表盘</h2>
600
- <button style="background: var(--wf-color-primary); color: #fff; border-radius: var(--wf-radius)">+ 新建</button>
601
- </div>
602
- <div class="wf-row" style="--wf-gap: 16px">
603
- <div class="wf-fill wf-surface wf-stack" style="padding: 20px; background: var(--wf-color-bg); --wf-gap: 4px">
604
- <span style="color: var(--wf-color-text-secondary)">总用户</span>
605
- <span style="font-size: var(--wf-font-size-4xl); font-weight: var(--wf-font-weight-bold); color: var(--wf-color-text)">1,234</span>
606
- </div>
607
- </div>
608
- </div>
609
- ```
610
-
611
- ### 33 个布局原语
612
-
613
- | 类别 | 原语 | 含义 | CSS 实现 |
614
- |------|------|------|---------|
615
- | **排列** | `wf-stack` | 纵向堆叠 | `flex-direction: column + gap` |
616
- | | `wf-stack-reverse` | 反向堆叠 | `flex-direction: column-reverse` |
617
- | | `wf-row` | 横向排列 | `flex + flex-wrap + gap` |
618
- | | `wf-row-reverse` | 反向排列 | `flex-direction: row-reverse` |
619
- | | `wf-nowrap` | 不换行 | `flex-wrap: nowrap` |
620
- | | `wf-cluster` | 换行簇 | `flex-wrap: wrap + justify-content: center` |
621
- | **分布** | `wf-split` | 两端展开 | `justify-content: space-between` |
622
- | | `wf-center` | 居中 | `flex + center both axes` |
623
- | | `wf-right` | 靠右 | `justify-content: flex-end` |
624
- | | `wf-around` | 环绕 | `justify-content: space-around` |
625
- | | `wf-evenly` | 均匀 | `justify-content: space-evenly` |
626
- | **对齐** | `wf-top` | 顶部 | `align-items: flex-start` |
627
- | | `wf-bottom` | 底部 | `align-items: flex-end` |
628
- | | `wf-stretch` | 拉伸 | `align-items: stretch` |
629
- | **弹性** | `wf-fill` | 撑满剩余空间 | `flex: 1 + min-width: 0` |
630
- | | `wf-fixed` | 固定不伸缩 | `flex: none` |
631
- | | `wf-auto` | 按内容撑满 | `flex: auto` |
632
- | | `wf-shrink` | 可收缩 | `min-width: 0 + min-height: 0` |
633
- | **Z轴** | `wf-cover` | 全屏覆盖 | `position: fixed + inset: 0` |
634
- | | `wf-pop` | 浮动层 | `position: absolute` |
635
- | | `wf-anchor` | 锚点容器 | `position: relative` |
636
- | | `wf-layer` | 层级控制 | `position: relative + z-index` |
637
- | | `wf-sticky` | 粘性定位 | `position: sticky` |
638
- | **容器** | `wf-surface` | 基础面 | `border-radius + box-shadow + bg` |
639
- | | `wf-grid` | 二维网格 | `display: grid + --wf-cols` |
640
- | | `wf-container` | 宽度约束 | `max-width + margin: auto` |
641
- | | `wf-scroll` | 可滚动 | `overflow: auto` |
642
- | | `wf-clip` | 溢出裁剪 | `overflow: hidden` |
643
- | **显隐** | `wf-hidden` | 隐藏 | `display: none` |
644
- | | `wf-block` | 块级 | `display: block` |
645
- | | `wf-inline` | 行内 | `display: inline` |
646
- | | `wf-inline-block` | 行内块 | `display: inline-block` |
647
- | | `wf-contents` | 容器抹除 | `display: contents` |
648
-
649
- ### 72 个主题 Token
650
-
651
- | 类别 | Token 示例 | 值/层级 |
652
- |------|-----------|---------|
653
- | 品牌色 | `--wf-color-primary`, `--wf-color-primary-bg` | 品牌色 + Hover + 背景 |
654
- | 语义色 | `--wf-color-success/warning/error/info` | 各带 `-bg` 背景变体 |
655
- | 中性色 | `--wf-color-text/text-secondary/text-tertiary/text-disabled` | 4 级文字色 |
656
- | | `--wf-color-bg/bg-secondary/bg-tertiary` | 3 级背景色 |
657
- | | `--wf-color-border/border-light/border-dark` | 3 级边框色 |
658
- | 字体 | `--wf-font-sans`, `--wf-font-mono` | 字体族 |
659
- | 字号 | `--wf-font-size-xs/sm/base/lg/xl/2xl/3xl/4xl/5xl` | 9 级字号 |
660
- | 字重 | `--wf-font-weight-normal/medium/semibold/bold` | 4 级字重 |
661
- | 行高 | `--wf-line-height-tight/normal/relaxed` | 3 级行高 |
662
- | 字距 | `--wf-letter-spacing/wide/wider` | 3 级字符间距 |
663
- | 间距 | `--wf-space-xs/sm/md/lg/xl/2xl` | 8 级 margin/padding |
664
- | 间隔 | `--wf-gap-xs/sm/md/lg/xl/2xl` | 6 级 flex/grid gap |
665
- | 圆角 | `--wf-radius-sm/md/lg/xl` | 5 级 border-radius |
666
- | 阴影 | `--wf-shadow-sm/md/lg` | 4 级 box-shadow |
667
- | 边框 | `--wf-border-width` | 边框宽度 |
668
- | 聚焦 | `--wf-focus-ring` | 聚焦环(box-shadow)|
669
- | 动效 | `--wf-transition-duration/timing` | 过渡时长 + 曲线 |
670
- | 表单 | `--wf-accent-color`, `--wf-caret-color` | 控件主题色 + 光标色 |
671
- | 透明 | `--wf-opacity-disabled`, `--wf-opacity-overlay` | 禁用态 + 遮罩透明度 |
672
- | 层级 | `--wf-pop-z`, `--wf-cover-z` | z-index 层 |
1245
+ ---
673
1246
 
674
- ### 暗色模式
1247
+ # 组件库 (`weifuwu/components`)
675
1248
 
676
- 切换 `html` `data-theme` 属性即可自动切换全部主题色:
1249
+ 38 个 HTML 原语组件。每个是 `(props, ctx) => VNode` 纯函数,引用 `--wf-*` CSS 变量做主题。
677
1250
 
678
1251
  ```ts
679
- document.documentElement.setAttribute('data-theme', 'dark')
680
- // → 全部引用 var(--wf-*) 的元素自动变色
1252
+ import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
1253
+ import 'weifuwu/components/style.css'
681
1254
  ```
682
1255
 
683
- ### 基础元素默认样式
1256
+ ## 组件列表
1257
+
1258
+ ### 表单核心
1259
+
1260
+ | 组件 | 导入名 | 关键 Props | 说明 |
1261
+ |-----|--------|-----------|------|
1262
+ | Button | `Button` | `variant`, `size`, `loading`, `disabled`, `block`, `type` | 按钮 |
1263
+ | Input | `Input` | `variant`, `size`, `placeholder`, `disabled`, `error`, `prefix`, `suffix` | 输入框 |
1264
+ | Textarea | `Textarea` | `rows`, `resize`, `maxLength`, `error` | 文本域 |
1265
+ | Select | `Select` | `options: SelectOption[]`, `placeholder`, `searchable` | 下拉选择 |
1266
+
1267
+ ### 表单选择
1268
+
1269
+ | 组件 | 导入名 | 关键 Props | 说明 |
1270
+ |-----|--------|-----------|------|
1271
+ | Checkbox | `Checkbox` | `checked`, `label`, `indeterminate` | 复选框 |
1272
+ | Switch | `Switch` | `checked`, `size` | 开关 |
1273
+ | RadioGroup | `RadioGroup` | `options: RadioOption[]`, `value`, `name` | 单选组 |
1274
+ | Slider | `Slider` | `min`, `max`, `step`, `value`, `range` | 滑块 |
1275
+
1276
+ ### 表单增强
1277
+
1278
+ | 组件 | 导入名 | 关键 Props | 说明 |
1279
+ |-----|--------|-----------|------|
1280
+ | Form | `Form` | `onSubmit`, `validation` | 表单容器 |
1281
+ | Field | `Field` | `label`, `error`, `required`, `help` | 字段包装 |
1282
+ | FileUpload | `FileUpload` | `accept`, `multiple`, `maxSize`, `onFiles` | 文件上传 |
1283
+ | SearchInput | `SearchInput` | `value`, `placeholder`, `onSearch`, `loading` | 搜索框 |
1284
+ | ProgressBar | `ProgressBar` | `value`, `max`, `variant`, `size`, `label` | 进度条 |
1285
+
1286
+ ### 数据展示
1287
+
1288
+ | 组件 | 导入名 | 关键 Props | 说明 |
1289
+ |-----|--------|-----------|------|
1290
+ | Table | `Table` | `columns: TableColumn[]`, `data`, `loading`, `sortable`, `selectable` | 表格 |
1291
+ | Card | `Card` | `title`, `extra`, `shadow`, `padding` | 卡片 |
1292
+ | Badge | `Badge` | `variant: BadgeVariant`, `count`, `dot`, `max` | 徽标 |
1293
+ | Tag | `Tag` | `variant`, `closable`, `onClose` | 标签 |
1294
+ | Avatar | `Avatar` | `src`, `name`, `size`, `shape` | 头像 |
1295
+ | StatCard | `StatCard` | `title`, `value`, `trend`, `icon`, `variant` | 统计卡片 |
1296
+ | PageHeader | `PageHeader` | `title`, `subtitle`, `actions`, `onBack`, `breadcrumb` | 页面标题 |
1297
+ | Img | `Img` | `src`, `alt`, `fallback`, `lazy`, `fit` | 图片(含 fallback) |
1298
+ | InView | `InView` | `once`, `threshold`, `rootMargin`, `placeholder`, `onEnter` | 进入视窗后懒加载内容 |
1299
+
1300
+ ### 数据反馈
1301
+
1302
+ | 组件 | 导入名 | 关键 Props | 说明 |
1303
+ |-----|--------|-----------|------|
1304
+ | Modal | `Modal` | `open`, `title`, `onClose`, `width`, `footer`, `closable` | 模态框 |
1305
+ | Drawer | `Drawer` | `open`, `title`, `onClose`, `position: DrawerPosition`, `width` | 抽屉 |
1306
+ | Tooltip | `Tooltip` | `content`, `position: TooltipPosition`, `trigger` | 工具提示 |
1307
+ | Popover | `Popover` | `content`, `position: PopoverPosition`, `trigger` | 弹出层 |
1308
+ | Toast | `Toast` | `items: ToastItem[]`, `position`, `max` | 消息提示 |
1309
+ | Alert | `Alert` | `variant: AlertVariant`, `title`, `closable`, `icon` | 警告提示 |
1310
+ | Loading | `Loading` | `size`, `text`, `fullscreen` | 加载中 |
1311
+ | EmptyState | `EmptyState` | `title`, `description`, `action`, `icon` | 空状态 |
1312
+ | Skeleton | `Skeleton` | `variant: SkeletonVariant`, `rows`, `width`, `height` | 骨架屏 |
1313
+
1314
+ ### 导航组件
1315
+
1316
+ | 组件 | 导入名 | 关键 Props | 说明 |
1317
+ |-----|--------|-----------|------|
1318
+ | Breadcrumb | `Breadcrumb` | `items: BreadcrumbItem[]` | 面包屑 |
1319
+ | Tabs | `Tabs` | `items: TabItem[]`, `activeKey`, `onChange`, `type` | 标签页 |
1320
+ | Dropdown | `Dropdown` | `items: DropdownItem[]`, `trigger`, `placement` | 下拉菜单 |
1321
+ | Pagination | `Pagination` | `total`, `page`, `pageSize`, `onChange` | 分页 |
1322
+ | Steps | `Steps` | `items: StepItem[]`, `current`, `direction`, `size` | 步骤条 |
1323
+ | Accordion | `Accordion` | `items: AccordionItem[]`, `multiple`, `defaultActive` | 手风琴 |
1324
+
1325
+ ### 布局
1326
+
1327
+ | 组件 | 导入名 | 关键 Props | 说明 |
1328
+ |-----|--------|-----------|------|
1329
+ | Divider | `Divider` | `orientation`, `plain` | 分割线(水平/垂直/带文字) |
684
1330
 
685
- 引入 weifuwu/layout 后,以下 HTML 元素自动绑定主题 Token:
1331
+ ---
686
1332
 
687
- `body`, `h1`~`h6`, `p`, `a`, `label`, `small`, `input`, `textarea`, `select`, `button`, `table`, `th`, `td`, `hr`, `pre`, `code`
1333
+ # 布局系统 (`weifuwu/layout`)
688
1334
 
689
- ---
1335
+ 纯 CSS 布局原语 + 72 个主题 Token。不绑定任何 JS 框架。
690
1336
 
691
- ## 组件库 — `weifuwu/components`
1337
+ ```html
1338
+ <link rel="stylesheet" href="/node_modules/weifuwu/dist/layout/weifuwu-layout.css">
1339
+ ```
692
1340
 
693
- 36 个 **HTML 原语**,覆盖 90% 的 SaaS 页面 HTML 需求。每个组件是 `(props, ctx) => VNode` 纯函数,引用 `weifuwu/layout` 的 CSS 变量做主题。
1341
+ 或通过 `ctx.ui.css` 服务:
694
1342
 
695
1343
  ```ts
696
- import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
697
- import 'weifuwu/components/style.css'
1344
+ app.get('/layout.css', (req, ctx) => ctx.ui.css('./node_modules/weifuwu/dist/layout/weifuwu-layout.css'))
698
1345
  ```
699
1346
 
700
- ### 模块总览
1347
+ ## 35 个布局原语
701
1348
 
702
- | 类别 | 组件 | 用途 |
1349
+ | 类别 | 原语 | 效果 |
703
1350
  |------|------|------|
704
- | **表单核心** | `Button` `Input` `Textarea` `Select` | 4 个最常用的表单元素 |
705
- | **表单选择** | `Checkbox` `Switch` `RadioGroup` `Slider` | 选择类输入 |
706
- | **表单增强** | `Form` `Field` `FileUpload` `SearchInput` `ProgressBar` | 文件上传、搜索、进度 |
707
- | **数据展示** | `Table` `Card` `Badge` `Tag` `Avatar` `StatCard` `PageHeader` `Img` | 数据展示与页面标题 |
708
- | **数据反馈** | `Modal` `Drawer` `Tooltip` `Popover` `Toast` `Alert` `Loading` `EmptyState` `Skeleton` | 弹窗、抽屉、弹出层、骨架屏 |
709
- | **导航组件** | `Breadcrumb` `Tabs` `Dropdown` `Pagination` `Steps` `Accordion` | 面包屑、标签页、分页 |
710
- | **布局** | `Divider` | 分割线 (水平/垂直/带文字) |
1351
+ | **排列** | `wf-stack` | 纵向 flex + gap |
1352
+ | | `wf-stack-reverse` | 纵向反向 |
1353
+ | | `wf-row` | 横向 flex + wrap + gap |
1354
+ | | `wf-row-reverse` | 横向反向 |
1355
+ | | `wf-nowrap` | flex-wrap: nowrap |
1356
+ | | `wf-cluster` | 换行居中簇 |
1357
+ | **分布** | `wf-split` | justify-content: space-between |
1358
+ | | `wf-center` | 双轴居中 |
1359
+ | | `wf-right` | justify-content: flex-end |
1360
+ | | `wf-around` | space-around |
1361
+ | | `wf-evenly` | space-evenly |
1362
+ | **对齐** | `wf-top` | align-items: flex-start |
1363
+ | | `wf-bottom` | align-items: flex-end |
1364
+ | | `wf-stretch` | align-items: stretch |
1365
+ | **弹性** | `wf-fill` | flex: 1 + min-width: 0 |
1366
+ | | `wf-fixed` | flex: none |
1367
+ | | `wf-auto` | flex: auto |
1368
+ | | `wf-shrink` | min-width/height: 0 |
1369
+ | **Z轴** | `wf-cover` | position: fixed + inset: 0 |
1370
+ | | `wf-pop` | position: absolute |
1371
+ | | `wf-anchor` | position: relative |
1372
+ | | `wf-layer` | position: relative + z-index |
1373
+ | | `wf-sticky` | position: sticky |
1374
+ | **容器** | `wf-surface` | 基础面(border-radius + shadow + bg) |
1375
+ | | `wf-grid` | display: grid + --wf-cols |
1376
+ | | `wf-container` | max-width + margin: auto |
1377
+ | | `wf-scroll` | overflow: auto |
1378
+ | | `wf-clip` | overflow: hidden |
1379
+ | **显隐** | `wf-hidden` | display: none |
1380
+ | | `wf-block` | display: block |
1381
+ | | `wf-inline` | display: inline |
1382
+ | | `wf-inline-block` | display: inline-block |
1383
+ | | `wf-contents` | display: contents |
1384
+
1385
+ ## 72 个主题 Token
1386
+
1387
+ ```css
1388
+ /* 品牌色 */
1389
+ --wf-color-primary / --wf-color-primary-hover / --wf-color-primary-bg
1390
+ --wf-color-secondary / --wf-color-secondary-bg
1391
+
1392
+ /* 语义色 */
1393
+ --wf-color-success / --wf-color-success-bg
1394
+ --wf-color-warning / --wf-color-warning-bg
1395
+ --wf-color-error / --wf-color-error-bg
1396
+ --wf-color-info / --wf-color-info-bg
1397
+
1398
+ /* 文字色 */
1399
+ --wf-color-text / --wf-color-text-secondary / --wf-color-text-tertiary / --wf-color-text-disabled
1400
+
1401
+ /* 背景色 */
1402
+ --wf-color-bg / --wf-color-bg-secondary / --wf-color-bg-tertiary
1403
+
1404
+ /* 边框色 */
1405
+ --wf-color-border / --wf-color-border-light / --wf-color-border-dark
1406
+
1407
+ /* 字体 */
1408
+ --wf-font-sans / --wf-font-mono
1409
+
1410
+ /* 字号: xs sm base lg xl 2xl 3xl 4xl 5xl */
1411
+ --wf-font-size-*
1412
+
1413
+ /* 字重: normal medium semibold bold */
1414
+ --wf-font-weight-*
1415
+
1416
+ /* 行高: tight normal relaxed */
1417
+ --wf-line-height-*
1418
+
1419
+ /* 字距: normal wide wider */
1420
+ --wf-letter-spacing-*
1421
+
1422
+ /* 间距: xs sm md lg xl 2xl */
1423
+ --wf-space-*
1424
+
1425
+ /* 间隔: xs sm md lg xl 2xl */
1426
+ --wf-gap-*
1427
+
1428
+ /* 圆角: sm md lg xl */
1429
+ --wf-radius-*
1430
+
1431
+ /* 阴影: sm md lg */
1432
+ --wf-shadow-*
1433
+
1434
+ /* 其他 */
1435
+ --wf-border-width / --wf-focus-ring
1436
+ --wf-transition-duration / --wf-transition-timing
1437
+ --wf-accent-color / --wf-caret-color
1438
+ --wf-opacity-disabled / --wf-opacity-overlay
1439
+ --wf-pop-z / --wf-cover-z
1440
+ ```
711
1441
 
712
- ### 状态管理说明
1442
+ ### 暗色模式
713
1443
 
714
- `ctx.ui.$` 是**组件级**状态——每个组件实例有独立的 Proxy(基于 `vnode._$`),同名变量不会冲突:
1444
+ ```ts
1445
+ document.documentElement.setAttribute('data-theme', 'dark')
1446
+ // 所有 var(--wf-*) 自动切换
1447
+ ```
715
1448
 
716
- ```tsx
717
- // 组件 A
718
- const $ = ctx.ui.$
719
- $.open = true // 只影响组件 A
1449
+ ---
720
1450
 
721
- // 组件 B(在同一页面)
722
- const $ = ctx.ui.$
723
- $.open = false // 只影响组件 B,不影响 A
724
- ```
1451
+ # 核心概念
725
1452
 
726
- 跨组件共享状态请使用 `ctx` 直接挂载属性(延续中间件模式):
1453
+ ## 中间件模式(前后端一致)
727
1454
 
728
- ```ts
729
- ctx.theme = 'dark' // 所有组件可读
730
- ctx.toast?.success('成功') // 如已注入 toast 中间件
1455
+ ```
1456
+ 后端: app.use(cors())
1457
+ app.use(postgres())
1458
+ app.get('/users', (req, ctx) => { ctx.sql`SELECT *` })
1459
+ // ctx 已注入 ctx.sql
1460
+
1461
+ 前端: createApp()
1462
+ .use(api({ baseURL: '/api' }))
1463
+ .use(auth())
1464
+ .mount('#root', App)
1465
+ // ctx 已注入 ctx.api, ctx.auth
731
1466
  ```
732
1467
 
733
- ### 页面模板 — `docs/pages/`
1468
+ ## 状态管理
734
1469
 
735
- | 模板 | 文件 | 用途 |
1470
+ | 模式 | 后端 | 前端 |
736
1471
  |------|------|------|
737
- | **列表页** | `docs/pages/list-page.md` | 搜索 + 表格 + 分页 + 加载/空/错误状态 |
738
- | **表单页** | `docs/pages/form-page.md` | 表单 + 字段 + 校验 + 提交 |
739
- | **详情页** | `docs/pages/detail-page.md` | 信息展示 + Tabs + 操作 |
740
- | **设置页** | `docs/pages/settings-page.md` | 分组设置 + 独立保存 |
741
- | **仪表盘** | `docs/pages/dashboard-page.md` | KPI 卡片 + 图表 + 列表 |
742
- | **认证页** | `docs/pages/auth-page.md` | 居中卡片 + 表单 + 错误提示 |
743
- | **应用壳** | `docs/pages/app-layout.md` | 侧边栏 + 导航菜单 + 认证守卫 |
744
-
745
- 每个模板标注了「改这里」——复制代码后改 API 路径、字段定义、操作按钮、导航项即可使用。
746
-
747
- ---
748
-
749
- ## 环境变量
750
-
751
- | 变量 | 用途 | 默认值 |
752
- |------|------|--------|
753
- | `DATABASE_URL` | PostgreSQL 连接字符串 | — |
754
- | `REDIS_URL` | Redis 连接字符串 | — |
755
-
756
- ---
757
-
758
- ## 项目结构
759
-
760
- ```
761
- src/
762
- ├── index.ts # 统一导出
763
- ├── types.ts # 后端类型
764
- ├── request.ts # 请求解析
765
- ├── response.ts # 响应工具
766
- ├── core/
767
- │ ├── router.ts # HTTP 路由
768
- │ ├── serve.ts # HTTP 服务器
769
- │ └── ws.ts # WebSocket
770
- ├── middleware/
771
- │ ├── cors.ts
772
- │ └── static.ts
773
- ├── postgres/
774
- ├── redis/
775
- ├── graphql.ts
776
- ├── ui/ # SSR + JS/CSS 编译
777
- ├── client/ # 前端 VDOM 框架
778
- │ ├── index.ts
779
- │ ├── vnode.ts
780
- │ ├── app.ts
781
- │ ├── render.ts
782
- │ ├── router.ts
783
- │ ├── types.ts
784
- │ ├── error-boundary.ts
785
- │ └── middleware/
786
- │ ├── api.ts
787
- │ ├── auth.ts
788
- │ └── ws.ts
789
- ├── components/ # 29 个 HTML 原语组件
790
- │ ├── index.ts
791
- │ ├── Button/ # Button.ts + .css + .test.ts
792
- │ ├── Input/
793
- │ ├── ...
794
- │ └── PageHeader/
795
- └── layout/ # 纯 CSS 布局 + 主题
796
- ├── weifuwu-layout.css
797
- ├── _tokens.css
798
- ├── _dark.css
799
- ├── _base.css
800
- └── _*.css # 33 个原语
801
- ```
802
-
803
- ---
804
-
805
- ## 开发
1472
+ | 注入 | 中间件注入 ctx.field | 中间件注入 ctx.field |
1473
+ | 读取 | handler 读取 ctx | 组件读取 ctx |
1474
+ | 渲染 | 返回 Response | Proxy 自动触发 VDOM patch |
806
1475
 
807
- ```bash
808
- # 构建
809
- npm run build
1476
+ ## Closeable 接口
810
1477
 
811
- # 类型检查
812
- npm run typecheck
1478
+ 所有有状态模块(postgres、redis)实现 `close(): Promise<void>`,serve 关闭时自动调用。
813
1479
 
814
- # 测试
815
- npm test
1480
+ ---
816
1481
 
817
- # 发布
818
- node scripts/release.mjs <version>
819
- ```
1482
+ # 环境变量
1483
+
1484
+ | 变量 | 用途 | 模块 |
1485
+ |------|------|------|
1486
+ | `DATABASE_URL` | PostgreSQL 连接字符串 | `postgres()` |
1487
+ | `REDIS_URL` | Redis 连接字符串 | `redis()` |
1488
+
1489
+ ---
820
1490
 
821
- ## 设计原则
1491
+ # 开发命令
822
1492
 
823
- - **后端为工具箱** —— 提供 HTTP 路由、数据库、中间件原语,不捆绑业务模块
824
- - **全栈单包** —— `npm install weifuwu` = 后端 + 前端 + 布局
825
- - **Web 标准优先** —— 所有 handler 使用 `(req: Request, ctx: Context) => Response`
826
- - **零外部依赖** —— 前端和布局没有任何 npm 运行时依赖
827
- - **LLM 友好** —— 模块总览表 + 一致格式 + 清晰依赖链
1493
+ ```bash
1494
+ npm run build # 构建 dist/
1495
+ npm run typecheck # TypeScript 类型检查
1496
+ npm test # 运行 node --test
1497
+ node scripts/release.mjs <version> # 发布
1498
+ ```
1499
+
1500
+ ```bash
1501
+ # 测试前启动依赖服务
1502
+ docker compose up -d
1503
+ ```