weifuwu 0.36.0 → 0.36.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,45 +1,33 @@
1
1
  # weifuwu
2
2
 
3
- **全栈框架 — 后端 `(req, ctx) => Response` + 前端 `(props, ctx) => JSX`**
3
+ **全栈框架 — 后端 `(req, ctx) => Response` + 前端 `(props, ctx) => VNode` + 纯 CSS 布局系统**
4
4
 
5
5
  ```bash
6
6
  npm install weifuwu
7
7
  ```
8
8
 
9
- 一个包,无上游依赖。后端提供 HTTP 路由、数据库、中间件;前端提供 VDOM + Proxy 驱动的前端框架。
9
+ 一个包,零上游依赖。后端提供 HTTP 路由、数据库、中间件;前端提供 VDOM + Proxy 驱动的前端框架;布局提供纯 CSS 原语 + 主题 Token。
10
10
 
11
11
  ---
12
12
 
13
13
  ## 模块总览
14
14
 
15
- | 模块 | 导出 | 用途 | 依赖 |
16
- |------|------|------|------|
17
- | **Router** | `Router` | HTTP 路由 + 中间件链 + WebSocket + GraphQL | — |
18
- | **serve** | `serve` | HTTP 服务器 | `Router` |
19
- | **cors** | `cors` | CORS 跨域中间件 | `Router` |
20
- | **serveStatic** | `serveStatic` | 静态文件服务 | `Router` |
21
- | **postgres** | `postgres` | PostgreSQL 客户端 → `ctx.sql` | `Router` |
22
- | **redis** | `redis` | Redis 客户端 → `ctx.redis` | `Router` |
23
- | **ui** | `ui` | SSR 渲染 + 动态 JS 编译 → `ctx.ui.html/css/js` | `Router` |
24
- | **graphql** | `router.graphql()` | GraphQL 端点 | `Router` |
25
- | **client** | | 前端 VDOM + Proxy 框架 | — |
26
- | **layout** | | 纯 CSS 布局原语 + 主题 Token 系统 | — |
27
-
28
- **前端 `weifuwu/client` 模块总览:**
29
-
30
- | 类别 | 导出 | 用途 |
31
- |------|------|------|
32
- | **渲染引擎** | `VNode`, `Component` | 虚拟 DOM 节点与组件类型 | — |
33
- | **JSX 运行时** | `jsx`/`jsxs`/`jsxDEV`, `Fragment` | TSX 编译目标 | — |
34
- | **应用** | `createApp` | 中间件链 + 挂载 | — |
35
- | **路由** | `router`, `RouteView` | 嵌套布局路由 | — |
36
- | **中间件** | `ws`, `api`, `auth` | WebSocket / HTTP 客户端 / 认证状态 | — |
37
- | **工具** | `extendCtx` | ctx 扩展 | — |
38
- | **类型** | `WfuiContext`, `AppMiddleware`, `RouteDef`, `VNodeType`, `Component`, `ApiClient`, `AuthClient` | — | — |
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 框架 | — |
26
+ | **layout** | `weifuwu/layout` | 纯 CSS 布局原语 + 主题 Token | — |
39
27
 
40
28
  ---
41
29
 
42
- ## 核心理念:`ctx`
30
+ ## 核心理念
43
31
 
44
32
  前后端共享同一模式:**中间件向 `ctx` 注入字段,handler/组件从 `ctx` 读取。**
45
33
 
@@ -55,80 +43,44 @@ npm install weifuwu
55
43
 
56
44
  ---
57
45
 
58
- ## 快速开始 全栈应用
46
+ ## 快速开始 —— 全栈应用
59
47
 
60
48
  ```ts
61
- // server.ts 同一个 npm
62
- import { serve, Router, cors, ui } from 'weifuwu'
49
+ import { serve, Router, ui } from 'weifuwu'
63
50
 
64
51
  const app = new Router()
65
- app.use(cors())
66
52
  app.use(ui())
67
53
 
68
- // REST API
69
- app.get('/api/posts', async (req, ctx) => {
70
- const posts = [{ id: 1, title: 'Hello' }]
71
- return Response.json(posts)
72
- })
54
+ // 前端 TSX → JS bundle(动态编译,零构建步骤)
55
+ app.get('/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
73
56
 
74
- // WebSocket
75
- app.ws('/ws', {
76
- open(ws) { ws.send(JSON.stringify({ type: 'system', body: 'connected' })) },
77
- message(ws, ctx, data) {
78
- const msg = JSON.parse(data.toString())
79
- ws.send(JSON.stringify({ type: 'echo', body: msg.body }))
80
- },
81
- })
57
+ // CSS(PostCSS + Tailwind 编译)
58
+ app.get('/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
82
59
 
83
- // SPA 入口 — 动态编译前端(零构建步骤)
60
+ // SPA 入口
84
61
  app.get('/', async (req, ctx) => ctx.ui.html`
85
62
  <!DOCTYPE html>
86
- <html><body><div id="root"></div>
87
- <script src="/static/app.js"></script></body></html>
63
+ <html>
64
+ <head><link rel="stylesheet" href="/style.css"></head>
65
+ <body><div id="root"></div><script src="/app.js"></script></body>
66
+ </html>
88
67
  `)
89
- app.get('/static/app.js', async (req, ctx) => ctx.ui.js('./src/main.tsx'))
90
68
 
91
69
  serve(app, { port: 3000 })
92
70
  ```
93
71
 
94
72
  ```tsx
95
- // src/main.tsx 前端
96
- import { createApp, router, RouteView, ws, api, auth } from 'weifuwu/client'
73
+ // src/main.tsx —— 前端
74
+ import { createApp, router, RouteView } from 'weifuwu/client'
97
75
  import type { WfuiContext, RouteDef } from 'weifuwu/client'
98
76
 
99
- // 组件 = (props, ctx) => VNode
100
77
  function Home(_props: {}, ctx: WfuiContext) {
101
- const $ = ctx.ui.$
102
- if (!ctx.ui.ready) { $.items = [{ id: 1, text: 'hello' }] }
103
- return <div>{$.items.map((i: any) => <div key={i.id}>{i.text}</div>)}</div>
78
+ return <h1>Hello weifuwu</h1>
104
79
  }
105
80
 
106
- const app = createApp()
107
- app.use(api({ baseURL: '' }))
108
- app.use(auth())
109
- app.use(ws())
110
- app.use(router({
111
- routes: [
112
- { path: '/', component: Home },
113
- {
114
- path: '/dashboard',
115
- layout: DashboardLayout,
116
- ],
117
- },
118
- ],
119
- mode: 'hash',
120
- scrollRestoration: true,
121
- }))
122
- app.mount('#root', AppShell)
123
-
124
- function AppShell(_props: {}, ctx: WfuiContext) {
125
- return (
126
- <div>
127
- <nav>{/* ... */}</nav>
128
- <main><RouteView /></main>
129
- </div>
130
- )
131
- }
81
+ createApp()
82
+ .use(router({ routes: [{ path: '/', component: Home }], mode: 'history' }))
83
+ .mount('#root', () => <Home />)
132
84
  ```
133
85
 
134
86
  ---
@@ -138,110 +90,134 @@ function AppShell(_props: {}, ctx: WfuiContext) {
138
90
  ### Router
139
91
 
140
92
  ```ts
93
+ import { Router } from 'weifuwu'
94
+
141
95
  const app = new Router()
142
96
 
143
- // HTTP 方法
144
- app.get(path, ...handlers)
145
- app.post / put / delete / patch / head / options(path, ...handlers)
146
- app.all(path, ...handlers)
97
+ // 中间件
98
+ app.use(cors())
147
99
 
148
- // WebSocket / GraphQL
149
- app.ws(path, handler)
150
- app.graphql('/graphql', handler)
100
+ // 路由
101
+ app.get('/api/users', async (req: Request, ctx: Context) => {
102
+ return Response.json(users)
103
+ })
151
104
 
152
- // 中间件
153
- app.use(middleware)
154
- app.mount(prefix, subRouter)
155
- app.onError(handler)
105
+ app.post('/api/users', async (req: Request, ctx: Context) => {
106
+ const body = await req.json()
107
+ return Response.json({ id: 1, ...body }, { status: 201 })
108
+ })
156
109
 
157
- // 调试
158
- app.routes() // 列出所有路由
110
+ // URL 参数
111
+ app.get('/users/:id', async (req: Request, ctx: Context) => {
112
+ const id = ctx.params.id
113
+ return Response.json({ id, name: 'User ' + id })
114
+ })
159
115
  ```
160
116
 
161
- | 方法 | 参数 | 说明 |
117
+ | 方法 | 路由 | 说明 |
162
118
  |------|------|------|
163
- | `get/post/put/delete/patch/head/options` | `(path, ...handlers)` | 注册 HTTP 路由 |
164
- | `all` | `(path, ...handlers)` | 匹配所有方法 |
165
- | `ws` | `(path, handler)` | WebSocket 端点 |
166
- | `graphql` | `('/path', handler)` | GraphQL 端点 |
167
- | `use` | `(middleware)` | 全局中间件 |
168
- | `mount` | `(prefix, subRouter)` | 子路由挂载 |
169
- | `onError` | `(handler)` | 全局错误处理 |
170
- | `routes` | `()` | 返回路由列表数组 |
171
-
172
- ### serve — HTTP 服务器
119
+ | `app.get(path, handler)` | 任意 | GET 请求 |
120
+ | `app.post(path, handler)` | 任意 | POST 请求 |
121
+ | `app.put(path, handler)` | 任意 | PUT 请求 |
122
+ | `app.patch(path, handler)` | 任意 | PATCH 请求 |
123
+ | `app.delete(path, handler)` | 任意 | DELETE 请求 |
124
+ | `app.use(middleware)` | 全路由 | 中间件 |
125
+ | `app.ws(path, handler)` | 任意 | WebSocket |
126
+ | `app.graphql(options)` | 自动 | GraphQL 端点 |
127
+ | `app.onError(handler)` | 全局 | 错误处理 |
128
+
129
+ ### serve —— HTTP 服务器
173
130
 
174
131
  ```ts
175
- const srv = serve(app, { port: 3000 })
176
- await srv.stop() // 程序化停止
177
- // Ctrl+C / SIGTERM — 自动关闭所有连接后退出
132
+ import { serve, Router } from 'weifuwu'
133
+
134
+ const router = new Router()
135
+ serve(router, { port: 3000 })
178
136
  ```
179
137
 
180
- | 选项 | 默认 | 说明 |
181
- |------|------|------|
182
- | `port` | `0` | 监听端口 |
183
- | `hostname` | `'0.0.0.0'` | 监听地址 |
184
- | `maxBodySize` | `10MB` | 请求体上限 |
185
- | `timeout` | `30000` | Socket 超时 (ms) |
186
- | `shutdown` | `true` | 是否注册 SIGTERM/SIGINT 处理 |
138
+ | 参数 | 类型 | 默认值 | 说明 |
139
+ |------|------|--------|------|
140
+ | `port` | `number` | `0`(随机端口) | 监听端口 |
141
+ | `hostname` | `string` | `'0.0.0.0'` | 监听地址 |
142
+ | `timeout` | `number` | `30000` | 连接超时(ms)|
143
+ | `maxBodySize` | `number` | `10MB` | 请求体上限 |
144
+ | `shutdown` | `boolean` | `true` | 自动注册 SIGTERM/SIGINT |
187
145
 
188
- ### cors CORS 中间件
146
+ ### cors —— CORS 中间件
189
147
 
190
148
  ```ts
149
+ app.use(cors())
150
+
191
151
  app.use(cors({
192
- origin: ['https://example.com'],
193
- credentials: true,
152
+ origin: ['https://app.example.com'],
153
+ methods: ['GET', 'POST'],
194
154
  }))
195
155
  ```
196
156
 
197
- ### serveStatic 静态文件
157
+ | 参数 | 默认值 |
158
+ |------|--------|
159
+ | `origin` | `*` |
160
+ | `methods` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` |
161
+ | `allowedHeaders` | `Content-Type, Authorization` |
162
+
163
+ ### serveStatic —— 静态文件
198
164
 
199
165
  ```ts
200
- app.use(serveStatic('./public', { prefix: '/assets' }))
201
- ```
166
+ import { serveStatic } from 'weifuwu'
202
167
 
203
- | 选项 | 默认 | 说明 |
204
- |------|------|------|
205
- | `prefix` | `''` | URL 前缀 |
206
- | `index` | `'index.html'` | 默认首页 |
168
+ // 单一路径
169
+ app.get('/static/*', serveStatic('./public'))
170
+
171
+ // 多目录
172
+ app.get('/uploads/*', serveStatic('./uploads'))
173
+ ```
207
174
 
208
- ### postgres PostgreSQL
175
+ ### postgres —— PostgreSQL
209
176
 
210
177
  ```ts
178
+ import { postgres } from 'weifuwu'
179
+
211
180
  app.use(postgres())
212
- // → ctx.sql`SELECT * FROM users`
213
181
 
214
- app.use(postgres({ url: 'postgres://user:pass@host:5432/db' }))
215
- // 默认读取 DATABASE_URL 环境变量
182
+ // 然后在 handler 中使用 ctx.sql
183
+ app.get('/users', async (req, ctx) => {
184
+ const users = await ctx.sql`SELECT * FROM users`
185
+ return Response.json(users)
186
+ })
216
187
  ```
217
188
 
218
- | 选项 | 默认 | 说明 |
219
- |------|------|------|
220
- | `url` | `DATABASE_URL` | 连接字符串 |
221
- | `migrations` | `'./migrations'` | 迁移文件目录 |
222
-
223
- 依赖:需要 `postgres` npm 包。实现 `close(): Promise<void>`。
189
+ | 选项 | 类型 | 默认值 | 说明 |
190
+ |------|------|--------|------|
191
+ | `url` | `string` | `DATABASE_URL` 环境变量 | 连接字符串 |
192
+ | `max` | `number` | `10` | 连接池大小 |
224
193
 
225
- ### redis Redis
194
+ ### redis —— Redis
226
195
 
227
196
  ```ts
197
+ import { redis } from 'weifuwu'
198
+
228
199
  app.use(redis())
229
- // → ctx.redis.get('key')
230
- // ctx.redis.set('key', 'value')
231
- // 默认读取 REDIS_URL 环境变量
200
+
201
+ // 使用 ctx.redis
202
+ app.get('/cache', async (req, ctx) => {
203
+ const cached = await ctx.redis.get('key')
204
+ return Response.json({ cached })
205
+ })
232
206
  ```
233
207
 
234
- 依赖:需要 `ioredis` npm 包。实现 `close(): Promise<void>`。
208
+ | 选项 | 类型 | 默认值 | 说明 |
209
+ |------|------|--------|------|
210
+ | `url` | `string` | `REDIS_URL` 环境变量 | 连接字符串 |
235
211
 
236
- ### ui SSR + SPA 渲染
212
+ ### ui —— SSR + SPA 渲染
237
213
 
238
214
  ```ts
239
215
  app.use(ui())
240
216
 
241
217
  // SSR 页面
242
- app.get('/blog/:slug', async (req, ctx) => ctx.ui.html`
243
- <!DOCTYPE html>
244
- <html><body><h1>${post.title}</h1></body></html>
218
+ app.get('/page', async (req, ctx) => ctx.ui.html`
219
+ <h1>${title}</h1>
220
+ <p>${body}</p>
245
221
  `)
246
222
 
247
223
  // 动态 JS 编译(esbuild,零构建步骤)
@@ -255,17 +231,17 @@ app.get('/style.css', async (req, ctx) => ctx.ui.css('./src/style.css'))
255
231
  |------|------|
256
232
  | `ctx.ui.html\`...\`` | 渲染 HTML 模板(转义变量防 XSS) |
257
233
  | `ctx.ui.html.unsafe(str)` | 插入原始 HTML |
258
- | `ctx.ui.js(entryPath)` | 动态编译 TSX → JS bundle |
259
- | `ctx.ui.css(entryPath)` | 编译 CSS (PostCSS + Tailwind) |
234
+ | `ctx.ui.js(entryPath)` | 编译 TSX → JS bundle |
235
+ | `ctx.ui.css(entryPath)` | 编译 CSSPostCSS + Tailwind |
260
236
 
261
- ### graphql GraphQL
237
+ ### graphql —— GraphQL
262
238
 
263
239
  ```ts
264
- app.graphql(async (req, ctx) => ({
240
+ app.graphql({
265
241
  schema: `type Query { hello: String }`,
266
242
  resolvers: { Query: { hello: () => 'world' } },
267
243
  graphiql: true,
268
- }))
244
+ })
269
245
  ```
270
246
 
271
247
  ### WebSocket
@@ -295,7 +271,6 @@ app.onError((err, req, ctx) => {
295
271
  |---------|------|
296
272
  | `HttpError` | HTTP 错误 `new HttpError(msg, status)` |
297
273
  | `DEFAULT_MAX_BODY` | 默认请求体上限 10MB |
298
- | `MIGRATIONS_TABLE` | Postgres 迁移表名 |
299
274
 
300
275
  ### 后端类型
301
276
 
@@ -305,9 +280,10 @@ app.onError((err, req, ctx) => {
305
280
 
306
281
  ## 前端 (`weifuwu/client`)
307
282
 
308
- **2750 行源码,28 个运行时导出 + 19 个类型,零外部依赖。**
283
+ 零外部依赖。组件模型:**纯函数 `(props, ctx) => VNode`**。
309
284
 
310
285
  构建配置(esbuild):
286
+
311
287
  ```js
312
288
  esbuild.build({
313
289
  jsx: 'automatic',
@@ -316,428 +292,246 @@ esbuild.build({
316
292
  })
317
293
  ```
318
294
 
319
- ### 状态管理 — 深度 Proxy
320
-
321
- `ctx.ui.$` 是**深度 Proxy**:任何属性/数组/对象层面的写入自动触发渲染,无需手动调用。
295
+ ### 组件
322
296
 
323
297
  ```tsx
324
- const $ = ctx.ui.$
325
-
326
- // 顶层属性赋值
327
- $.count = 0 // → 自动渲染
328
- $.user = { name: 'alice' } // → 新值自动深度包装
329
-
330
- // 数组突变
331
- $.items.push(newItem) // → 自动渲染
332
- $.items.pop() // → 自动渲染
333
- $.items.splice(i, 1) // → 自动渲染
334
-
335
- // 对象属性突变(数组内部也支持)
336
- $.items[0].done = true // → 自动渲染
337
- $.msgs[idx].content += event.text // → 自动渲染
298
+ // 组件 = 纯函数 (props, ctx) => VNode
299
+ function Greeting(props: { name: string }, _ctx: WfuiContext) {
300
+ return <div>Hello, {props.name}!</div>
301
+ }
338
302
 
339
- // 不可变更新(同样支持)
340
- $.items = [...$.items, newItem]
303
+ // 使用
304
+ <Greeting name="world" />
341
305
  ```
342
306
 
343
- | API | 说明 |
344
- |------|------|
345
- | `ctx.ui.$` | 深度 Proxy 对象,所有写入自动触发渲染 |
346
- | `ctx.ui.dirty()` | ⚠ 极少需要 — 仅当绕过 Proxy 直接操作底层对象时 |
307
+ ### 状态 —— 深度 Proxy
347
308
 
348
- ### JSX 运行时
309
+ `ctx.ui.$` 是**深度 Proxy**:任何属性/数组/对象写入自动触发渲染,无需手动调用。
349
310
 
350
311
  ```tsx
351
- // 自动由 esbuild 调用(无需手动导入)
352
- <div class="foo">hello</div>
353
- <Fragment>...</Fragment>
312
+ function Counter(_props: {}, ctx: WfuiContext) {
313
+ const $ = ctx.ui.$
314
+ if (!ctx.ui.ready) $.count = 0
315
+
316
+ return (
317
+ <div>
318
+ <span>{$.count}</span>
319
+ <button onClick={() => $.count++}>+</button>
320
+ </div>
321
+ )
322
+ }
354
323
  ```
355
324
 
356
- | 导出 | 说明 |
325
+ | API | 说明 |
357
326
  |------|------|
358
- | `jsx` / `jsxs` / `jsxDEV` | JSX 编译目标 |
359
- | `Fragment` | `<></>` 片段组件 |
360
-
361
- **Signal 属性自动绑定:** `<input value={signalVal} />` 信号变化时只更新对应 DOM 属性。
327
+ | `ctx.ui.$` | 深度 Proxy,所有写入自动触发渲染 |
328
+ | `$.x = val` | 顶层属性赋值 自动渲染 |
329
+ | `$.items.push(val)` | 数组突变 → 自动渲染 |
330
+ | `$.items[0].x = val` | 对象属性突变 自动渲染 |
331
+ | `ctx.ui.dirty()` | 仅当绕过 Proxy 直接操作底层对象时使用 |
362
332
 
363
333
  ### 条件与列表
364
334
 
365
- 使用原生 JS 表达式,不需要框架 API:
335
+ 使用原生 JS 控制流:
366
336
 
367
337
  ```tsx
368
338
  // 条件
369
- {$.loading && <Loading />}
370
- {$.error ? <Error msg={$.error} /> : <Content />}
339
+ {cond ? <A /> : <B />}
340
+ {cond && <A />}
371
341
 
372
342
  // 列表
373
- {$.items.map(item => (
374
- <div key={item.id}>{item.name}</div>
375
- ))}
343
+ {items.map(item => <div key={item.id}>{item.name}</div>)}
376
344
  ```
377
345
 
378
- | 模式 | 说明 |
379
- |------|------|
380
- | `{cond && <A/>}` | 条件渲染(cond 为 true 时渲染 A) |
381
- | `{cond ? <A/> : <B/>}` | 二选一 |
382
- | `{arr.map(x => <div key={x.id}/>)}` | 列表渲染,必须加 `key` |
383
-
384
- ### 生命周期
346
+ ### 生命周期 —— ref 回调
385
347
 
386
- 使用 `ref` 回调替代生命周期钩子:
348
+ `ref` 回调在 mount 时触发,接收 DOM 元素。返回的清理函数在 unmount 时由框架保证调用。
387
349
 
388
350
  ```tsx
389
- <div ref={el => {
390
- if (el) {
391
- // 挂载:el DOM 元素
392
- fetchData()
393
- el.addEventListener('scroll', handler)
394
- }
395
- if (!el) {
396
- // 卸载:el 为 null
397
- cleanup()
398
- }
399
- }} />
351
+ function MyComponent(_props: {}, ctx: WfuiContext) {
352
+ return (
353
+ <div ref={el => {
354
+ // mount: el 是 DOM 元素
355
+ el.addEventListener('scroll', handler)
356
+ // 返回清理函数,unmount 时框架保证调用
357
+ return () => el.removeEventListener('scroll', handler)
358
+ }} />
359
+ )
360
+ }
400
361
  ```
401
362
 
402
- `ref` 在挂载时传入 DOM 元素,卸载时传入 `null`。一个回调覆盖 mount/unmount 两个场景。
363
+ | 场景 | 写法 |
364
+ |------|------|
365
+ | 事件监听 | `ref={el => { el.addEventListener(...); return () => el.removeEventListener(...) }}` |
366
+ | 定时器 | `ref={el => { const t = setInterval(f, 1000); return () => clearInterval(t) }}` |
367
+ | 第三方库 | `ref={el => { const c = new Chart(el); return () => c.destroy() }}` |
368
+ | 仅 mount | `ref={el => { init(el) }}` |
403
369
 
404
- ### 应用
370
+ ### 应用 —— createApp
405
371
 
406
372
  ```tsx
373
+ import { createApp } from 'weifuwu/client'
374
+
407
375
  const app = createApp()
408
376
  app.use(middleware1)
409
377
  app.use(middleware2)
410
- await app.mount('#root', AppShell)
378
+ app.mount('#root', RootComponent)
379
+ app.destroy()
411
380
  ```
412
381
 
413
- | 方法 | 说明 |
414
- |------|------|
415
- | `use(mw)` | 注册中间件,返回 `this` 支持链式 |
416
- | `mount(selector, RootComponent)` | 挂载到 DOM |
417
- | `hydrate(selector, Component, props?)` | 在 SSR 内容上附加组件 |
418
- | `ctx` | 当前上下文 |
419
-
420
- ### 路由
382
+ ### 路由 —— router + RouteView
421
383
 
422
384
  ```tsx
423
- // 路由定义
424
- const routes: RouteDef[] = [
425
- { path: '/', component: HomePage },
385
+ import { router, RouteView } from 'weifuwu/client'
386
+
387
+ createApp()
388
+ .use(router({
389
+ routes: [
390
+ { path: '/', component: Home },
391
+ { path: '/users', component: UserList },
392
+ { path: '/users/:id', component: UserDetail },
393
+ ],
394
+ notFound: NotFound,
395
+ mode: 'history', // 或 'hash'
396
+ }))
397
+ .mount('#root', AppShell)
398
+
399
+ // 嵌套布局
400
+ const routes = [
426
401
  {
427
402
  path: '/dashboard',
428
- layout: DashboardLayout, // 嵌套布局
403
+ layout: DashboardLayout, // 持久布局
429
404
  children: [
430
- { path: '/overview', component: Overview }, // 子路由
405
+ { path: '/overview', component: Overview },
431
406
  { path: '/settings', component: Settings },
432
407
  ],
433
408
  },
434
- { path: '/user/:id', component: UserPage, title: '用户' },
435
409
  ]
436
410
 
437
- // 注册路由中间件
438
- app.use(router({
439
- routes,
440
- notFound: NotFound,
441
- mode: 'hash', // 'hash' | 'history'
442
- scrollRestoration: true,
443
- }))
444
-
445
- // 路由出口 — 根层级和嵌套层级用同一个组件
446
- function AppShell() {
447
- return <main><RouteView /></main> // 根出口
448
- }
449
- function DashboardLayout() {
411
+ // 在 layout 中放置 RouteView 渲染子路由
412
+ function DashboardLayout(_props: {}, ctx: WfuiContext) {
450
413
  return (
451
- <div class="flex">
452
- <Sidebar />
453
- <main><RouteView /></main> // 嵌套出口(同一组件)
414
+ <div class="wf-split">
415
+ <aside>sidebar</aside>
416
+ <main><RouteView /></main>
454
417
  </div>
455
418
  )
456
419
  }
457
420
  ```
458
421
 
459
- | RouteDef 字段 | 类型 | 说明 |
460
- |---------------|------|------|
461
- | `path` | `string` | 路由路径,支持 `/:param` |
462
- | `component` | `Component` | 路由组件 |
463
- | `layout` | `Component` | 嵌套布局(渲染 `<RouteView />` 显示子路由)|
464
- | `children` | `RouteDef[]` | 子路由(与 layout 配合使用)|
465
- | `auth` | `boolean` | 是否需要登录 |
466
- | `title` | `string` | 页面标题(自动设置 `document.title`)|
467
- | `loader` | `(ctx) => Promise<data>` | 数据预取 → `ctx.route.data` |
468
- | `transition` | `string` | 页面切换过渡动画 CSS class 前缀 |
469
-
470
- | RouterOptions | 默认 | 说明 |
471
- |---------------|------|------|
472
- | `mode` | `'hash'` | 路由模式 |
473
- | `notFound` | — | 404 组件 |
474
- | `scrollRestoration` | `true` | 历史模式时恢复滚动位置 |
475
- | `transition` | — | 全局过渡动画 |
476
-
477
- **`ctx.route` 注入:**
478
-
479
- ```tsx
480
- ctx.route.path // '/user/42'
481
- ctx.route.params // { id: '42' }
482
- ctx.route.query // { tab: 'profile' }
483
- ctx.route.component // 当前路由组件
484
- ctx.route.data // loader 返回的数据
485
- ctx.route.loading // loader 是否加载中
486
- ctx.app.navigate('/path')
487
- ```
488
-
489
- ### 代码分割
490
-
491
- ```tsx
492
- const AdminPage = lazy(() => import('./pages/AdminPage'), {
493
- fallback: () => <div>加载中...</div>,
494
- })
495
-
496
- const routes = [
497
- { path: '/admin', component: AdminPage },
498
- ]
499
- ```
500
-
501
- 需 esbuild `splitting: true` + `outdir`。
422
+ | API | 说明 |
423
+ |------|------|
424
+ | `ctx.route.path` | 当前路由路径 |
425
+ | `ctx.route.params` | URL 参数(如 `:id`)|
426
+ | `ctx.route.query` | 查询参数对象 |
427
+ | `ctx.app.navigate(path)` | 编程式导航 |
502
428
 
503
429
  ### 中间件
504
430
 
505
- #### ws WebSocket 客户端
431
+ **ws —— WebSocket 客户端**
506
432
 
507
433
  ```tsx
508
- app.use(ws({ url: '/ws' }))
434
+ app.use(ws())
509
435
 
510
- // 组件中:
511
- const unsub = ctx.ws?.onMessage((data) => { ... })
512
- // 清理在 ref 中处理
436
+ // 发送消息
513
437
  ctx.ws?.send({ type: 'chat', body: 'hello' })
514
- {ctx.ws?.isConnected && <span>🟢 已连接</span>}
515
- ```
516
438
 
517
- | `ctx.ws` | 类型 | 说明 |
518
- |----------|------|------|
519
- | `send` | `(data: unknown) => void` | 发送消息 |
520
- | `onMessage` | `(handler) => dispose()` | 注册消息监听 |
521
- | `isConnected` | `Signal<boolean>` | 连接状态信号 |
522
-
523
- | 选项 | 默认 | 说明 |
524
- |------|------|------|
525
- | `url` | `'/ws'` | WebSocket 地址 |
526
- | `reconnectInterval` | `3000` | 重连间隔 (ms) |
527
- | `maxReconnect` | `10` | 最大重连次数 |
439
+ // 接收消息
440
+ ctx.ws?.onMessage((msg) => { console.log(msg) })
441
+ ```
528
442
 
529
- #### api HTTP 客户端
443
+ **api —— HTTP 客户端**
530
444
 
531
445
  ```tsx
532
446
  app.use(api({ baseURL: '/api' }))
533
447
 
534
- // 组件中:
535
- await ctx.api.get<User[]>('/users')
536
- await ctx.api.post<User>('/users', body)
537
- await ctx.api.put<User>('/users/1', body)
538
- await ctx.api.patch<User>('/users/1', body)
539
- await ctx.api.delete('/users/1')
448
+ // 自动携带 Authorization header
449
+ const user = await ctx.api?.get('/users/1')
450
+ const res = await ctx.api?.post('/users', { name: 'Alice' })
540
451
  ```
541
452
 
542
- | `ctx.api` | 签名 | 说明 |
543
- |-----------|------|------|
544
- | `get` | `<T>(url, opts?) => Promise<T>` | GET 请求 |
545
- | `post` | `<T>(url, body?, opts?) => Promise<T>` | POST 请求 |
546
- | `put` | `<T>(url, body?, opts?) => Promise<T>` | PUT 请求 |
547
- | `patch` | `<T>(url, body?, opts?) => Promise<T>` | PATCH 请求 |
548
- | `delete` | `<T>(url, opts?) => Promise<T>` | DELETE 请求 |
549
-
550
- | 选项 | 说明 |
453
+ | 方法 | 说明 |
551
454
  |------|------|
552
- | `baseURL` | API 基础路径 |
553
- | `headers` | 默认请求头 |
554
- | `onRequest` | 请求拦截器 `({url, init}) => {url, init}` |
555
- | `onResponse` | 响应拦截器 `(res) => Promise<T>` |
556
-
557
- 错误类型:`ApiError` — 包含 `status` 和 `body`。
455
+ | `ctx.api.get(url, opts?)` | GET |
456
+ | `ctx.api.post(url, body?, opts?)` | POST |
457
+ | `ctx.api.put(url, body?, opts?)` | PUT |
458
+ | `ctx.api.patch(url, body?, opts?)` | PATCH |
459
+ | `ctx.api.delete(url, opts?)` | DELETE |
558
460
 
559
- #### auth 认证状态管理
461
+ **auth —— 认证状态管理**
560
462
 
561
463
  ```tsx
562
464
  app.use(auth())
563
465
 
564
- // 组件中:
565
- {ctx.auth?.isLoggedIn ? (
566
- <div>
567
- <span>{ctx.auth?.user?.name}</span>
568
- <button onClick={() => ctx.auth?.logout()}>退出</button>
569
- </div>
570
- ) : (
571
- <Login />
572
- )}
573
-
574
466
  // 登录
575
- ctx.auth.login('jwt-token', { id: 1, name: 'Alice' })
576
- // 退出
577
- ctx.auth.logout()
578
- ```
579
-
580
- | `ctx.auth` | 类型 | 说明 |
581
- |-----------|------|------|
582
- | `token` | `Signal<string \| null>` | 当前 token |
583
- | `user` | `Signal<AuthUser \| null>` | 当前用户 |
584
- | `isLoggedIn` | `Signal<boolean>` | 是否已登录(computed)|
585
- | `login` | `(token, user) => void` | 存储 token + 用户到 localStorage |
586
- | `logout` | `() => void` | 清除 token + 用户 |
587
- | `setUser` | `(user) => void` | 更新用户信息 |
588
- | `authorizationHeader` | `Signal<string \| null>` | `'Bearer xxx'` 或 `null` |
589
-
590
- | 选项 | 默认 | 说明 |
591
- |------|------|------|
592
- | `storage` | `localStorage` | 存储方式 |
593
- | `tokenKey` | `'weifuwu_token'` | token 存储 key |
594
- | `userKey` | `'weifuwu_user'` | 用户信息存储 key |
467
+ ctx.auth?.login(token, user)
595
468
 
596
- ### 工具
597
-
598
- #### useForm — 表单管理
599
-
600
- ```tsx
601
- const form = useForm({
602
- initial: { name: '', email: '' },
603
- validate: {
604
- name: (v) => !v ? '请输入姓名' : null,
605
- email: [
606
- (v) => !v ? '请输入邮箱' : null,
607
- (v) => !v.includes('@') ? '邮箱格式错误' : null,
608
- ],
609
- },
610
- onSubmit: async (values) => {
611
- await ctx.api.post('/users', values)
612
- },
613
- })
469
+ // 登出
470
+ ctx.auth?.logout()
614
471
 
615
- // JSX:
616
- <form onSubmit={form.handleSubmit}>
617
- <input {...form.field('name')} />
618
- <span>{form.errors.value.name}</span>
619
- <button disabled={form.submitting}>提交</button>
620
- </form>
472
+ // 状态
473
+ if (ctx.auth?.isLoggedIn) { ... }
621
474
  ```
622
475
 
623
- | 返回值 | 类型 | 说明 |
624
- |--------|------|------|
625
- | `values` | `Signal<T>` | 表单值 |
626
- | `errors` | `Signal<Partial<Record<keyof T, string\|null>>>` | 验证错误 |
627
- | `submitting` | `Signal<boolean>` | 提交状态 |
628
- | `touched` | `Signal<Partial<Record<keyof T, boolean>>>` | 触碰字段 |
629
- | `handleSubmit` | `(e: Event) => void` | 提交处理(绑定到 `<form>`)|
630
- | `field` | `(name) => { value, onInput, error }` | 字段绑定对象 |
631
- | `setValue` | `(name, value) => void` | 设字段值 |
632
- | `reset` | `() => void` | 重置表单 |
633
- | `validateAll` | `() => boolean` | 触发全部验证 |
476
+ | API | 说明 |
477
+ |------|------|
478
+ | `ctx.auth.token` | JWT token |
479
+ | `ctx.auth.user` | 用户对象 |
480
+ | `ctx.auth.isLoggedIn` | 是否已登录 |
481
+ | `ctx.auth.login(token, user, refreshToken?)` | 登录 |
482
+ | `ctx.auth.logout()` | 登出 |
634
483
 
635
- #### createResource — 异步数据
484
+ ### ErrorBoundary
636
485
 
637
486
  ```tsx
638
- const [data, { loading, error, refetch }] = createResource(
639
- () => fetch('/api/posts').then(r => r.json()),
640
- { initialValue: [] }
641
- )
642
-
643
- // JSX:
644
- <Show when={loading}><p>加载中...</p></Show>
645
- <Show when={error}><p>错误: {error.value?.message}</p></Show>
646
- <Show when={computed(() => !loading.value && !error.value)}>
647
- <For each={data}>{(item) => <div>{item.title}</div>}</For>
648
- </Show>
649
- ```
487
+ import { ErrorBoundary } from 'weifuwu/client'
650
488
 
651
- | 返回值 | 类型 | 说明 |
652
- |--------|------|------|
653
- | `data` (元组第一项) | `Signal<T \| undefined>` | 数据信号 |
654
- | `loading` | `Signal<boolean>` | 加载状态 |
655
- | `error` | `Signal<Error \| undefined>` | 错误信号 |
656
- | `refetch` | `() => void` | 手动重新加载 |
657
-
658
- #### ErrorBoundary — 错误捕获
659
-
660
- ```tsx
661
- <ErrorBoundary
662
- fallback={(e) => <p>出错了: {e.message}</p>}
663
- onError={(e) => console.error(e)}
664
- >
665
- {() => <Dashboard />} {/* 必须用 thunk */}
489
+ <ErrorBoundary fallback={<p>出错了</p>}>
490
+ <UserProfile />
666
491
  </ErrorBoundary>
667
492
  ```
668
493
 
669
- #### createPortal — 渲染到指定位置
670
-
671
- ```tsx
672
- <Show when={showModal}>
673
- {createPortal(<Modal />, document.body)}
674
- </Show>
675
- ```
676
-
677
- #### wrap — 封装三方库为组件
678
-
679
- ```tsx
680
- const Chart = wrap('div', (el, props: { data: any }, ctx) => {
681
- const chart = echarts.init(el)
682
- chart.setOption(props.data)
683
- effect(() => chart.setOption(props.data))
684
- return () => chart.dispose() // 卸载时自动清理
685
- })
686
-
687
- // 使用:
688
- <Chart data={salesData} />
689
- ```
690
-
691
- #### createContext / extendCtx — 上下文扩展
692
-
693
- ```tsx
694
- // 类型安全的 provide/inject
695
- const ThemeCtx = createContext<string>('theme')
696
- ThemeCtx.provide(ctx, 'dark')
697
- const theme = ThemeCtx.inject(ctx) // 'dark' | null
698
-
699
- // 中间件注入
700
- function myMiddleware(): AppMiddleware {
701
- return (ctx) => extendCtx(ctx, { myField: 'hello' })
702
- }
703
- ```
704
-
705
- ### React 对照表
494
+ ### 工具
706
495
 
707
- | React | weifuwu/client |
708
- |-------|----------------|
709
- | `useState(0)` | `$.count = 0` |
710
- | `useMemo(() => a*2, [a])` | `const doubled = a * 2`(render 时计算) |
711
- | `useEffect(() => f, [])` | `if (!ctx.ui.ready) { f() }` |
712
- | `{cond && <X/>}` | 相同 |
713
- | `{arr.map(i => <X/>)}` | 相同,加 `key` |
714
- | `Suspense` | `{$.loading && <Loading/>}` |
715
- | `useNavigate()` | `ctx.app?.navigate()` |
716
- | `useParams()` | `ctx.route?.params` |
717
- | `axios.get()` | `ctx.api?.get()` |
496
+ | 函数 | 用途 |
497
+ |------|------|
498
+ | `extendCtx(ctx, fields)` | 创建新 ctx,继承原 ctx 的 getter |
718
499
 
719
500
  ### 前端类型
720
501
 
721
- `VNode`, `Component`, `WfuiContext`, `AppMiddleware`, `RouteDef`, `ApiClient`, `AuthClient`
502
+ `VNode`, `VNodeType`, `Component`, `WfuiContext`, `AppMiddleware`, `RouteDef`, `ApiClient`, `ApiOptions`, `ApiRequestOptions`, `ApiError`, `AuthClient`, `AuthOptions`, `ErrorBoundaryProps`
722
503
 
723
504
  ---
724
505
 
725
506
  ## 布局 & 主题 (`weifuwu/layout`)
726
507
 
727
- 纯 CSS 布局原语 + 主题 Token 系统,不绑定任何 JS 框架。
728
-
729
- ```bash
730
- npm install weifuwu # 已包含
731
- ```
508
+ 纯 CSS 布局原语 + 主题 Token。不绑定任何 JS 框架。
732
509
 
733
510
  ```ts
734
- // 服务端编译 CSS
735
- app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/dist/layout/weifuwu-layout.css'))
511
+ // 服务端编译
512
+ app.get('/layout.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/dist/layout/weifuwu-layout.css'))
736
513
  ```
737
514
 
738
515
  ```html
739
516
  <!-- 或直接引入 -->
740
- <link rel="stylesheet" href="/weifuwu.css">
517
+ <link rel="stylesheet" href="/layout.css">
518
+ ```
519
+
520
+ ### 使用示例
521
+
522
+ ```html
523
+ <div class="wf-stack" style="--wf-gap: 24px">
524
+ <div class="wf-split">
525
+ <h2 style="color: var(--wf-color-text)">仪表盘</h2>
526
+ <button style="background: var(--wf-color-primary); color: #fff; border-radius: var(--wf-radius)">+ 新建</button>
527
+ </div>
528
+ <div class="wf-row" style="--wf-gap: 16px">
529
+ <div class="wf-fill wf-surface wf-stack" style="padding: 20px; background: var(--wf-color-bg); --wf-gap: 4px">
530
+ <span style="color: var(--wf-color-text-secondary)">总用户</span>
531
+ <span style="font-size: var(--wf-font-size-4xl); font-weight: var(--wf-font-weight-bold); color: var(--wf-color-text)">1,234</span>
532
+ </div>
533
+ </div>
534
+ </div>
741
535
  ```
742
536
 
743
537
  ### 33 个布局原语
@@ -745,7 +539,7 @@ app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/d
745
539
  | 类别 | 原语 | 含义 | CSS 实现 |
746
540
  |------|------|------|---------|
747
541
  | **排列** | `wf-stack` | 纵向堆叠 | `flex-direction: column + gap` |
748
- | | `wf-stack-reverse` | 反向堆叠 | `flex-direction: column-reverse + gap` |
542
+ | | `wf-stack-reverse` | 反向堆叠 | `flex-direction: column-reverse` |
749
543
  | | `wf-row` | 横向排列 | `flex + flex-wrap + gap` |
750
544
  | | `wf-row-reverse` | 反向排列 | `flex-direction: row-reverse` |
751
545
  | | `wf-nowrap` | 不换行 | `flex-wrap: nowrap` |
@@ -758,20 +552,20 @@ app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/d
758
552
  | **对齐** | `wf-top` | 顶部 | `align-items: flex-start` |
759
553
  | | `wf-bottom` | 底部 | `align-items: flex-end` |
760
554
  | | `wf-stretch` | 拉伸 | `align-items: stretch` |
761
- | **弹性** | `wf-fill` | 撑满 | `flex: 1 + min-width: 0` |
762
- | | `wf-fixed` | 固定 | `flex: none` |
763
- | | `wf-auto` | 按内容 | `flex: auto` |
764
- | | `wf-shrink` | 可收缩 | `min-width: 0 / min-height: 0` |
555
+ | **弹性** | `wf-fill` | 撑满剩余空间 | `flex: 1 + min-width: 0` |
556
+ | | `wf-fixed` | 固定不伸缩 | `flex: none` |
557
+ | | `wf-auto` | 按内容撑满 | `flex: auto` |
558
+ | | `wf-shrink` | 可收缩 | `min-width: 0 + min-height: 0` |
765
559
  | **Z轴** | `wf-cover` | 全屏覆盖 | `position: fixed + inset: 0` |
766
560
  | | `wf-pop` | 浮动层 | `position: absolute` |
767
561
  | | `wf-anchor` | 锚点容器 | `position: relative` |
768
562
  | | `wf-layer` | 层级控制 | `position: relative + z-index` |
769
- | | `wf-sticky` | 粘性 | `position: sticky` |
563
+ | | `wf-sticky` | 粘性定位 | `position: sticky` |
770
564
  | **容器** | `wf-surface` | 基础面 | `border-radius + box-shadow + bg` |
771
- | | `wf-grid` | 网格 | `display: grid + --wf-cols` |
565
+ | | `wf-grid` | 二维网格 | `display: grid + --wf-cols` |
772
566
  | | `wf-container` | 宽度约束 | `max-width + margin: auto` |
773
- | | `wf-scroll` | 滚动 | `overflow: auto` |
774
- | | `wf-clip` | 裁剪 | `overflow: hidden` |
567
+ | | `wf-scroll` | 可滚动 | `overflow: auto` |
568
+ | | `wf-clip` | 溢出裁剪 | `overflow: hidden` |
775
569
  | **显隐** | `wf-hidden` | 隐藏 | `display: none` |
776
570
  | | `wf-block` | 块级 | `display: block` |
777
571
  | | `wf-inline` | 行内 | `display: inline` |
@@ -780,151 +574,116 @@ app.get('/weifuwu.css', async (req, ctx) => ctx.ui.css('./node_modules/weifuwu/d
780
574
 
781
575
  ### 72 个主题 Token
782
576
 
783
- | 类别 | Token 示例 | 含义 |
784
- |------|-----------|------|
785
- | 品牌色 | `--wf-color-primary`, `--wf-color-primary-bg` | 品牌色、Hover、背景 |
786
- | 语义色 | `--wf-color-success`, `--wf-color-warning`, `--wf-color-error`, `--wf-color-info` | 状态语义色 |
787
- | 中性色 | `--wf-color-text`, `--wf-color-bg`, `--wf-color-border` | 文字/背景/边框 |
577
+ | 类别 | Token 示例 | 值/层级 |
578
+ |------|-----------|---------|
579
+ | 品牌色 | `--wf-color-primary`, `--wf-color-primary-bg` | 品牌色 + Hover + 背景 |
580
+ | 语义色 | `--wf-color-success/warning/error/info` | 各带 `-bg` 背景变体 |
581
+ | 中性色 | `--wf-color-text/text-secondary/text-tertiary/text-disabled` | 4 级文字色 |
582
+ | | `--wf-color-bg/bg-secondary/bg-tertiary` | 3 级背景色 |
583
+ | | `--wf-color-border/border-light/border-dark` | 3 级边框色 |
788
584
  | 字体 | `--wf-font-sans`, `--wf-font-mono` | 字体族 |
789
- | 字号 | `--wf-font-size-xs` ~ `--wf-font-size-5xl`(9 级)| 字号层级 |
790
- | 字重 | `--wf-font-weight-normal` ~ `bold`(4 级)| 字重 |
791
- | 行高 | `--wf-line-height-tight`, `--wf-line-height`, `--wf-line-height-relaxed` | 行高 |
792
- | 字距 | `--wf-letter-spacing`, `--wf-letter-spacing-wide`, `--wf-letter-spacing-wider` | 字符间距 |
793
- | 间距 | `--wf-space-xs` ~ `--wf-space-2xl`(8 级)| margin / padding |
794
- | 间隔 | `--wf-gap-xs` ~ `--wf-gap-2xl`(6 级)| flex / grid gap |
795
- | 圆角 | `--wf-radius-sm` ~ `--wf-radius-xl`(5 级)| border-radius |
796
- | 阴影 | `--wf-shadow-sm` ~ `--wf-shadow-lg`(4 级)| box-shadow |
585
+ | 字号 | `--wf-font-size-xs/sm/base/lg/xl/2xl/3xl/4xl/5xl` | 9 级字号 |
586
+ | 字重 | `--wf-font-weight-normal/medium/semibold/bold` | 4 级字重 |
587
+ | 行高 | `--wf-line-height-tight/normal/relaxed` | 3 级行高 |
588
+ | 字距 | `--wf-letter-spacing/wide/wider` | 3 级字符间距 |
589
+ | 间距 | `--wf-space-xs/sm/md/lg/xl/2xl` | 8 margin/padding |
590
+ | 间隔 | `--wf-gap-xs/sm/md/lg/xl/2xl` | 6 flex/grid gap |
591
+ | 圆角 | `--wf-radius-sm/md/lg/xl` | 5 border-radius |
592
+ | 阴影 | `--wf-shadow-sm/md/lg` | 4 box-shadow |
797
593
  | 边框 | `--wf-border-width` | 边框宽度 |
798
- | 聚焦 | `--wf-focus-ring` | 聚焦环 |
799
- | 动效 | `--wf-transition-duration`, `--wf-transition-timing` | 过渡时长/曲线 |
800
- | 表单 | `--wf-accent-color`, `--wf-caret-color` | 表单控件主题色 |
801
- | 透明 | `--wf-opacity-disabled`, `--wf-opacity-overlay` | 禁用态/遮罩透明度 |
802
- | 层级 | `--wf-pop-z`, `--wf-cover-z` | z-index |
594
+ | 聚焦 | `--wf-focus-ring` | 聚焦环(box-shadow)|
595
+ | 动效 | `--wf-transition-duration/timing` | 过渡时长 + 曲线 |
596
+ | 表单 | `--wf-accent-color`, `--wf-caret-color` | 控件主题色 + 光标色 |
597
+ | 透明 | `--wf-opacity-disabled`, `--wf-opacity-overlay` | 禁用态 + 遮罩透明度 |
598
+ | 层级 | `--wf-pop-z`, `--wf-cover-z` | z-index |
803
599
 
804
600
  ### 暗色模式
805
601
 
806
- 切换 `html` 标签的 `data-theme` 属性即可自动切换全部主题色:
602
+ 切换 `html` `data-theme` 属性即可自动切换全部主题色:
807
603
 
808
604
  ```ts
809
- // weifuwu/client
810
- function toggleTheme() {
811
- const dark = document.documentElement.getAttribute('data-theme') === 'dark'
812
- document.documentElement.setAttribute('data-theme', dark ? 'light' : 'dark')
813
- }
605
+ document.documentElement.setAttribute('data-theme', 'dark')
606
+ // → 全部引用 var(--wf-*) 的元素自动变色
814
607
  ```
815
608
 
816
609
  ### 基础元素默认样式
817
610
 
818
- 引入 `weifuwu/layout` 后,以下 HTML 元素自动绑定主题 Token,无需额外样式:
611
+ 引入 weifuwu/layout 后,以下 HTML 元素自动绑定主题 Token
819
612
 
820
613
  `body`, `h1`~`h6`, `p`, `a`, `label`, `small`, `input`, `textarea`, `select`, `button`, `table`, `th`, `td`, `hr`, `pre`, `code`
821
614
 
822
615
  ---
823
616
 
824
- ## 全栈模式
825
-
826
- ### 认证流程
827
-
828
- ```ts
829
- // 后端
830
- app.post('/api/login', async (req, ctx) => {
831
- const { email } = await req.json()
832
- return Response.json({
833
- token: 'jwt_' + Math.random().toString(36),
834
- user: { id: 1, name: email.split('@')[0], email },
835
- })
836
- })
837
-
838
- // 前端
839
- app.use(api({ baseURL: '' }))
840
- app.use(auth())
617
+ ## 环境变量
841
618
 
842
- // 登录
843
- const res = await ctx.api.post('/api/login', { email, password })
844
- ctx.auth.login(res.token, res.user)
845
- ```
619
+ | 变量 | 用途 | 默认值 |
620
+ |------|------|--------|
621
+ | `DATABASE_URL` | PostgreSQL 连接字符串 | — |
622
+ | `REDIS_URL` | Redis 连接字符串 | — |
846
623
 
847
- ### 异步数据 + SSR
624
+ ---
848
625
 
849
- ```ts
850
- // 后端 — 同路径既支持 SSR 也支持 API
851
- app.get('/api/posts', async (req, ctx) => {
852
- return Response.json(posts)
853
- })
626
+ ## 项目结构
854
627
 
855
- // 前端 — 客户端获取
856
- const [posts, { loading }] = createResource(
857
- () => ctx.api.get('/api/posts')
858
- )
859
628
  ```
860
-
861
- ### 嵌套布局 + 代码分割
862
-
863
- ```tsx
864
- const routes = [
865
- {
866
- path: '/dashboard',
867
- layout: DashboardLayout, // 侧边栏等 UI 保持挂载
868
- children: [
869
- { path: '/overview', component: lazy(() => import('./Overview')) },
870
- { path: '/settings', component: lazy(() => import('./Settings')) },
871
- ],
872
- },
873
- ]
629
+ src/
630
+ ├── index.ts # 统一导出
631
+ ├── types.ts # 后端类型
632
+ ├── request.ts # 请求解析
633
+ ├── response.ts # 响应工具
634
+ ├── core/
635
+ │ ├── router.ts # HTTP 路由
636
+ ├── serve.ts # HTTP 服务器
637
+ │ └── ws.ts # WebSocket
638
+ ├── middleware/
639
+ │ ├── cors.ts
640
+ │ └── static.ts
641
+ ├── postgres/
642
+ ├── redis/
643
+ ├── graphql.ts
644
+ ├── ui/ # SSR + JS/CSS 编译
645
+ ├── client/ # 前端 VDOM 框架
646
+ │ ├── index.ts
647
+ │ ├── vnode.ts
648
+ │ ├── app.ts
649
+ │ ├── render.ts
650
+ │ ├── router.ts
651
+ │ ├── types.ts
652
+ │ ├── error-boundary.ts
653
+ │ └── middleware/
654
+ │ ├── api.ts
655
+ │ ├── auth.ts
656
+ │ └── ws.ts
657
+ └── layout/ # 纯 CSS 布局 + 主题
658
+ ├── weifuwu-layout.css
659
+ ├── _tokens.css
660
+ ├── _dark.css
661
+ ├── _base.css
662
+ └── _*.css # 33 个原语
874
663
  ```
875
664
 
876
665
  ---
877
666
 
878
- ## 环境变量
879
-
880
- | 变量 | 默认 | 说明 |
881
- |------|------|------|
882
- | `DATABASE_URL` | `postgres://root:123456@localhost:5432/demo` | Postgres 连接字符串 |
883
- | `REDIS_URL` | `redis://localhost:6379` | Redis 连接字符串 |
884
-
885
- ---
886
-
887
- ## Demo
667
+ ## 开发
888
668
 
889
669
  ```bash
890
- cd apps/demo
891
- node server.ts
892
- # http://localhost:3000
893
- ```
670
+ # 构建
671
+ npm run build
894
672
 
895
- Demo 包含:嵌套布局、ctx.ui.$ 待办列表、手动表单、async fetch 数据请求、api + auth 认证、WebSocket 实时通信。
673
+ # 类型检查
674
+ npm run typecheck
896
675
 
897
- ---
898
-
899
- ## 项目结构
676
+ # 测试
677
+ npm test
900
678
 
901
- ```
902
- src/
903
- ├── index.ts 入口,导出所有后端模块
904
- ├── types.ts Context, Handler, Middleware 等类型
905
- ├── core/ Router, serve, WebSocket upgrade
906
- ├── middleware/ cors, serveStatic
907
- ├── postgres/ PostgreSQL 客户端
908
- ├── redis/ Redis 客户端
909
- ├── ui/ SSR 渲染 + 动态编译
910
- ├── graphql.ts GraphQL
911
- ├── client/
912
- │ ├── index.ts 前端导出入口
913
- │ ├── vnode.ts VNode 类型 + JSX 工厂
914
- │ ├── render.ts VDOM 渲染器(render + patchValue)
915
- │ ├── router.ts 路由中间件 + RouteView
916
- │ ├── app.ts createApp 应用实例
917
- │ ├── types.ts 前端类型
918
- │ └── middleware/
919
- │ ├── ws.ts WebSocket 客户端
920
- │ ├── api.ts HTTP 客户端
921
- │ └── auth.ts 认证状态管理
922
- ├── test/ 测试
923
- apps/demo/ 全栈 demo
679
+ # 发布
680
+ node scripts/release.mjs <version>
924
681
  ```
925
682
 
926
- ```bash
927
- npm run build # esbuild → dist/
928
- npm run typecheck # tsc --noEmit
929
- npm test # 运行所有测试
930
- ```
683
+ ## 设计原则
684
+
685
+ - **后端为工具箱** —— 提供 HTTP 路由、数据库、中间件原语,不捆绑业务模块
686
+ - **全栈单包** —— `npm install weifuwu` = 后端 + 前端 + 布局
687
+ - **Web 标准优先** —— 所有 handler 使用 `(req: Request, ctx: Context) => Response`
688
+ - **零外部依赖** —— 前端和布局没有任何 npm 运行时依赖
689
+ - **LLM 友好** —— 模块总览表 + 一致格式 + 清晰依赖链
@@ -56,7 +56,8 @@ function renderValue(v, ctx) {
56
56
  el.appendChild(renderValue(child, ctx));
57
57
  }
58
58
  if (vnode.props?.ref) {
59
- queueMicrotask(() => vnode.props.ref(el));
59
+ const result = vnode.props.ref(el);
60
+ if (typeof result === "function") vnode._cleanup = result;
60
61
  }
61
62
  return el;
62
63
  }
@@ -140,7 +141,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
140
141
  }
141
142
  if (newInput == null) {
142
143
  if (oldNode) {
143
- callRef(oldInput, null);
144
+ callRefCleanup(oldInput);
144
145
  oldNode.remove();
145
146
  }
146
147
  return null;
@@ -148,7 +149,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
148
149
  const oldType = typeOf(oldInput);
149
150
  const newType = typeOf(newInput);
150
151
  if (oldType !== newType) {
151
- callRef(oldInput, null);
152
+ callRefCleanup(oldInput);
152
153
  const node = renderValue(newInput, ctx);
153
154
  if (oldNode?.parentNode) {
154
155
  oldNode.parentNode.replaceChild(node, oldNode);
@@ -181,7 +182,7 @@ function patchValue(parent, oldNode, oldInput, newInput, ctx) {
181
182
  patchProps(oldNode, oldV.props, newV.props);
182
183
  patchChildren(oldNode, oldV, newV, ctx);
183
184
  } else if (oldNode) {
184
- callRef(oldInput, null);
185
+ callRefCleanup(oldInput);
185
186
  const node = renderValue(newInput, ctx);
186
187
  oldNode.parentNode?.replaceChild(node, oldNode);
187
188
  return node;
@@ -286,7 +287,7 @@ function patchSimpleChildren(parent, oldChildren, newChildren, ctx) {
286
287
  parent.appendChild(node);
287
288
  } else if (newChild === void 0) {
288
289
  if (existingNode) {
289
- callRef(oldChild, null);
290
+ callRefCleanup(oldChild);
290
291
  existingNode.remove();
291
292
  }
292
293
  } else {
@@ -306,7 +307,7 @@ function patchKeyedChildren(parent, oldChildren, newChildren, ctx) {
306
307
  for (const key of oldKeyMap.keys()) {
307
308
  if (!newKeys.includes(key)) {
308
309
  const entry = oldKeyMap.get(key);
309
- callRef(entry.vnode, null);
310
+ callRefCleanup(entry.vnode);
310
311
  entry.node?.remove();
311
312
  oldKeyMap.delete(key);
312
313
  }
@@ -316,7 +317,7 @@ function patchKeyedChildren(parent, oldChildren, newChildren, ctx) {
316
317
  if (key === void 0) {
317
318
  const node = parent.childNodes[i];
318
319
  if (node) {
319
- callRef(oldChildren[i], null);
320
+ callRefCleanup(oldChildren[i]);
320
321
  node.remove();
321
322
  }
322
323
  }
@@ -337,13 +338,21 @@ function patchKeyedChildren(parent, oldChildren, newChildren, ctx) {
337
338
  }
338
339
  }
339
340
  }
340
- function callRef(input, el) {
341
+ function runRefCleanup(vnode) {
342
+ if (vnode._cleanup) {
343
+ vnode._cleanup();
344
+ vnode._cleanup = void 0;
345
+ }
346
+ forEach(vnode.props?.children, (child) => {
347
+ if (child && typeof child === "object" && child._cleanup) {
348
+ runRefCleanup(child);
349
+ }
350
+ });
351
+ }
352
+ function callRefCleanup(input) {
341
353
  if (input == null || typeof input !== "object") return;
342
354
  const vnode = input;
343
- if (typeof vnode.props?.ref === "function") {
344
- vnode.props.ref(el);
345
- }
346
- forEach(vnode.props?.children, (child) => callRef(child, el));
355
+ if (vnode._cleanup) runRefCleanup(vnode);
347
356
  }
348
357
 
349
358
  // src/client/router.ts
@@ -16,6 +16,8 @@ export interface VNode {
16
16
  _$?: Record<string, any>;
17
17
  /** 子 VNode 缓存(用于 patchValue diff,避免重复执行组件) */
18
18
  _child?: any;
19
+ /** ref 回调返回的清理函数,卸载时由框架调用 */
20
+ _cleanup?: (() => void) | undefined;
19
21
  }
20
22
  export type Component<P = {}> = (props: P, ctx: WfuiContext) => VNode | null;
21
23
  export declare const Fragment: unique symbol;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "weifuwu",
3
3
  "type": "module",
4
- "version": "0.36.0",
4
+ "version": "0.36.2",
5
5
  "description": "AI SaaS framework — (req, ctx) => Response",
6
6
  "exports": {
7
7
  ".": {