weifuwu 0.63.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,31 +1,72 @@
1
1
  # weifuwu
2
2
 
3
- **一个包的全栈框架** — 后端 HTTP + 前端 VDOM + 组件库 + CSS 设计系统。全自研、零配置、消灭样板。
3
+ **自托管全栈框架**一个 npm 包 = 后端 HTTP + 前端 VDOM + 92 组件 + CSS 设计系统 + SaaS 地基(认证 / 消息 / 队列 / AI)。全自研、零构建、消灭样板。
4
4
 
5
5
  ```bash
6
- npm install weifuwu
6
+ npm install weifuwu # 一个依赖,完整应用栈
7
7
  ```
8
8
 
9
- 一个包 = 后端 (`weifuwu`) + 前端 (`weifuwu/client`) + 组件库 (`weifuwu/components`) + 布局系统 (`weifuwu/layout`)
9
+ **定位**:面向需要完整应用栈、又不想缝合多个框架/服务、且重视代码与数据所有权的开发者——独立开发者、小团队、自托管/私有化部署。尤其当应用包含 **认证 + 实时消息 + AI 对话 + 后台管理** 时,weifuwu 把这些「每个应用都要的地基」全部内置为一行 `app.use(...)`。
10
+
11
+ ### 五个关键卖点
12
+
13
+ | 卖点 | 为什么重要 |
14
+ |------|-----------|
15
+ | **一个包,零构建** | 服务端 `node --import weifuwu/dev` 直跑 `.tsx`;浏览器 CDN import map 即用;CSS 一条 link 即得完整设计系统——没有构建步骤、没有脚手架 |
16
+ | **协议层全自研** | PG v3 / RESP2 / GraphQL schema / OpenAI 流式协议全部自研——确定性、可预测、错误模型统一;诚实裁剪:**不支持的能力明确报错,绝不静默降级** |
17
+ | **消灭样板** | 动态编译免构建、`ctx.data.get` 一个 API 覆盖 SSR 预取/hydration/SPA、自研 DB 客户端免双重编码与 parseRow 样板 |
18
+ | **SaaS 地基随包内置** | rateLimit / email / userSystem / messager / queue / ai 六个中间件**互相咬合**(身份是消息的路由)——从「自建基础设施」变「声明业务」 |
19
+ | **自托管友好** | 运行时仅 esbuild + graphql + ws;部署 = 一个 Node 进程 + Postgres + Redis;数据、代码、模型全部在自己手里 |
20
+
21
+ ### 一个包 = 五层能力
22
+
23
+ | 层 | 入口 | 能力 |
24
+ |----|------|------|
25
+ | 后端 | `weifuwu` | Trie 路由 / 中间件链 / serve / 自研 PG+Redis / SSR / GraphQL / WebSocket |
26
+ | 前端 | `weifuwu/client` | 两阶段组件 / Proxy 渲染 / 数据管道 / 路由 / api·auth·ws·i18n / 移动端原语 |
27
+ | 组件 | `weifuwu/components` | 92 个 HTML 原语组件(表单/表格/弹层/AiChat…),引用 `--wf-*` 主题变量 |
28
+ | 样式 | `weifuwu/layout` | 70 布局原语 + 141 主题 Token,零自定义 CSS 文件 |
29
+ | SaaS 地基 | 随包内置 | rateLimit / email / userSystem / messager / queue / ai → `ctx.*` 一行接入 |
10
30
 
11
31
  > ⚠️ **注意:前后端都有 `ctx.ui`,但用途完全不同**
12
32
  > - **后端** `ctx.ui`(SSR/编译):`ctx.ui.html`(HTML 模板)、`ctx.ui.js`(TSX→JS 动态编译)、`ctx.ui.css`(CSS 编译)、`ctx.ui.ssr`(组件 SSR)、`ctx.ui.ssrData`(数据序列化)
13
- > - **前端** `ctx.ui`(渲染引擎):`ctx.ui.$()`(响应式状态)、`ctx.ui.render()` / `dirty()`(渲染控制)、`useMedia()` / `useBreakpoint()` / `usePopupPosition()` / `useInView()` / `useScrollPosition()`(浏览器事件监听)
33
+ > - **前端** `ctx.ui`(渲染引擎):`ctx.ui.$()`(响应式状态)、`ctx.ui.render()` / `dirty()`(渲染控制)、`useChat()`(AI 会话)/ `useAsync()`(异步取数)/ `selfId()`(跨组件刷新)/ `useMedia()` / `useBreakpoint()`(响应式断点)/ `usePopupPosition()` / `usePopup()`(弹层定位/组合)/ `useHoverCapable()` / `useLongPress()` / `useVisualViewport()`(移动端原语)/ `useInView()` / `useScrollPosition()`(浏览器事件监听)
14
34
  > 后端的是「把页面和代码交给浏览器」,前端的是「在浏览器里驱动 UI」。
15
35
 
36
+ ### 与主流方案的关系
37
+
38
+ | | weifuwu | Express + React 等 | Next.js 全家桶 |
39
+ |--|---------|------------------|----------------|
40
+ | 依赖 | **1 个包** | 5+ 个框架/库 | 生态绑定 |
41
+ | 构建 | **零**(动态编译直跑 `.tsx`) | 需要 | 需要 |
42
+ | DB 客户端 | **自研协议**(零依赖,确定性输出) | pg + 连接池 | Prisma 等 |
43
+ | AI / Agent | **内置**(agent 循环 + 工具调用 + HITL 审批 + 流式 UI) | 自接 ai-sdk | 自接 |
44
+ | 认证 / 消息 / 队列 | **随包内置、互相咬合** | 自选 + 自缝 | 自选 + 自缝 |
45
+ | 部署 | 一个 Node 进程 + PG + Redis | 各组件自理 | 平台绑定 |
46
+
47
+ > 定位不是「替代某个框架」,而是**包换包**:用 weifuwu 一个依赖替换你原本要缝合的整套栈。心智模型有借鉴(两阶段组件接近 React、中间件接近 Express),但每一层都是自研的确定性实现——组件模型见[核心概念](#核心概念),与 antd/Element Plus/shadcn 的对应见 `design/components-map.md`。
48
+
49
+ ### 从这里开始
50
+
51
+ | 你想… | 去哪 |
52
+ |--------|------|
53
+ | 10 分钟跑通 SPA + SSR | [快速开始](#快速开始) |
54
+ | 立刻体验(跑现成 demo) | 快速开始的「30 秒体验」 |
55
+ | 零后端原型(一个 HTML 文件) | [CDN 快速原型](#cdn-快速原型零构建纯-html) |
56
+ | 按任务找 API(认证/消息/AI/移动端…) | [能力速查](#能力速查任务--api) |
57
+ | 读完整 API 参考 | [文档导航](#文档导航) |
58
+
16
59
  ---
17
60
 
18
61
  ## 设计理念
19
62
 
20
- ### 一句话
21
-
22
- **weifuwu = 一个包的全栈框架:全自研、零配置、消灭样板、SaaS 地基随包内置。** 下面四条核心哲学与十一条技术原则都是这句话的展开——我们不做缝合框架,每一层都自研且可预测。
63
+ > 顶部「定位」回答了**是什么 / 为什么**;以下是**哲学展开**——四条核心哲学与十一条技术原则。
23
64
 
24
65
  ### 核心哲学
25
66
 
26
67
  **① 一个包,全栈一体。** 后端、前端、组件、样式装在一个 npm 包里,零配置、零构建、纯 link 可用:服务端 `--import weifuwu/dev` 直接跑 `.tsx`(Node loader + esbuild 同步编译);浏览器 CDN import map 直接跑;CSS 一条 link 即得完整设计系统。
27
68
 
28
- **② 全自研,诚实裁剪。** VDOM、PG v3 / RESP2 协议、GraphQL schema、OpenAI 兼容流式协议——全部自研而非包装他人。动机不是炫技而是**确定性**:自研客户端输出确定、行为可预测、错误模型统一。配套纪律是诚实裁剪:**不支持的能力明确抛 `ProtocolError('unsupported')`,绝不静默降级或"尽量支持"**(已裁剪清单见 `docs/db-clients-plan.md`)。
69
+ **② 全自研,诚实裁剪。** VDOM、PG v3 / RESP2 协议、GraphQL schema、OpenAI 兼容流式协议——全部自研而非包装他人。动机不是炫技而是**确定性**:自研客户端输出确定、行为可预测、错误模型统一。配套纪律是诚实裁剪:**不支持的能力明确抛 `ProtocolError('unsupported')`,绝不静默降级或"尽量支持"**(已裁剪清单见 `design/db-clients-plan.md`)。
29
70
 
30
71
  **③ 消灭样板。** 框架的每一层都在消灭一类样板代码:
31
72
 
@@ -44,7 +85,7 @@ npm install weifuwu
44
85
 
45
86
  **两阶段组件模型** — 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook——**位置即语义**:外层天生只跑一次,没有 hooks 规则、没有依赖数组、没有闭包陷阱(详解见[核心概念](#核心概念))。
46
87
 
47
- **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch;也支持手动 `ctx.ui.render()` 精确控制渲染时机。**组件库手动优先、业务层自动优先**——同一框架内按角色选模式(详见[组件库](#组件库-weifuwucomponents))。
88
+ **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch;也支持手动 `ctx.ui.render()` 精确控制渲染时机。**组件库手动优先、业务层自动优先**——同一框架内按角色选模式(详见[组件库](docs/components.md))。
48
89
 
49
90
  **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` / `ctx.limit` / `ctx.email` / `ctx.queue` / `ctx.ai` / `ctx.msg` 等),Handler/组件从 `ctx` 读取。
50
91
 
@@ -52,13 +93,13 @@ npm install weifuwu
52
93
 
53
94
  **SPA/SSR/Hydration 统一透明** — 同一份路由定义(`routes`)一个组件三场景自动适配:后端 `uiSsr({ routes })` 匹配即自动 SSR(完整 HTML + `__DATA__`),客户端 `router({ routes })` + `RouteView` + `mount(..., { hydrate: true })` 按 URL 同源匹配并收养服务端 HTML(不重建、无闪跳)。`ctx.data.get` 一个 API:SSR 预取 / hydration 命中(不重复请求)/ SPA 触发 fetch。服务端直接用 `.tsx`(`weifuwu/dev` Node loader),前后端同一 JSX 运行时。
54
95
 
55
- **AI 是一等公民** — 自研 OpenAI 兼容协议(`docs/ai-contract.md`)+ 零依赖流式客户端 + agent 工具循环 + HITL 人工审批 + embedding 向量化。后端 `ctx.ai` 一个入口:`chat()` / `stream()` / `agent()`(`stream(messages, { emit })` emitter 抽象——事件可接任意通道,`runToResult()` 结构化结果)/ `approve()` / `embed()` / `embedMany()`;前端 `ctx.ui.useChat()`(会话语义)+ `AiChat` 组件(标准对话界面)——流式 token / 工具调用卡 / 审批卡开箱即用,协议对页面完全透明,不用 ai-sdk。
96
+ **AI 是一等公民** — 自研 OpenAI 兼容协议(`design/ai-contract.md`)+ 零依赖流式客户端 + agent 工具循环 + HITL 人工审批 + embedding 向量化。后端 `ctx.ai` 一个入口:`chat()` / `stream()` / `agent()`(`stream(messages, { emit })` emitter 抽象——事件可接任意通道,`runToResult()` 结构化结果)/ `approve()` / `embed()` / `embedMany()`;前端 `ctx.ui.useChat()`(会话语义)+ `AiChat` 组件(标准对话界面)——流式 token / 工具调用卡 / 审批卡开箱即用,协议对页面完全透明,不用 ai-sdk。
56
97
 
57
- **SaaS 地基随包内置** — rateLimit(限流)/ email(邮件)/ userSystem(用户认证)/ messager(消息系统)/ queue(可靠队列)以中间件形态随包提供,`app.use(...)` 一行接入(详见文末[SaaS 地基模块](#saas-地基模块ratelimit--email--usersystem--messager--queue))。互相咬合成协作基础:身份(userSystem)+ 消息(messager)的组合让「谁能跟谁说话、消息如何送达」天然对齐,不再需要第三套权限系统。
98
+ **SaaS 地基随包内置** — rateLimit(限流)/ email(邮件)/ userSystem(用户认证)/ messager(消息系统)/ queue(可靠队列)以中间件形态随包提供,`app.use(...)` 一行接入(详见[SaaS 地基模块](docs/saas.md))。互相咬合成协作基础:身份(userSystem)+ 消息(messager)的组合让「谁能跟谁说话、消息如何送达」天然对齐,不再需要第三套权限系统。
58
99
 
59
100
  **机制与策略分离** — 框架管**机制**(token 怎么签、消息怎么送达、agent 循环怎么跑),开发者管**策略**(谁能建群、租户隔离 SQL、技能注册表)。这是「诚实裁剪」的积极面:**框架不越界,应用层不被绑架**——agent-platform 迁移验证了边界:多租户隔离(`WHERE tenant_id`)、技能编排、聊天产品模型留在应用层,框架守住通用能力(auth / ai / messager / UI / 数据管道)。
60
101
 
61
- **零自定义 CSS 设计系统** — 一个 CSS 文件 = 双层 Token + 布局原语 + 工具类 + 组件样式。业务页面不写 style.css:组件 + `wf-*` 原语写业务,品牌/组件定制改变量(`--wf-brand-500` / `--wf-btn-radius`),暗色自动(详见[布局系统](#布局系统-weifuwulayout))。
102
+ **零自定义 CSS 设计系统** — 一个 CSS 文件 = 双层 Token + 布局原语 + 工具类 + 组件样式。业务页面不写 style.css:组件 + `wf-*` 原语写业务,品牌/组件定制改变量(`--wf-brand-500` / `--wf-btn-radius`),暗色自动(详见[布局系统](docs/layout.md))。
62
103
 
63
104
  **自研数据层** — `ctx.sql`(PG v3 协议)与 `ctx.redis`(RESP2 协议)为**自研客户端**:确定性输出、行为可预测、统一错误模型。jsonb 自动解码、TTL 安全 API、schema 写前校验——高频痛点(双重编码/parseRow 样板/`'EX'` 参数顺序)从根上消除。
64
105
 
@@ -166,7 +207,22 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
166
207
  - 改组件刷新即生效,无需构建步骤
167
208
  - 完整可运行示例见 `apps/components-demo`(组件 cheatsheet)与 `apps/agent-platform`(全栈 SaaS 应用)
168
209
 
169
- > 想**零后端、零构建**最快跑起来?直接跳到下面的「CDN 快速原型」。
210
+ > 需要 **Node.js ≥ 20.6**(`--import weifuwu/dev` 与 `node --test` 依赖)。
211
+
212
+ ### 30 秒体验(跑现有 demo)
213
+
214
+ ```bash
215
+ # ① 组件 cheatsheet——92 组件全部可交互预览(零依赖,5 秒起)
216
+ cd apps/components-demo && node server.ts
217
+ # 打开 http://localhost:3000
218
+
219
+ # ② 全栈 SaaS 示例——多租户 AI 平台(auth / AI 对话 / 部门聊天 / 知识库 / HITL 审批)
220
+ docker compose up -d postgres redis # 仓库根目录
221
+ cd apps/agent-platform && npm run seed && npm run dev
222
+ # 打开 http://localhost:3000(admin@demo.com / admin123)
223
+ ```
224
+
225
+ > 想**零后端、零构建**最快跑起来?直接跳到下面的「CDN 快速原型」——一个 `.html` 文件即可。
170
226
 
171
227
  ---
172
228
 
@@ -243,7 +299,7 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
243
299
  |------|---------|------|
244
300
  | `weifuwu/client` | `https://unpkg.com/weifuwu@latest/dist/client/index.js` | 客户端核心(createApp, h, 路由, 状态管理等) |
245
301
  | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/index.js` | 92 个 UI 组件(Button, Card, Table, Modal, Icon 等) |
246
- | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/style.css` | 组件 CSS + 141 个主题 Token + 67 个布局原语 |
302
+ | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/style.css` | 组件 CSS + 141 个主题 Token + 70 个布局原语 |
247
303
  | 独立布局系统 | `https://unpkg.com/weifuwu@latest/dist/layout/weifuwu-layout.css` | 仅 CSS 布局,不依赖 JS |
248
304
 
249
305
 
@@ -282,9 +338,35 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
282
338
  | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
283
339
  | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
284
340
  | `weifuwu/client` | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
285
- | `weifuwu/client` | **popup** | 弹层 fixed 定位工具(`computeFixedPos` / `computeFixedPosRect`) | — |
341
+ | `weifuwu/client` | **popup** | 弹层 fixed 定位工具(`computeFixedPos` / `computeFixedPosRect` / `clampToViewport`) | — |
342
+ | `weifuwu/client` | **移动端原语** | `usePopup`(弹层组合器)/ `useHoverCapable` / `useLongPress` / `useVisualViewport`(触屏友好由构造保证,见 [docs/mobile.md](docs/mobile.md)) | — |
286
343
  | `weifuwu/components` | **92 个组件** | Button/Table/Modal/Confirm/Toast/... + `confirm()` / `toast()` 命令式中间件 | weifuwu/client |
287
- | `weifuwu/layout` | **CSS 布局** | 67 个布局原语 + 141 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
344
+ | `weifuwu/layout` | **CSS 布局** | 70 个布局原语 + 141 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
345
+
346
+ ---
347
+
348
+ ## 能力速查(任务 → API)
349
+
350
+ 按任务场景找入口(完整参考见对应 docs):
351
+
352
+ | 任务 | 用 | 位置 |
353
+ |------|-----|------|
354
+ | 起 HTTP 服务 + 路由 | `serve(app)` + `new Router()` + `app.get/post/...` | [docs/server.md](docs/server.md) |
355
+ | 渲染页面(SPA / SSR+hydrate) | `ui()` + `uiSsr({ routes })`;`createApp()` + `router({ routes })` + `RouteView` | [docs/realtime.md](docs/realtime.md) · [docs/frontend.md](docs/frontend.md) |
356
+ | 数据持久化 | `postgres()` → `` ctx.sql`SELECT *` `` · `redis()` → `ctx.redis` | [docs/data.md](docs/data.md) |
357
+ | 数据管道(SSR 预取/hydration/SPA) | `ctx.data.get(key)` + `asyncComponent` | [docs/frontend.md](docs/frontend.md) |
358
+ | 用户注册/登录/会话/多租户 | `userSystem()` → `ctx.auth` + `/api/auth/*` | [docs/saas.md](docs/saas.md) |
359
+ | 限流防爆破 | `rateLimit()` + `ctx.limit()` | [docs/saas.md](docs/saas.md) |
360
+ | 发邮件 | `email()` → `ctx.email`(Resend/SMTP) | [docs/saas.md](docs/saas.md) |
361
+ | 实时消息/聊天/通知 | `messager()` → `ctx.msg` + `app.ws` | [docs/saas.md](docs/saas.md) |
362
+ | 后台任务/定时 | `queue()` → `ctx.queue` · `scheduler()` → `ctx.schedule/cron` | [docs/saas.md](docs/saas.md) |
363
+ | AI 对话 / Agent / HITL 审批 | `ai()` → `ctx.ai` + `ctx.ui.useChat()` + `AiChat` | [docs/saas.md](docs/saas.md) |
364
+ | GraphQL / WebSocket | `app.graphql(handler)` · `app.ws(path, handler)` | [docs/realtime.md](docs/realtime.md) |
365
+ | 前端 UI 组件 | `weifuwu/components`(92 个:Button/Table/Modal/AiChat/...) | [docs/components.md](docs/components.md) |
366
+ | 布局/主题/暗色 | `weifuwu/layout`(70 原语 + 141 Token) | [docs/layout.md](docs/layout.md) |
367
+ | 样式定制(零自定义 CSS) | `--wf-*` 变量覆盖 + 组件定制钩子 | [docs/styling.md](docs/styling.md) |
368
+ | 移动端适配(tap/长按/键盘/弹层) | `usePopup` / `useHoverCapable` / `useLongPress` / `useVisualViewport` | [docs/mobile.md](docs/mobile.md) |
369
+ | 前后端类型安全中间件 | `createMiddleware`(声明注入即类型化) | [docs/server.md](docs/server.md) |
288
370
 
289
371
  ---
290
372
 
@@ -380,2953 +462,39 @@ const UserProfile = asyncComponent(async (ctx) => {
380
462
 
381
463
  ---
382
464
 
383
- # 后端 API (`weifuwu`)
384
-
385
- > 以下为完整 API 参考,按需查阅。新手建议先阅读上文的「核心概念」和「快速开始」。
386
-
387
- ## Router
388
-
389
- Trie 路由,支持 URL 参数、通配符、中间件链、WebSocket、GraphQL。
390
-
391
- ```ts
392
- import { Router } from 'weifuwu'
393
-
394
- const app = new Router()
395
- ```
396
-
397
- ### 路由方法
398
-
399
- 每个路由方法接受 `path, ...middlewares[], handler`:
400
-
401
- ```ts
402
- app.get('/users', handler)
403
- app.post('/users', handler)
404
- app.put('/users/:id', handler)
405
- app.patch('/users/:id', handler) // PATCH
406
- app.delete('/users/:id', handler) // DELETE
407
- app.head('/users', handler) // HEAD
408
- app.options('/users', handler) // OPTIONS
409
- app.all('/users', handler) // 所有 HTTP 方法
410
- ```
411
-
412
- | 参数 | 类型 | 说明 |
413
- |------|------|------|
414
- | `path` | `string` | 路由路径,支持 `:param` 和 `*` 通配符 |
415
- | `...middlewares` | `Middleware[]` | 路由级中间件(可选) |
416
- | `handler` | `Handler` 或 `Router` | 处理器或子路由器 |
417
-
418
- ### 路由级中间件
419
-
420
- ```ts
421
- declare module 'weifuwu' { interface Context { auth: { userId: string } } }
422
-
423
- const requireAuth: Middleware = (req, ctx, next) => {
424
- if (!req.headers.get('authorization')) return new Response('Unauthorized', { status: 401 })
425
- ;(ctx as any).auth = { userId: '123' }
426
- return next(req, ctx)
427
- }
428
-
429
- app.get('/users/:id', requireAuth, (req, ctx) => {
430
- return Response.json({ id: ctx.params.id, auth: ctx.auth })
431
- })
432
- ```
433
-
434
- ### 参数与查询
435
-
436
- ```ts
437
- app.get('/posts/:category/:slug', (req, ctx) => {
438
- ctx.params.category // URL 参数
439
- ctx.params.slug // URL 参数
440
- ctx.query.page // 查询参数 ?page=1
441
- return Response.json(ctx.params)
442
- })
443
- ```
444
-
445
- ### 通配符
446
-
447
- ```ts
448
- app.get('/files/*', (req, ctx) => {
449
- ctx.params['*'] // 剩余路径 "a/b/c.txt"
450
- return Response.json({ path: ctx.params['*'] })
451
- })
452
- ```
453
-
454
- ### 子路由挂载
455
-
456
- ```ts
457
- const users = new Router()
458
- users.get('/', listUsers)
459
- users.get('/:id', getUser)
460
-
461
- app.mount('/api/users', users)
462
- // → GET /api/users, GET /api/users/:id
463
- ```
464
-
465
- ### 插件模式
466
-
467
- ```ts
468
- app.plugin(app => {
469
- app.get('/health', () => Response.json({ ok: true }))
470
- app.use(cors())
471
- })
472
- ```
473
-
474
- ### 类型安全中间件工厂
475
-
476
- ```ts
477
- import { createMiddleware } from 'weifuwu'
478
- declare module 'weifuwu' { interface Context { greeting: string } }
479
-
480
- const greet = createMiddleware({
481
- injects: ['greeting'], // 注入的 ctx 字段
482
- depends: ['sql'], // 前置依赖(可选)
483
- setup: async (ctx) => ({
484
- greeting: 'Hello ' + ctx.params.name
485
- }),
486
- })
487
-
488
- app.use(greet)
489
- app.get('/hello/:name', (req, ctx) => Response.json({ msg: ctx.greeting }))
490
- ```
491
-
492
- `createMiddleware` 自动生成 `__meta` 元数据用于运行时依赖检查。
493
-
494
- ### 查看路由表
495
-
496
- ```ts
497
- console.log(app.routes())
498
- // → [
499
- // "GET /users",
500
- // "POST /users",
501
- // "WS /chat",
502
- // "MIDDLEWARE [2 global]"
503
- // ]
504
- ```
505
-
506
- ### 路由方法速查
507
-
508
- | 方法 | 说明 |
509
- |------|------|
510
- | `app.get(path, ...mws, handler)` | GET |
511
- | `app.post(path, ...mws, handler)` | POST |
512
- | `app.put(path, ...mws, handler)` | PUT |
513
- | `app.patch(path, ...mws, handler)` | PATCH |
514
- | `app.delete(path, ...mws, handler)` | DELETE |
515
- | `app.head(path, ...mws, handler)` | HEAD |
516
- | `app.options(path, ...mws, handler)` | OPTIONS |
517
- | `app.all(path, ...mws, handler)` | 任意方法 |
518
- | `app.use(mw)` | 全局中间件 |
519
- | `app.ws(path, ...mws, handler)` | WebSocket |
520
- | `app.graphql(path?, handler)` | GraphQL |
521
- | `app.mount(path, subRouter)` | 挂载子路由 |
522
- | `app.plugin(fn)` | 插件扩展现有 Router |
523
- | `app.onError(handler)` | 全局错误处理 |
524
- | `app.wsHub(hub)` | 注入自定义 Hub(多进程 WebSocket) |
525
- | `app.onClose(closeable)` | 注册关闭回调 |
526
- | `app.routes()` | 打印路由表 |
527
- | `app.close()` | 关闭所有注册的 Closeable 资源 |
528
-
529
- ---
530
-
531
- ## serve — HTTP 服务器
532
-
533
- ```ts
534
- import { serve, Router } from 'weifuwu'
535
-
536
- const app = new Router()
537
- const server = serve(app, { port: 3000 })
538
-
539
- // 等待就绪
540
- await server.ready
541
- console.log(server.port) // 实际端口
542
-
543
- // 停止
544
- await server.close()
545
- // 或
546
- await server.stop(2000) // 超时毫秒
547
- ```
548
-
549
- | 选项 | 类型 | 默认值 | 说明 |
550
- |------|------|--------|------|
551
- | `port` | `number` | `0`(随机) | 监听端口 |
552
- | `hostname` | `string` | `'0.0.0.0'` | 监听地址 |
553
- | `signal` | `AbortSignal` | — | 通过信号停止 |
554
- | `maxBodySize` | `number` | `10MB` | 请求体上限(0=无限) |
555
- | `timeout` | `number` | `120000` | Socket 超时(ms,2 分钟,适配 LLM 生成等长任务) |
556
- | `keepAliveTimeout` | `number` | `5000` | Keep-Alive 超时 |
557
- | `headersTimeout` | `number` | `6000` | 请求头超时 |
558
- | `shutdown` | `boolean` | `true` | 自动注册 SIGTERM/SIGINT |
559
-
560
- | 属性/方法 | 类型/签名 | 说明 |
561
- |-----------|----------|------|
562
- | `server.port` | `number` | 实际监听端口(未就绪时 0) |
563
- | `server.hostname` | `string` | 监听地址 |
564
- | `server.ready` | `Promise<void>` | 服务器就绪 |
565
- | `server.close(timeoutMs?)` | `() => Promise<void>` | 优雅关闭 |
566
- | `server.stop(timeoutMs?)` | `() => Promise<void>` | `close` 别名 |
567
-
568
- `sig-server` 自动注册 SIGTERM/SIGINT → `server.closeAllConnections()` → `router.close()` → `process.exit(0)`。
569
-
570
- ---
571
-
572
- ## cors — CORS 中间件
573
-
574
- ```ts
575
- import { cors } from 'weifuwu'
576
-
577
- // 默认:允许所有来源
578
- app.use(cors())
579
-
580
- // 自定义
581
- app.use(cors({
582
- origin: 'https://app.example.com',
583
- methods: ['GET', 'POST'],
584
- allowedHeaders: ['Content-Type', 'Authorization'],
585
- credentials: true,
586
- exposedHeaders: ['X-Total-Count'],
587
- maxAge: 86400,
588
- }))
589
- ```
590
-
591
- | 选项 | 类型 | 默认值 | 说明 |
592
- |------|------|--------|------|
593
- | `origin` | `string \| string[] \| (origin) => string` | `'*'` | `credentials: true` 时自动回显请求 origin |
594
- | `methods` | `string[]` | `GET,HEAD,PUT,PATCH,POST,DELETE` | 允许的方法 |
595
- | `allowedHeaders` | `string[]` | `Content-Type, Authorization` | 允许的请求头 |
596
- | `exposedHeaders` | `string[]` | — | 暴露的响应头 |
597
- | `credentials` | `boolean` | — | 是否允许凭据 |
598
- | `maxAge` | `number` | — | 预检缓存秒数 |
599
-
600
- ---
601
-
602
- ## serveStatic — 静态文件服务
603
-
604
- ```ts
605
- import { serveStatic } from 'weifuwu'
606
-
607
- // 作为全局中间件(未匹配到文件时走下一个中间件)
608
- app.use(serveStatic('./public'))
609
-
610
- // 或挂载到特定路径
611
- app.get('/assets/*', serveStatic('./assets', {
612
- index: 'index.html',
613
- maxAge: 31536000,
614
- immutable: true,
615
- }))
616
- ```
617
-
618
- | 选项 | 类型 | 默认值 | 说明 |
619
- |------|------|--------|------|
620
- | `index` | `string` | `'index.html'` | 目录索引文件名 |
621
- | `maxAge` | `number` | `0` | Cache-Control max-age(秒) |
622
- | `immutable` | `boolean` | — | 添加 `immutable` 指令(需 maxAge) |
623
-
624
- 特性:
625
- - ETag/304 缓存协商(`if-none-match` + `if-modified-since`)
626
- - MIME 类型自动检测(支持 30+ 扩展名)
627
- - 目录遍历保护(`..` / symlink 逃逸 → 403)
628
- - 目录自动跳转到 index 文件
629
465
 
630
466
  ---
631
467
 
632
- ## postgres — PostgreSQL 客户端(自研)
633
-
634
- > **自研 PG v3 协议**(零第三方依赖)——支持 SCRAM-SHA-256 认证、扩展查询(参数化)、类型映射(int8 超范围自动 string 防丢精度)、事务、连接池(acquire 超时防饿死)、schema 写前校验、statement_timeout 慢查询保护。
635
-
636
- ```ts
637
- import { postgres } from 'weifuwu'
638
-
639
- // 注入 ctx.sql(懒连接池)
640
- app.use(postgres())
641
-
642
- // ① tagged template —— 插值自动参数化(防注入)
643
- app.get('/users', async (req, ctx) => {
644
- const users = await ctx.sql`SELECT * FROM users WHERE id = ${ctx.params.id}`
645
- return Response.json(users)
646
- })
647
-
648
- // ② jsonb 对象直传——自动序列化,不再有双重编码/parseRow 样板
649
- app.post('/decks', async (req, ctx) => {
650
- const deck = await req.json()
651
- await ctx.sql`INSERT INTO decks (title, deck_json) VALUES (${deck.title}, ${deck})`
652
- // 读回来自动是对象:rows[0].deck_json === { slides: [...] }(不是字符串)
653
- })
654
-
655
- // ③ 事务(postgres.js 兼容 begin)
656
- app.post('/transfer', async (req, ctx) => {
657
- await ctx.sql.begin(async sql => {
658
- await sql`UPDATE accounts SET balance = balance - 100 WHERE id = 1`
659
- await sql`UPDATE accounts SET balance = balance + 100 WHERE id = 2`
660
- })
661
- })
662
- ```
663
-
664
- ### 类型映射(自动)
665
-
666
- | 数据库类型 | 返回 JS 类型 |
667
- |-----------|-------------|
668
- | json / jsonb | `object`(自动 JSON.parse) |
669
- | int2 / int4 / int8(安全范围内) | `number` |
670
- | **int8(超出安全范围)** | **`string`**(防静默丢精度,金额/ID 关键) |
671
- | float / numeric | `number` |
672
- | boolean | `boolean` |
673
- | text / varchar / uuid | `string` |
674
- | **timestamptz** | **`Date`**(带时区,ISO 解析无本地时区魔法) |
675
- | timestamp / date / interval | `string`(无时区语义——转 Date 按本地时区解析即时区魔法,诚实裁剪不转) |
676
- | NULL | `null` |
468
+ ## 文档导航
677
469
 
678
- ### 类型层(查询泛型 + schema 写前校验)
679
-
680
- ```ts
681
- // ① 查询结果泛型(编译期类型,无需手写 interface + 断言)
682
- interface Deck { id: number; title: string; deck_json: { slides: unknown[] } }
683
- const decks = await ctx.sql.query<Deck>('SELECT id, title, deck_json FROM decks')
684
-
685
- // ② schema 注册 → insert 写前校验(脏数据源头拦截)
686
- ctx.sql.register('decks', {
687
- title: { type: 'text', required: true },
688
- status: { type: 'enum', values: ['outline', 'ready'] },
689
- deck_json: { type: 'jsonb' },
690
- })
691
- await ctx.sql.insert('decks', { title: 'x', status: 'INVALID' }) // → ValidationError
692
- ```
470
+ README 只保留入门内容(设计理念 / 快速开始 / 核心概念 / 模块总览)。完整 API 参考按**开发者角色**拆分在 `docs/`,设计与计划文档在 `design/`:
693
471
 
694
- ### 方法面
472
+ ### 后端开发者
695
473
 
696
- | 方法 | 说明 |
474
+ | 文档 | 内容 |
697
475
  |------|------|
698
- | `ctx.sql\`...\`` | tagged template 参数化查询(插值=参数,表名需硬编码) |
699
- | `ctx.sql.query<T>(sql, params?)` | 参数化查询 + 泛型 |
700
- | `ctx.sql.unsafe(sql, params?)` | 原生 SQLDDL / 动态表名) |
701
- | `ctx.sql.begin(fn)` | 事务(回调收到 tagged template sql) |
702
- | `ctx.sql.transaction(fn)` | 事务(回调收到 `{ query }`) |
703
- | `ctx.sql.register(table, schema)` | 注册表结构(写前校验) |
704
- | `ctx.sql.insert(table, row)` | schema 校验 + 参数化插入 |
705
- | `ctx.sql.insertMany(table, rows[], { batchSize? })` | **批量插入**:多行 VALUES 单次往返(默认 500/批;所有行键必须一致) |
706
- | `ctx.sql.update(table, set, where, { returning? })` | **参数化 UPDATE**:SET/WHERE 全部参数化,返回 `affectedRows` |
707
- | `ctx.sql.delete(table, where)` | **参数化 DELETE**:WHERE 必填(防全表误删),返回 `affectedRows` |
708
- | `ctx.sql\`...\` 内嵌片段` | 条件 SQL 片段(嵌套过滤,参数自动重编号) |
709
- | `ctx.sql.close()` | 关闭连接池 |
710
-
711
- ### 影响行数(affectedRows)
712
-
713
- `INSERT / UPDATE / DELETE / MERGE` 的返回行数组带**非枚举** `affectedRows` 属性(不干扰 `deepEqual`/`JSON.stringify`):
714
-
715
- ```ts
716
- const r = await ctx.sql`UPDATE messages SET read = true WHERE id = ${id}`
717
- if (r.affectedRows === 0) return new Response('not found', { status: 404 })
718
- ```
719
-
720
- ```ts
721
- // 批量插入:100 行 1 次往返
722
- await ctx.sql.insertMany('agent_logs', logs, { batchSize: 500 })
723
- // 语义化更新/删除:WHERE 全参数化 + 返回影响行数
724
- await ctx.sql.update('users', { role: 'admin' }, { id: userId })
725
- await ctx.sql.delete('messages', { id: msgId })
726
-
727
- ### 条件片段(嵌套过滤)
728
-
729
- ```ts
730
- const status = req.query.status // 可能为空
731
- const rows = await ctx.sql`
732
- SELECT * FROM orders WHERE amount > ${100}
733
- ${status ? ctx.sql`AND status = ${status}` : ctx.sql``}
734
- `
735
- // 空片段内联为空,参数自动重编号——同一 SQL 无论条件多少都安全参数化
736
- ```
737
-
738
- ### 选项
739
-
740
- | 选项 | 类型 | 默认值 | 说明 |
741
- |------|------|--------|------|
742
- | `connection` | `string` | `DATABASE_URL` | 连接字符串 |
743
- | `max`(或 `poolSize`) | `number` | `10` | 连接池大小 |
744
- | `acquireTimeoutMs` | `number` | `30000` | 池全忙时 acquire 超时(防饿死,0=无限) |
745
- | `statementTimeoutMs`(或 `statementTimeout`) | `number` | `0` | 语句超时(慢查询保护,0=禁用) |
746
- | `idleTimeoutMs` | `number` | `0` | 空闲连接回收(超时未用关闭,容量收缩后自动重建;0=禁用) |
747
- | `onQuery` | `(sql, durationMs, rowCount, traceId?) => void` | — | 查询观测钩子;第 4 参数为请求级 traceId(`x-trace-id` 头经 ALS 传播) |
748
-
749
- ### 幂等迁移(内置)
750
-
751
- `postgres()` 返回的中间件自带迁移跟踪(`_weifuwu_migrations` 表),模块启动时检查-执行-记录三步幂等:
752
-
753
- ```ts
754
- const db = postgres()
755
- await db.migrate() // ① 建迁移跟踪表(幂等)
756
-
757
- if (!(await db.isMigrated('users'))) { // ② 检查是否已迁移
758
- await db.sql.unsafe(`CREATE TABLE users (...)`)
759
- await db.markMigrated('users') // ③ 记录(幂等,重复调用无害)
760
- }
761
-
762
- app.use(db)
763
- ```
764
-
765
- > 多副本部署时天然安全:`markMigrated` 用 `ON CONFLICT DO NOTHING`,两个实例同时迁移也不会重复执行。
766
-
767
- ### 错误映射(自动)
768
-
769
- `ctx.sql` 查询错误自动映射为 `HttpError`,业务无需手写 catch:
770
-
771
- | 错误码 | 含义 | HTTP |
772
- |--------|------|------|
773
- | `23505` | 唯一约束冲突 | **409** |
774
- | `23503` / `23502` / `23514` | 外键 / 非空 / 检查约束 | **400** |
775
- | `22P02` / `22003` | 类型 / 数值错误 | **400** |
776
-
777
- > 未映射的错误码原样抛出(带 `code` 属性,如 `42P01` 表不存在)。
778
-
779
- > **裁剪声明**:逻辑复制 / 大对象 / 显式游标 / 二进制 COPY 不支持(明确抛 `ProtocolError('unsupported')`,而非静默出错)。
780
-
781
- ---
782
-
783
- ## redis — Redis 客户端(自研)
784
-
785
- > **自研 RESP2 协议**(零第三方依赖)——连接/重连(断线 pending 拒绝、指数退避)/离线队列/管道/Pub-Sub(订阅断线自动重放)+ 消除 ioredis 高频痛点(TTL 参数顺序、JSON 手动序列化、缓存样板)。**二进制安全**:`getBuffer(key)` 原样返回字节(缓存序列化 payload 不损坏)。
786
-
787
- ```ts
788
- import { redis } from 'weifuwu'
789
-
790
- app.use(redis())
791
-
792
- // ① TTL 安全 —— 直接传秒,不会写错
793
- app.post('/cache/:key', async (req, ctx) => {
794
- const { value } = await req.json()
795
- await ctx.redis.set(ctx.params.key, value, 3600) // ioredis 要 set(k, v, 'EX', 3600)
796
- })
476
+ | [docs/server.md](docs/server.md) | HTTP 服务层:Router / serve / cors / serveStatic / HttpError / 响应辅助 / parseBody |
477
+ | [docs/data.md](docs/data.md) | 数据层:postgres(PG v3 自研协议)/ redis(RESP2 自研协议) |
478
+ | [docs/realtime.md](docs/realtime.md) | 实时与渲染:scheduler / uiSSR + JS/CSS 编译)/ graphql / WebSocket |
479
+ | [docs/saas.md](docs/saas.md) | SaaS 地基:rateLimit / email / userSystem / messager / queue / ai |
797
480
 
798
- // ② JSON 零样板 —— 自动序列化(AI 缓存场景)
799
- app.get('/cache/:key', async (req, ctx) => {
800
- const val = await ctx.redis.jsonGet(ctx.params.key) // 自动 JSON.parse
801
- return Response.json(val ?? { miss: true })
802
- })
803
-
804
- // ③ 缓存便捷 —— 读-算-写一体,null 不缓存(防穿透)
805
- app.get('/llm/:id', async (req, ctx) => {
806
- const result = await ctx.redis.cache(`llm:${ctx.params.id}`, async () => {
807
- return await generateLLM(ctx.params.id) // miss 才执行
808
- }, 3600)
809
- return Response.json(result)
810
- })
811
-
812
- // ④ Pub/Sub —— 发布用 ctx.redis,订阅用独立连接(回调式,断线自动重连恢复订阅)
813
- app.post('/events', async (req, ctx) => {
814
- await ctx.redis.publish('events', JSON.stringify({ type: 'deck.created' }))
815
- })
816
-
817
- const sub = ctx.redis.createSubscriber()
818
- await sub.connect()
819
- await sub.subscribe('events', (channel, message) => {
820
- // 收到实时消息
821
- })
822
- await sub.psubscribe('jobs:*', (channel, message) => {
823
- // 模式匹配订阅
824
- })
481
+ ### 前端开发者
825
482
 
826
- // 任意命令透传 + keyPrefix 隔离
827
- await ctx.redis.command('LRANGE', 'list', '0', '-1')
828
-
829
- app.use(redis({ keyPrefix: 'api:' })) // 之后所有 key 自动加前缀
830
- await ctx.redis.set('user', 1) // 实际写入 'api:user'
831
- ```
832
-
833
- ### 方法面
834
-
835
- | 方法 | 说明 |
483
+ | 文档 | 内容 |
836
484
  |------|------|
837
- | `get / set(key, val, ttl?) / del / incr / expire / ttl` | 基础命令(set 直接传秒) |
838
- | `jsonGet / jsonSet(key, val, ttl?)` | JSON 自动序列化 |
839
- | `cache(key, fn, ttl)` | 缓存读-算-写(null 不缓存防穿透) |
840
- | `publish(channel, msg)` | Pub-Sub 发布 |
841
- | `createSubscriber()` | 独立订阅连接(`subscribe`/`psubscribe` 回调式) |
842
- | `hset / hget / hgetall / hdel` | hash 字段读写(`hgetall` → `Record`,缺失 `{}`) |
843
- | `lpush / rpush / lpop / rpop / lrange` | list 队列操作(`lrange` 支持负数区间) |
844
- | `sadd / srem / smembers` | set 成员操作(`sadd` 重复不加) |
845
- | `zadd / zrange` | zset 有序集(score 升序) |
846
- | `mget / mset / exists / setnx / incrby` | 批量读写 / 存在性 / 原子设值(锁基础)/ 增量 |
847
- | `pipeline()` | 管道:批量命令一次往返(池级,key 自动加前缀) |
848
- | `command(name, ...args)` | 底层命令透传 |
849
- | `close()` | 关闭连接池 |
850
-
851
- | 选项 | 类型 | 默认值 | 说明 |
852
- |------|------|--------|------|
853
- | `url` | `string` | `REDIS_URL` 环境变量 | 连接字符串 |
854
- | `poolSize` | `number` | `5` | 连接池大小 |
855
- | `keyPrefix` | `string` | `''` | 所有 key 自动加前缀(多应用隔离) |
856
- | `commandTimeoutMs` | `number` | `0` | 命令超时(阻塞命令 resolve(null);防挂起。0=禁用) |
857
- | `socketTimeoutMs` | `number` | `0` | socket 响应超时(僵尸连接自愈:pending 有命令且超时无数据 → 主动断开重连。0=禁用) |
858
-
859
- > **连接健康**:断线自动剔除死连接并重建(池不萎缩);`CLIENT KILL`/网络抖动后服务自愈,命令不命中死连接。
860
-
861
- > **裁剪声明**:集群(MOVED 路由)/ 哨兵 / 自动管道不支持(standalone 优先)。
862
-
863
- ---
864
-
865
- ## scheduler — 计划任务(即时/延时/cron)
866
-
867
- > 依赖 `queue`(触发后入队执行)。三类任务:即时(queue.add 已有)、延时(`ctx.schedule`)、定时(`ctx.cron`)。
868
-
869
- ```ts
870
- import { queue, scheduler } from 'weifuwu'
871
-
872
- const q = queue()
873
- app.use(q)
874
- app.use(scheduler({ queue: q })) // 依赖 ctx.queue(触发后入队)
875
-
876
- // 延时任务(单次):delayMs 或指定时间
877
- await ctx.schedule('email.send', { to, body }, { delayMs: 30_000 })
878
- await ctx.schedule('report.build', {}, { when: new Date('2026-09-01T00:00:00Z') })
879
-
880
- // cron 定时任务(重复):每分钟触发 → 入队执行
881
- ctx.cron('* * * * *', 'heartbeat.check', { scope: 'health' })
882
- // 改需求 = 重新注册(同 name 覆盖更新,旧定义不残留)
883
- ctx.cron('*/5 * * * *', 'heartbeat.check', { scope: 'health' })
884
- // 停用 = cancel(删定义 + 清理 pending 触发点)
885
- await ctx.cancelCron('heartbeat.check')
886
-
887
- // 执行端:与 queue 完全一致
888
- const worker = ctx.queue.worker('email.send', async (job) => { ... })
889
- ```
890
-
891
- - **延时**:ZSET(score=触发时间戳)+ 守护循环(独立连接)→ 到期 `ZREM` 原子抢占(多实例不重复)→ `queue.add`
892
- - **多应用隔离**:`scheduler({ prefix })`——ZSET/HASH 应用级共享,多应用共用 redis 时必须各自 prefix(同应用多实例共享 prefix = 协作消费)
893
- - **cron**:HASH 注册表(**field = name,同 name 重新注册 = 覆盖更新**,改表达式不残留旧定义)+ 滚动生成触发点(`ZADD NX` 幂等)→ 复用延时链路;`nextRunAt` 原子推进
894
- - **取消**:`ctx.cancelCron(name)` 删定义 + 清理 pending 触发点(停用 cron 必须 cancel——定义无 TTL 会累积)
895
- - **崩溃恢复**:未消费触发点留在 ZSET,重启后补扫立即触发(at-least-once,幂等由业务保证)
896
- - **cron 表达式**:5 字段(分 时 日 月 周),支持 `*`/步进/列表/范围;时区 = 服务器本地;非法表达式注册即抛错
897
- - **裁剪**:❌ cron 秒/年/别名(@daily)/特殊字符(L/W/#)、时区配置、单次任务取消(v2)、分布式锁(原子命令抢占替代)
898
- - **文档红线**:cron 定义持久化在 HASH——进程重启后守护循环恢复即继续触发(无需重新注册);**停用必须 `cancelCron`**(定义无 TTL,不取消会永久触发)
899
-
900
- ## ui — SSR 渲染 + JS/CSS 编译
901
-
902
- ```ts
903
- import { ui } from 'weifuwu'
904
-
905
- app.use(ui())
906
- ```
907
-
908
- | ctx 注入 | 签名 | 说明 |
909
- |----------|------|------|
910
- | `ctx.ui.html` | `` (strings, ...values) => Response `` | HTML 模板 (转义防 XSS) |
911
- | `ctx.ui.html.unsafe(str)` | `(string) => string` | 插入原始 HTML |
912
- | `ctx.ui.js(entryPath)` | `(string) => Promise<Response>` | esbuild 编译 TSX → JS bundle |
913
- | `ctx.ui.css(entryPath)` | `(string) => Promise<Response>` | 读取 CSS 文件 → CSS Response(如安装 postcss + @tailwindcss/postcss 则自动编译) |
914
- | `ctx.ui.ssr(Comp, props?, { data })` | `(Component, props, opts?) => Promise<string>` | 服务端渲染组件 → HTML 片段(async 工厂自动 await;HtmlSafe 内联不二次转义) |
915
- | `ctx.ui.ssrData(data)` | `(Map) => string` | 序列化 SSR 数据 → `<script>window.__DATA__=...</script>`(`<` 转义防 XSS) |
916
-
917
- ### ctx.ui.html — HTML 模板
485
+ | [docs/frontend.md](docs/frontend.md) | 前端核心:createApp / 组件模型 / 状态管理 / 条件与列表 / ref / 类型 |
486
+ | [docs/frontend-middleware.md](docs/frontend-middleware.md) | 前端中间件:router / api / auth / ws / i18n / ErrorBoundary / confirm / toast / ScrollLock / extendCtx |
487
+ | [docs/components.md](docs/components.md) | 组件库(92 个组件 + 使用示例 + 组件列表) |
488
+ | [docs/layout.md](docs/layout.md) | 布局系统:70 个布局原语 + 141 个主题 Token |
489
+ | [docs/styling.md](docs/styling.md) | 样式定制指南:零自定义 CSS 模式 / 暗色 / 组件级覆盖 / 作用域主题 |
918
490
 
919
- 模板插值自动转义(`& < > "` → 实体),防 XSS:
491
+ ### 通用
920
492
 
921
- ```ts
922
- app.get('/page', (req, ctx) => ctx.ui.html`
923
- <h1>${title}</h1> <!-- 自动转义 -->
924
- <div>${ctx.ui.html.unsafe(richHtml)}</div> <!-- 不转义 -->
925
- `)
926
- ```
927
-
928
- ### ctx.ui.js — 编译 TSX → JS
929
-
930
- ```ts
931
- app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx')) // 相对路径
932
- app.get('/app.js', (req, ctx) => ctx.ui.js('weifuwu/client')) // 或包名
933
- ```
934
-
935
- 使用 esbuild 编译:
936
- - `bundle: true`, `format: 'esm'`, `platform: 'browser'`
937
- - `jsx: 'automatic'`, `jsxImportSource: 'weifuwu/client'`
938
- - 带 mtime 缓存验证(开发时编辑文件后自动失效)
939
-
940
- ### ctx.ui.css — CSS 编译
941
-
942
- ```ts
943
- app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css')) // 相对路径
944
- app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css')) // 或包名
945
- ```
946
-
947
- - 无编译工具时直接返回原始 CSS
948
- - 检测到已安装 `postcss` + `@tailwindcss/postcss` 时自动编译 Tailwind CSS
949
- - 支持包名(`weifuwu/layout/style.css`, `weifuwu/components/style.css`)或文件路径
950
- - 带 mtime 缓存验证(开发时编辑文件后自动失效)
951
-
952
- ### ctx.ui.ssr — SSR 渲染组件 → HTML
953
-
954
- 将组件(含 async 工厂组件)在服务端渲染为完整 HTML 片段,数据经 `ctx.data` 预取并序列化进 `window.__DATA__`(客户端 hydration 时同步命中,不重跑请求):
955
-
956
- ```ts
957
- const BlogPage = asyncComponent(async (ctx) => {
958
- const post = await ctx.data.get(`/api/posts/${ctx.params.slug}`, fetchPost)
959
- return (_init, ctx) => () =>
960
- h('article', {},
961
- h('h1', {}, post.title),
962
- h('div', { innerHTML: post.body }),
963
- )
964
- })
965
-
966
- app.get('/blog/:slug', async (req, ctx) => {
967
- const data = new Map()
968
- const html = await ctx.ui.ssr(BlogPage, {}, { data }) // HtmlSafe:模板内联不二次转义
969
- return ctx.ui.html`
970
- <!DOCTYPE html>
971
- <html><body>
972
- <div id="root">${html}</div>
973
- ${ctx.ui.ssrData(data)}
974
- <script src="/static/app.js"></script>
975
- </body></html>
976
- `
977
- })
978
- ```
979
-
980
- - 事件处理器/ref 剥离,文本自动转义(XSS),`class`/`style` 对象序列化,`innerHTML` 原样输出
981
- - Fragment/Portal 子节点就地内联
982
- - `ctx.ui.ssrData(data)` 输出 `<script>window.__DATA__=...</script>`(JSON `<` 转义防 XSS)
983
- - 服务端 ctx shim:`$`(dirty no-op)、`ctx.data` 预取去重、`selfId` 请求级隔离
984
-
985
- ### Hydration — 客户端收养服务端 HTML
986
-
987
- 服务端 HTML + `window.__DATA__`(ctx.data 种子)到达客户端后,`mount(..., { hydrate: true })` **收养现有 DOM**(不重建、不闪跳),只接线事件/ref/$:
988
-
989
- ```ts
990
- import { createApp } from 'weifuwu/client'
991
-
992
- createApp()
993
- .mount('#root', BlogPage, { hydrate: true }) // 容器已有服务端 HTML
994
- ```
995
-
996
- - **游标收养**:元素/文本按位置匹配现有 DOM;tag 不匹配 → 局部替换;文本不一致 → 就地修正;服务端多余节点 → 收尾清理
997
- - **async 工厂 hydration**:工厂 `ctx.data.get` 从 `__DATA__` 同步命中(不重跑请求)→ 渲染与服务端一致 → 收养
998
- - hydration 后 `$`/dirty/事件全量可用(与纯 SPA 无差别)
999
- - 诚实裁剪:Portal 内容就地收养(不移动到 `#__wf_portal`);渲染期非确定性(Date/random)会导致 mismatch(dev 警告)
1000
-
1001
- ### uiSsr — 路由级 SSR(声明即渲染)
1002
-
1003
- 共享路由定义,前后端同一份声明——后端匹配即自动 SSR,无需手写 handler/模板/序列化:
1004
-
1005
- ```tsx
1006
- // routes.tsx —— 前后端共用
1007
- import type { RouteDef } from 'weifuwu/client'
1008
- import { BlogPage } from './pages/BlogPage.tsx'
1009
-
1010
- export const routes: RouteDef[] = [
1011
- { path: '/blog/:slug', component: BlogPage, title: '博客' },
1012
- ]
1013
-
1014
- // server.ts —— 一行中间件:GET 匹配 → 注入 ctx.route.params → await 组件工厂 → 完整 HTML + __DATA__ + bundle
1015
- import { uiSsr } from 'weifuwu'
1016
- app.use(uiSsr({ routes, bundle: '/static/blog.js' }))
1017
-
1018
- // blog-hydrate.ts —— 客户端:同一份 routes,router() 注入 ctx.route.params(两端同源)
1019
- createApp()
1020
- .use(router({ routes }))
1021
- .mount('#root', routes[0].component, { hydrate: true })
1022
- ```
1023
-
1024
- - 组件工厂读 `ctx.route.params`(`/blog/:slug` → `ctx.route.params.slug`)——后端 uiSsr / 前端 router **同源注入**
1025
- - 未匹配 → next()(交给 API/静态/404);非 GET → next()
1026
- - 可自定义 `title` / `template`
1027
-
1028
- ### weifuwu/dev — 服务端直接跑 .tsx
1029
-
1030
- Node 原生 TS 只剥离类型(不支持 JSX)。`weifuwu/dev` 注册 esbuild loader,服务端直接跑 `.tsx`(零构建):
1031
-
1032
- ```json
1033
- {
1034
- "scripts": {
1035
- "dev": "node --import weifuwu/dev server.ts",
1036
- "start": "node --import weifuwu/dev server.ts"
1037
- }
1038
- }
1039
- ```
1040
-
1041
- - 前后端同一 JSX 运行时(`jsxImportSource: weifuwu/client`)→ 两端 VNode 一致 → hydration 可靠
1042
- - 与 `ctx.ui.js` 前端动态编译同一理念:无构建、无产物、改代码即生效
1043
-
1044
- ---
1045
-
1046
- ## graphql — GraphQL 端点
1047
-
1048
- > **SDL + resolvers 绑定为自研实现**(`makeExecutableSchema`,56 行替代 @graphql-tools/schema)——支持根类型与嵌套类型字段 resolver、默认属性查找。
1049
-
1050
- ```ts
1051
- import type { GraphQLHandler } from 'weifuwu'
1052
-
1053
- const handler: GraphQLHandler = async (req, ctx) => ({
1054
- schema: `
1055
- type Query {
1056
- hello: String
1057
- users: [User]
1058
- }
1059
- type User { id: ID, name: String }
1060
- `,
1061
- resolvers: {
1062
- Query: {
1063
- hello: () => 'world',
1064
- users: () => [{ id: 1, name: 'Alice' }],
1065
- },
1066
- },
1067
- rootValue: {},
1068
- context: (req, ctx) => ({ user: ctx.user }),
1069
- graphiql: true,
1070
- maxDepth: 10,
1071
- timeout: 30000,
1072
- })
1073
-
1074
- // 挂载到 /
1075
- app.graphql(handler)
1076
-
1077
- // 或挂载到自定义路径
1078
- app.graphql('/graphql', handler)
1079
- ```
1080
-
1081
- | 选项 | 类型 | 默认值 | 说明 |
1082
- |------|------|--------|------|
1083
- | `schema` | `string \| GraphQLSchema` | — | SDL 字符串或 Schema 对象 |
1084
- | `resolvers` | `any` | — | 解析器(schema 为字符串时必填)|
1085
- | `rootValue` | `any` | — | 根值 |
1086
- | `context` | `(req, ctx) => object` | — | 上下文工厂 |
1087
- | `graphiql` | `boolean` | — | 启用 GraphiQL IDE |
1088
- | `maxDepth` | `number` | `10` | 查询深度限制(0=关闭)|
1089
- | `timeout` | `number` | `30000` | 执行超时(ms,0=关闭)|
1090
-
1091
- GET 请求支持 query 参数查询;POST 支持 JSON body。启用 `graphiql: true` 时,GET 无 `?query=` 参数返回 GraphiQL IDE 页面。
1092
-
1093
- ---
493
+ | 文档 | 内容 |
494
+ |------|------|
495
+ | [docs/examples.md](docs/examples.md) | 组合场景示例:登录表单 / 数据列表 + 搜索 / 消息提示 |
496
+ | [docs/environment.md](docs/environment.md) | 环境变量与开发命令 |
497
+ | [docs/mobile.md](docs/mobile.md) | 移动端开发指南:断点 / 44px 命中区 / usePopup / 手势 / safe-area |
498
+ | [design/](design/) | 设计与计划文档(组件地图 / AI 协议契约 / 移动端指南 / 数据库客户端计划 / 设计系统 / 各阶段计划) |
1094
499
 
1095
- ## WebSocket
1096
-
1097
- ```ts
1098
- app.ws('/chat/:room', {
1099
- open(ws, ctx) {
1100
- ws.send(`欢迎加入 ${ctx.params.room} 房间`)
1101
- ctx.hub?.join(ctx.params.room, ws)
1102
- },
1103
- message(ws, ctx, data) {
1104
- // data: string | Buffer
1105
- ctx.hub?.send(ctx.params.room, `用户: ${data}`)
1106
- },
1107
- close(ws, ctx) {
1108
- ctx.hub?.leave(ws)
1109
- },
1110
- error(ws, ctx, error) {
1111
- console.error('WS error:', error)
1112
- },
1113
- })
1114
- ```
1115
-
1116
- | 回调 | 参数 | 说明 |
1117
- |------|------|------|
1118
- | `open(ws, ctx)` | `WebSocket`, `Context` | 连接建立 |
1119
- | `message(ws, ctx, data)` | `WebSocket`, `Context`, `string \| Buffer` | 收到消息 |
1120
- | `close(ws, ctx)` | `WebSocket`, `Context` | 连接关闭 |
1121
- | `error(ws, ctx, error)` | `WebSocket`, `Context`, `Error` | 错误 |
1122
-
1123
- ### Hub — WebSocket 房间
1124
-
1125
- ```ts
1126
- // 注入 hub → ctx.hub
1127
- app.ws('/chat/:room', {
1128
- open(ws, ctx) { ctx.hub.join(ctx.params.room, ws) },
1129
- message(ws, ctx, data) { ctx.hub.send(ctx.params.room, String(data)) },
1130
- close(ws, ctx) { ctx.hub.leave(ws) },
1131
- })
1132
-
1133
- // 自定义 Hub(Redis 后端)
1134
- import type { Hub } from 'weifuwu'
1135
- const redisHub: Hub = { ... }
1136
- app.wsHub(redisHub)
1137
- ```
1138
-
1139
- | Hub 方法 | 说明 |
1140
- |----------|------|
1141
- | `join(key, ws)` | WebSocket 加入房间 |
1142
- | `leave(ws)` | WebSocket 离开所有房间 |
1143
- | `send(key, message)` | 向房间广播消息 |
1144
- | `close()` | 关闭 Hub |
1145
-
1146
- WebSocket 原生 `ws.send()` 发送,`ws.on('message', cb)` WebSocket 接收。
1147
-
1148
- > **实时应用推荐用 `messager()`**(SaaS 地基模块):协议内置(`connected/subscribe/ping`)+ 持久化 + 跨进程广播 + 点对点,不必自写 Hub/协议——见[消息系统章节](#messager--消息系统)。
1149
-
1150
- ---
1151
-
1152
- ## HttpError — HTTP 错误
1153
-
1154
- ```ts
1155
- import { HttpError } from 'weifuwu'
1156
-
1157
- app.get('/secure', () => {
1158
- if (!condition) throw new HttpError('Forbidden', 403)
1159
- // serve() 自动捕获并返回对应状态码
1160
- })
1161
- ```
1162
-
1163
- | API | 说明 |
1164
- |-----|------|
1165
- | `new HttpError(msg, status)` | 创建 HTTP 错误,name = 'HttpError' |
1166
-
1167
- > 请求体上限常量 `DEFAULT_MAX_BODY`(10MB)见上方 serve 选项表 `maxBodySize`。
1168
-
1169
- ---
1170
-
1171
- ## 响应辅助函数
1172
-
1173
- > 以下为完整 API 参考,按需查阅。五个 SaaS 地基模块(rateLimit / email / userSystem / messager / queue)见文末「SaaS 地基模块」章节。
1174
-
1175
- 消除 `Response.json(...)` 重复模式:
1176
-
1177
- ```ts
1178
- import { ok, created, noContent, badRequest, unauthorized, forbidden, notFound, conflict, unprocessable, tooManyRequests, serverError, redirect } from 'weifuwu'
1179
-
1180
- app.get('/users/:id', async (req, ctx) => {
1181
- const user = await findUser(ctx.params.id)
1182
- if (!user) return notFound('用户不存在')
1183
- return ok(user)
1184
- })
1185
-
1186
- app.post('/users', async (req, ctx) => {
1187
- const body = await parseBody(req)
1188
- const user = await createUser(body)
1189
- return created(user)
1190
- })
1191
- ```
1192
-
1193
- | 函数 | 状态码 | Content-Type |
1194
- |------|--------|-------------|
1195
- | `ok(data, init?)` | 200 | `application/json` |
1196
- | `created(data, init?)` | 201 | `application/json` |
1197
- | `noContent(init?)` | 204 | — |
1198
- | `badRequest(msg?)` | 400 | `application/json` |
1199
- | `unauthorized(msg?)` | 401 | `application/json` |
1200
- | `forbidden(msg?)` | 403 | `application/json` |
1201
- | `notFound(msg?)` | 404 | `application/json` |
1202
- | `conflict(msg?)` | 409 | `application/json` |
1203
- | `unprocessable(msg?)` | 422 | `application/json` |
1204
- | `tooManyRequests(msg?)` | 429 | `application/json` |
1205
- | `serverError(msg?)` | 500 | `application/json` |
1206
- | `redirect(url, status?)` | 302 (默认) | — |
1207
-
1208
- ---
1209
-
1210
- ## parseBody — 请求体解析
1211
-
1212
- ```ts
1213
- import { parseBody } from 'weifuwu'
1214
-
1215
- app.post('/users', async (req, ctx) => {
1216
- const body = await parseBody<{ name: string; email: string }>(req)
1217
- // JSON 解析失败自动 throw HttpError(400)
1218
- return ok(body)
1219
- })
1220
- ```
1221
-
1222
- | 行为 | 说明 |
1223
- |------|------|
1224
- | JSON 格式正确 | 返回解析后的数据 |
1225
- | JSON 格式错误 | `throw new HttpError('Invalid JSON body', 400)` |
1226
- | GET/HEAD 请求 | 返回 `{}` |
1227
-
1228
- ---
1229
-
1230
- ## 后端类型
1231
-
1232
- ```ts
1233
- import type { Context, Handler, Middleware, ErrorHandler, User, Closeable } from 'weifuwu'
1234
- import type { HttpError } from 'weifuwu'
1235
- import type { ServeOptions, Server } from 'weifuwu'
1236
- import type { Hub, WebSocketHandler } from 'weifuwu'
1237
- import type { WebSocket } from 'weifuwu'
1238
- import type { CORSOptions } from 'weifuwu'
1239
- import type { ServeStaticOptions } from 'weifuwu'
1240
- import type { PostgresOptions, PostgresClient, PostgresInjected } from 'weifuwu'
1241
- import type { RedisOptions, RedisClient, RedisInjected } from 'weifuwu'
1242
- import type { MessagerOptions, MessagerClient, MessagerInjected } from 'weifuwu'
1243
- import type { GraphQLOptions, GraphQLHandler } from 'weifuwu'
1244
- ```
1245
-
1246
- | 类型 | 签名 | 说明 |
1247
- |------|------|------|
1248
- | `Context` | `interface` | `{ params, query, mountPath, user, loaderData?, env?, [key]: unknown }` |
1249
- | `Handler<T>` | `(req: Request, ctx: T) => Response \| Promise<Response>` | 请求处理器 |
1250
- | `Middleware<In, Out>` | `(req, ctx: In, next) => Response` | 中间件,含 `__meta` |
1251
- | `ErrorHandler<T>` | `(error, req, ctx: T) => Response` | 错误处理器 |
1252
- | `Closeable` | `interface` | `{ close(): Promise<void> }` |
1253
- | `User` | `interface` | `{ id, role?, tenant?, [key]: unknown }` |
1254
- | `HttpError` | `class` | `extends Error`,含 `status` 属性 |
1255
-
1256
- ---
1257
-
1258
- # 前端 API (`weifuwu/client`)
1259
-
1260
- > 以下为完整 API 参考,按需查阅。新手建议先阅读上文的「组件模型」和「状态管理」。
1261
-
1262
- 零外部 npm 运行时依赖。组件签名:`(initProps, ctx) => (props) => VNode`(两阶段模型,外层 mount 只一次,内层 render 每次变化时执行)。无状态组件可简写为 `() => () => VNode`。
1263
-
1264
- 构建配置(esbuild):
1265
-
1266
- ```js
1267
- esbuild.build({
1268
- jsx: 'automatic',
1269
- jsxImportSource: 'weifuwu/client',
1270
- bundle: true,
1271
- })
1272
- ```
1273
-
1274
- ---
1275
-
1276
- ## createApp — 应用引导
1277
-
1278
- ```tsx
1279
- import { createApp } from 'weifuwu/client'
1280
-
1281
- const app = createApp()
1282
-
1283
- // 注册中间件
1284
- app.use(middleware1)
1285
- app.use(middleware2)
1286
-
1287
- // 挂载到 DOM
1288
- app.mount('#root', RootComponent)
1289
-
1290
- // 获取当前 ctx
1291
- console.log(app.ctx)
1292
-
1293
- // 销毁
1294
- app.destroy()
1295
- ```
1296
-
1297
- | 方法 | 说明 |
1298
- |------|------|
1299
- | `createApp()` | 创建应用实例 |
1300
- | `app.use(mw)` | 注册 AppMiddleware |
1301
- | `app.mount(selector, RootComponent)` | 挂载到 DOM |
1302
- | `app.destroy()` | 卸载应用 |
1303
- | `app.ctx` | 当前 WfuiContext |
1304
-
1305
- ---
1306
-
1307
- ## 组件模型
1308
-
1309
- ```tsx
1310
- import type { Component, WfuiContext } from 'weifuwu/client'
1311
-
1312
- // 两阶段组件:mount(只一次)→ render(每次 dirty/props 变化)
1313
- const Counter: Component = (_init, ctx) => {
1314
- // ── mount ──
1315
- let count = 0
1316
-
1317
- // ── render ──
1318
- return (props) =>
1319
- h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
1320
- }
1321
-
1322
- // 无状态组件:只有 render
1323
- const Badge: Component = () =>
1324
- (props) => h('span', { class: `badge-${props.variant}` }, props.children)
1325
- ```
1326
-
1327
- ### 类型流(props 泛型 + ctx 注入)
1328
-
1329
- ```tsx
1330
- import type { Component } from 'weifuwu/client'
1331
- import type { ApiInjected, RouteInjected } from 'weifuwu/client'
1332
-
1333
- // ① props 泛型:JSX 使用时自动类型检查(传错类型编译期报错)
1334
- interface DeckCardProps { title: string; pages: number }
1335
- const DeckCard: Component<DeckCardProps> = (_init, ctx) =>
1336
- (props) => <div>{props.title} / {props.pages} 页</div>
1337
- // <DeckCard title="x" pages={8} /> ✓
1338
- // <DeckCard title="x" pages="8" /> ✗ 编译期报错
1339
-
1340
- // ② ctx 注入声明:use(api()).use(router()) 后组件声明依赖,ctx 直接访问
1341
- const Home: Component<{}, ApiInjected & RouteInjected> = (_init, ctx) => {
1342
- ctx.api.get('/users') // ✓ 有类型
1343
- ctx.app.navigate('/x') // ✓ 有类型
1344
- return () => <h1>Home</h1>
1345
- }
1346
- // 未声明的注入字段编译期报错——注入从"文档约定"变成"类型保证"
1347
-
1348
- createApp()
1349
- .use(api()) // 注入 ctx.api
1350
- .use(router({ routes })) // 注入 ctx.route / ctx.app
1351
- .mount('#root', Home) // mount 时类型累积完整
1352
- ```
1353
-
1354
- > 各中间件的注入接口:`api()` → `ApiInjected`、`auth()` → `AuthInjected`、`ws()` → `WsInjected`、`i18n()` → `I18nInjected`、`router()` → `RouteInjected`(均可从 `weifuwu/client` 导入)。
1355
-
1356
- | 规则 | 说明 |
1357
- |------|------|
1358
- | 组件签名 | `(initProps: P, ctx: WfuiContext) => (props: P) => VNode \| null` |
1359
- | mount 阶段 | 外层函数只执行一次,初始化状态 |
1360
- | render 阶段 | 内层函数每次 dirty/props 变化时执行,返回 VNode |
1361
- | 无 class | 无 `this`,无实例方法 |
1362
- | 无 hook | 无 `useState` / `useEffect` / `useMemo` |
1363
- | 状态 | 闭包变量 + `ctx.ui.render()` 手动触发,或 `ctx.ui.$()` 响应式容器 |
1364
- | ref 引用 | `ref={el => { if (el) init; else cleanup }}` 获取 DOM |
1365
-
1366
- ### JSX 工厂
1367
-
1368
- ```tsx
1369
- // 由 esbuild 自动调用(jsxImportSource: 'weifuwu/client')
1370
- import { h, jsx, jsxs, jsxDEV, Fragment } from 'weifuwu/client'
1371
-
1372
- // h 支持 variadic children
1373
- h('div', { class: 'x' }, child1, child2)
1374
-
1375
- // Fragment
1376
- <><div>A</div><div>B</div></>
1377
- ```
1378
-
1379
- | 导出 | 用途 |
1380
- |------|------|
1381
- | `h(type, props, ...children)` | hyperscript |
1382
- | `jsx` / `jsxs` / `jsxDEV` | JSX 编译目标 |
1383
- | `Fragment` | 片段 |
1384
- | `Portal` / `createPortal(children, portalKey?)` | 渲染到 `document.body#__wf_portal` 独立容器(弹层/对话框,脱离父级 overflow 裁剪) |
1385
-
1386
- ```tsx
1387
- import { createPortal } from 'weifuwu/client'
1388
-
1389
- // 内容渲染到 body 下的独立容器(不在父组件的 DOM 树内)
1390
- const Tooltip = (_init, ctx) =>
1391
- (props) => createPortal(
1392
- <div class="tooltip">{props.text}</div>
1393
- )
1394
-
1395
- // 配合 ctx.ui.selfId('name') 可从任何地方精准刷新 portal 内容
1396
- ctx.ui.render(['name'])
1397
- ```
1398
-
1399
- ---
1400
-
1401
- ## 状态管理
1402
-
1403
- ### ctx.ui 方法速查
1404
-
1405
- | 方法 | 签名 | 一句话说明 |
1406
- |------|------|-----------|
1407
- | `$()` | `$(): Record<string, any>` | 深度 Proxy 响应式状态容器,赋值自动触发渲染(**推荐首选**) |
1408
- | `render()` | `render(ids?: string[])` | 同步强制渲染;无参 = 当前组件,传参 = 指定组件列表 |
1409
- | `dirty()` | `dirty(ids?: string[])` | 异步渲染(微任务批处理合并);`$` 内部就是调它 |
1410
- | `selfId()` | `selfId(name: string)` | 注册组件自定义 ID,配合 `render(['id'])` 跨组件精准刷新 |
1411
- | `useMedia()` | `useMedia(query, cb)` | 响应式媒体查询,断点变化时自动回调 |
1412
- | `useBreakpoint()` | `useBreakpoint(cb \| bps, cb?)` | 命名断点 mobile/tablet/desktop |
1413
- | `usePopupPosition()` | `usePopupPosition(opts)` | 弹层坐标跟随:scroll/resize 时自动重算 fixed 坐标 |
1414
- | `useInView()` | `useInView(opts)` | 可见性观察(IntersectionObserver 封装,替代组件自建 scroll 监听);`isIn` 响应式 + `ready` |
1415
- | `useScrollPosition()` | `useScrollPosition({ getScroller? })` | 滚动位置跟踪(全局 scroll 监听 + rAF 节流);`y` 响应式,容器/视口通用 |
1416
-
1417
- > 每个方法的完整说明见下文对应章节。
1418
-
1419
- ### Render 机制总览
1420
-
1421
- | API | 触发时机 | 渲染方式 | 作用域 | 使用场景 |
1422
- |------|---------|---------|--------|---------|
1423
- | `$.x = val` | 赋值后自动 | 微任务批量(异步) | 当前组件 | **日常 UI 状态** — 表单输入、切换开关、异步数据加载等 |
1424
- | `ctx.ui.dirty()` | 主动调用 | 微任务批量(异步) | 当前/指定 | **绕过 Proxy 后手动标记** |
1425
- | `ctx.ui.render()` | 主动调用 | 立即同步 | 当前/指定 | **需要立即拿到最新 DOM** — DOM 测量、动画触发 |
1426
- | `ctx.ui.render(['id'])` | 主动调用 | 立即同步 | 指定组件 | **跨组件精准刷新** — 全局事件、Portal 远程控制 |
1427
- | `ctx.ui.useMedia()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **响应式媒体查询** — 断点变化时自动 dirty |
1428
- | `ctx.ui.useBreakpoint()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **命名断点** — mobile/tablet/desktop 自动 dirty |
1429
- | `ctx.ui.usePopupPosition()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **弹层坐标跟随** — scroll/resize 时自动重算 fixed 坐标 |
1430
- | `ctx.ui.useInView()` | 注册监听 | IO 合成器线程评估 | 当前组件 | **可见性观察**(IO 封装,无 scroll-linked 警告)— Affix/BackTop/InView 统一使用;rootMargin/threshold 支持函数 |
1431
- | `ctx.ui.useScrollPosition()` | 注册监听 | 全局 scroll + rAF 节流 | 当前组件 | **滚动位置跟踪** — `y` 响应式(视口/内部容器通用),Affix/VirtualList 使用 |
1432
-
1433
- `render()` 和 `dirty()` 无参 = 当前组件,传参 = 指定组件列表。三套 API 同一 scope 机制。
1434
-
1435
- ### 闭包变量 + `ctx.ui.render()`(简单场景)
1436
-
1437
- ```tsx
1438
- const Counter: Component = (_init, ctx) => {
1439
- let count = 0
1440
- return (props) =>
1441
- h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
1442
- }
1443
- ```
1444
-
1445
- 适合状态极少的简单组件。每次修改后手动调用 `ctx.ui.render()` 同步刷新 DOM。
1446
-
1447
- ### `ctx.ui.$()` — 响应式 Proxy(推荐首选)
1448
-
1449
- `ctx.ui.$()` 返回**深度 Proxy** 容器。任意层级赋值操作自动触发渲染(微任务批量合并):
1450
-
1451
- ```tsx
1452
- const FormPage: Component = (_init, ctx) => {
1453
- const $ = ctx.ui.$()
1454
- $.email = ''
1455
- $.loading = false
1456
- return (props) =>
1457
- h('input', {
1458
- value: $.email,
1459
- onInput: (e: any) => { $.email = e.target.value }
1460
- })
1461
- }
1462
- ```
1463
-
1464
- **深度 Proxy 拦截**:
1465
- - `$.x = val` → 自动排队重渲染
1466
- - `$.obj.a = 1` → 自动 dirty(嵌套对象递归包装)
1467
- - `$.arr.push(val)` / `$.arr[0].x = y` → 自动 dirty(数组变异 + 嵌套属性拦截)
1468
- - `delete $.x` → 自动 dirty
1469
- - 每个组件实例独立 Proxy,同名变量不冲突
1470
-
1471
- **注意**:mount/render 中 `$.x = val` **不触发渲染**,仅事件/timer/Promise.then 中生效。这是有意设计——初始化和 mount 阶段设置状态不应触发额外渲染。
1472
-
1473
- **何时用 `$`**:所有需要触发 UI 重新渲染的状态。90% 以上的场景用 `$` 就够。
1474
-
1475
- **何时不用**:
1476
- - 不需要触发渲染的内部缓存(用闭包变量 `let`)
1477
- - 简单组件只有一两个状态变量(闭包变量 + `render()` 更轻量)
1478
-
1479
- ### 响应式自适应组件
1480
-
1481
- #### `ctx.ui.useMedia(query, callback)` — 响应式媒体查询
1482
-
1483
- 注册媒体查询监听,值变化时自动调用 callback(callback 内赋值 `$` 触发 dirty):
1484
-
1485
- ```tsx
1486
- const Card = (_init, ctx) => {
1487
- const $ = ctx.ui.$()
1488
- $.isMobile = false
1489
- // 立即回调一次(取当前值),之后变化时自动重新回调
1490
- ctx.ui.useMedia('(max-width: 640px)', (v) => { $.isMobile = v })
1491
-
1492
- return (props) => (
1493
- <div class={$.isMobile ? 'wf-stack' : 'wf-row'}>
1494
- {!$.isMobile && <Sidebar />}
1495
- <Content />
1496
- </div>
1497
- )
1498
- }
1499
- ```
1500
-
1501
- `callback` 在 mount 时立即执行一次,之后断点变化时再次执行。赋值给 `$` 的属性自动触发渲染。
1502
-
1503
- #### `ctx.ui.useBreakpoint(callback)` — 命名断点
1504
-
1505
- 预设三个断点名称:`mobile`(<640px)、`tablet`(640-1023px)、`desktop`(≥1024px):
1506
-
1507
- ```tsx
1508
- const Layout = (_init, ctx) => {
1509
- const $ = ctx.ui.$()
1510
- ctx.ui.useBreakpoint((vp) => { $.vp = vp })
1511
-
1512
- return (props) =>
1513
- <div class={`sidebar-${$.vp}`}>
1514
- {$.vp === 'mobile' ? <BottomNav /> : <SideNav />}
1515
- {$.vp === 'mobile' ? <MobileContent /> : <Content />}
1516
- </div>
1517
- }
1518
- ```
1519
-
1520
- 也支持自定义断点:
1521
-
1522
- ```tsx
1523
- ctx.ui.useBreakpoint(
1524
- { narrow: '(max-width: 480px)', wide: '(min-width: 1200px)' },
1525
- (vp) => { $.size = vp },
1526
- )
1527
- ```
1528
-
1529
- #### `ctx.ui.usePopupPosition(options)` — 弹层坐标跟随
1530
-
1531
- 解决弹出层(Popover / Tooltip / Dropdown / DatePicker 等)在 **页面滚动 / 窗口缩放后不跟随触发元素** 的问题。基于 `position: fixed` + `getBoundingClientRect()`(视口坐标)的弹层,滚动后坐标需要重算——本 API 用全局 scroll/resize 监听(rAF 节流)自动重算并精准刷新当前组件。
1532
-
1533
- ```tsx
1534
- const DatePicker = (_init, ctx) => {
1535
- let show = false
1536
- let inputEl: HTMLElement | null = null
1537
- let prevOpen = false
1538
-
1539
- // mount 阶段注册:scroll/resize 时自动重算 pos
1540
- const pos = ctx.ui.usePopupPosition({
1541
- el: () => inputEl, // 锚定元素(ref 保存)
1542
- isOpen: () => show, // 弹层是否显示
1543
- compute: (r) => ({ top: r.bottom + 4, left: r.left }), // rect → 坐标
1544
- })
1545
-
1546
- return (props) => {
1547
- const isOpen = show
1548
- // 打开瞬间算一次初始坐标(受控/非受控统一覆盖)
1549
- if (isOpen && !prevOpen) pos.refresh()
1550
- prevOpen = isOpen
1551
-
1552
- return h('div', {}, [
1553
- h('input', {
1554
- ref: (el) => { inputEl = el as HTMLElement },
1555
- onClick: () => { show = !show; ctx.ui.render() },
1556
- }),
1557
- isOpen ? h('div', { style: { top: pos.top, left: pos.left } }) : null,
1558
- ].filter(Boolean))
1559
- }
1560
- }
1561
- ```
1562
-
1563
- 要点:
1564
-
1565
- - `pos` 是稳定对象,render 闭包直接读取 `top/left/width`,滚动重算原地更新,无需重新绑定
1566
- - `pos.refresh()` 只重算不渲染——配合打开路径上已有的 `render()`,避免重复渲染
1567
- - 监听是**全局单例**(capture 捕获所有嵌套滚动容器 + rAF 节流),按组件 selfId 注册,组件多时开销 O(1)
1568
- - `compute` 是纯函数(rect → 坐标),可单独单测
1569
-
1570
- 已内置接入的组件:**Popover / Tooltip / Dropdown / DatePicker / Chart**(tooltip)——它们的弹出层在页面滚动、嵌套容器滚动、窗口缩放时都会自动跟随触发元素,无需额外配置。
1571
-
1572
- #### `ctx.ui.selfId(name)` — 跨组件精准刷新
1573
-
1574
- 用于全局事件通知、Portal 远程控制、兄弟组件协调等场景——绕过多层 props 传递,直接按 ID 刷新目标组件:
1575
-
1576
- ```tsx
1577
- // 组件 A:mount 阶段注册自定义 ID
1578
- const StatsPanel = (_init, ctx) => {
1579
- ctx.ui.selfId('stats')
1580
- const $ = ctx.ui.$()
1581
- $.data = []
1582
- return (props) => h('div', {}, String($.data.length))
1583
- }
1584
-
1585
- // 组件 B(或其他任何地方)用 ID 精准刷新
1586
- ctx.ui.render(['stats']) // 同步刷新
1587
- // 或:ctx.ui.dirty(['stats']) // 异步批处理版本
1588
- ```
1589
-
1590
- **语义**:
1591
-
1592
- - 必须在 **mount 阶段**调用(组件初始化时),注册后组件即可被 `render(['id'])` / `dirty(['id'])` 精准定位
1593
- - **同名冲突直接抛错**,每个自定义 ID 必须全局唯一
1594
- - 配合 `selfId` 注册的组件在跨组件场景下无需把刷新逻辑层层传 props
1595
-
1596
- #### CSS 层响应式(不碰 JS)
1597
-
1598
- 配合 `weifuwu/layout` 的断点变体,纯 CSS 实现布局方向切换:
1599
-
1600
- ```html
1601
- <!-- 小屏堆叠,桌面并排 -->
1602
- <div class="wf-stack wf-stack@md"></div>
1603
-
1604
- <!-- 小屏隐藏侧栏 -->
1605
- <aside class="wf-hidden wf-block@md"></aside>
1606
- ```
1607
-
1608
- 可用断点变体:
1609
-
1610
- | 原语 | 变体 | 效果 |
1611
- |------|------|------|
1612
- | `wf-stack` | `@sm` `@md` `@lg` | 断点以上改为横向排列 |
1613
- | `wf-row` | `@sm` `@md` `@lg` | 断点以上保持横向 |
1614
- | `wf-hidden` | `@sm` `@md` `@lg` | 断点以上隐藏 |
1615
- | `wf-block` | `@sm` `@md` `@lg` | 断点以上显示 |
1616
-
1617
- 断点尺寸:`--wf-bp-sm: 640px` / `--wf-bp-md: 768px` / `--wf-bp-lg: 1024px` / `--wf-bp-xl: 1280px`
1618
-
1619
- ### `ctx.ui.dirty()` — 异步标记脏
1620
-
1621
- 异步版本,无参 = 当前组件,传参 = 指定组件列表。多次调用合并为一次微任务渲染。`$` 内部就是调 `dirty()`。
1622
-
1623
- 与 `render()` 的区别:`dirty()` 是**异步**(微任务批量合并,同帧多次调用只渲染一次),`render()` 是**同步**(立即执行 VDOM diff + patch)。日常 UI 状态用 `$` 或 `dirty()`,需要立即拿到最新 DOM(测量/动画/第三方库)时用 `render()`。
1624
-
1625
- ### `ctx.ui.render()` — 同步强制渲染
1626
-
1627
- 与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。无参时只刷新当前组件,传参时可精准刷新指定组件。
1628
-
1629
- **何时必须用 `render()`**:
1630
-
1631
- ```tsx
1632
- // 1. DOM 测量(读取 offsetHeight/scrollWidth 等)
1633
- // 用 ref 在 DOM 创建后操作
1634
- ref: (el) => {
1635
- if (!el) return
1636
- el.style.height = 'auto'
1637
- ctx.ui.render()
1638
- const h = el.offsetHeight
1639
- el.style.height = h + 'px'
1640
- }
1641
-
1642
- // 2. 动画触发(需要确保上一帧 DOM 已提交)
1643
- function startAnimation() {
1644
- $.animating = true
1645
- ctx.ui.render() // 同步刷新 DOM
1646
- el.startViewTransition(...) // 拿到最新 DOM 启动动画
1647
- }
1648
-
1649
- // 3. 第三方库需要在事件回调中读取最新 DOM
1650
- onClick: () => {
1651
- $.selected = !$.selected
1652
- ctx.ui.render() // 确保 DOM 已更新
1653
- thirdPartyLib.measure(el) // 读取最新状态
1654
- }
1655
- ```
1656
-
1657
- **规则**:能用 `$` 就用 `$`。只有当你**必须同步拿到最新 DOM 状态**时才用 `render()`。
1658
-
1659
- ### 三种方式速查
1660
-
1661
- ```tsx
1662
- // 自动:$.x = val — 微任务批量,绑定当前组件
1663
- const $ = ctx.ui.$()
1664
- $.count++
1665
- $.name = 'hello' // 多次赋值合并为一次渲染
1666
-
1667
- // 手动:ctx.ui.render() — 同步,无参=当前,传参=指定
1668
- let count = 0
1669
- count++
1670
- ctx.ui.render() // DOM 立刻更新
1671
- ctx.ui.render(['stats']) // 精准刷新指定组件
1672
-
1673
- // 异步:ctx.ui.dirty() — 微任务批量,同 render() 作用域
1674
- ctx.ui.dirty()
1675
- ctx.ui.dirty(['stats']) // 批处理合并
1676
- ```
1677
-
1678
- **性能说明**:
1679
- - `$.x = val` 和 `dirty()` 都是微任务批量合并
1680
- - `render()` 从 dirty 组件**向下**遍历(scope render),兄弟组件不遍历
1681
- - **三态 skip 自动优化**:组件重新渲染时,框架自动检查三个维度:
1682
- - **props**(含 children 元素级比较)——值没变则不渲染
1683
- - **`$` 状态**——没被 dirty 标记则不渲染
1684
- - **ctx 版本**——ctx 没变化则不渲染
1685
- 三个条件全部满足时跳过整个子树(零 `_render` 调用、零 `patchValue` 遍历)
1686
- - **lastIndex keyed diff**:列表 diff 采用正向 lastIndex 算法(React 同款),顺序不变时零 `insertBefore`。对比传统的逆序循环全量移动,DOM 修改从 O(N) 降到 O(0)。
1687
- - 示例:DemoButton 点击一次,DOM 修改从 34 次降到 **1 次**(仅变更文本节点的 `textContent`)
1688
-
1689
- ### 实践建议
1690
-
1691
- **组件库**(可分享组件)推荐手动模式:
1692
-
1693
- ```tsx
1694
- const DatePicker = (_init, ctx) => {
1695
- let show = false // let 不触发渲染
1696
- return (props) =>
1697
- h('input', {
1698
- onClick: () => { show = true; ctx.ui.render() }
1699
- })
1700
- }
1701
- ```
1702
-
1703
- 行为只由 `render()` 显式控制,不依赖 `$`,测试中 `render()` 直接 mock 为空函数。
1704
-
1705
- **业务层**推荐自动模式:
1706
-
1707
- ```tsx
1708
- const OrderPage = (_init, ctx) => {
1709
- const $ = ctx.ui.$()
1710
- $.orders = [] // $ 赋值自动触发渲染
1711
- $.loading = false
1712
- return (props) => h('div', {}, $.loading ? h(Spinner) : h(OrderList, { orders: $.orders }))
1713
- }
1714
- ```
1715
-
1716
- 省事、安全、`$` 绑定所属组件不波及兄弟。
1717
-
1718
- 同一个组件内可以按变量混用两种模式:需要渲染的用 `$`,不需要的用 `let`。
1719
-
1720
- ### VDOM diff 优化机制
1721
-
1722
- weifuwu 的 VDOM 在每次 render 时自动执行**三态 skip 判定**,减少不必要的组件渲染和 DOM 操作:
1723
-
1724
- ```
1725
- canSkip = (props 没变) AND ($ 没脏) AND (ctx 版本一致)
1726
- ↑ 值级浅比较 ↑ VNode dirty 标记 ↑ 全局版本号
1727
- ```
1728
-
1729
- 三个维度各自独立判断,AND 合并。任何一个维度说
1730
-
1731
- ---
1732
-
1733
- ## 条件与列表
1734
-
1735
- 使用原生 JS 控制流:
1736
-
1737
- ```tsx
1738
- // 条件
1739
- {cond ? <A /> : <B />}
1740
- {cond && <A />}
1741
-
1742
- // 列表 — 必须指定 key
1743
- {items.map(item => (
1744
- <div key={item.id}>{item.name}</div>
1745
- ))}
1746
- ```
1747
-
1748
- ---
1749
-
1750
- ## ref 管理 DOM
1751
-
1752
- 使用 `ref` prop 获取元素引用,适合管理第三方库或读取 DOM:
1753
-
1754
- ```tsx
1755
- const Timer: Component = (_init, ctx) => {
1756
- let timer: ReturnType<typeof setInterval> | undefined
1757
-
1758
- return (props) =>
1759
- h('div', {
1760
- ref: (el) => {
1761
- if (el) {
1762
- timer = setInterval(() => console.log('tick'), 1000)
1763
- } else {
1764
- clearInterval(timer)
1765
- }
1766
- },
1767
- }, 'Timer')
1768
- }
1769
- ```
1770
-
1771
- `ref` 在元素创建时调用 `ref(el)`,元素移除时调用 `ref(null)`。
1772
- `ref` 不接受返回值,清理逻辑直接在 `else` 分支处理。
1773
-
1774
- 对于**内嵌元素**(非根元素),直接在目标元素上放 `ref`:
1775
-
1776
- ```tsx
1777
- return h('div', {},
1778
- h('input', {
1779
- type: 'text',
1780
- ref: (el) => el?.focus(),
1781
- })
1782
- )
1783
- ```
1784
-
1785
- ### 异步组件
1786
-
1787
- 在 mount 阶段发起请求,数据通过 `$.x = val` 自动触发渲染:
1788
-
1789
- ```tsx
1790
- const UserProfile: Component = (initProps, ctx) => {
1791
- const $ = ctx.ui.$()
1792
- $.loading = true
1793
-
1794
- fetch(`/api/user/${initProps.id}`)
1795
- .then(r => r.json())
1796
- .then(user => { $.user = user; $.loading = false })
1797
-
1798
- return (props) =>
1799
- $.loading
1800
- ? h('div', {}, '加载中...')
1801
- : h('div', {}, $.user?.name ?? '')
1802
- }
1803
- ```
1804
-
1805
- ### asyncComponent 工厂(async 组件)— 同步式数据声明
1806
-
1807
- `async (ctx) => (initProps, ctx) => (props) => VNode` — 工厂层(async,只执行一次并缓存)声明数据/加载代码,mount/render 保持同步。数据经闭包注入组件,渲染无 loading 分支:
1808
-
1809
- ```tsx
1810
- import { asyncComponent } from 'weifuwu/client'
1811
-
1812
- const UserProfile = asyncComponent(async (ctx) => {
1813
- const user = await ctx.data.get(`/api/user/${ctx.params.id}`)
1814
- return (_init, ctx) => {
1815
- const $ = ctx.ui.$()
1816
- $.liked = false // 客户端状态(交互后变化)
1817
- return (props) =>
1818
- h('div', {},
1819
- h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
1820
- h('button', { onClick: () => $.liked = !$.liked }, $.liked ? '❤️' : '🤍'),
1821
- )
1822
- }
1823
- })
1824
- ```
1825
-
1826
- - **客户端**:首次渲染占位 → 工厂 resolve 后整树重渲染补全(SPA);数据经 `ctx.data` 缓存(hydration 时从 `__DATA__` 同步命中,不重跑请求)
1827
- - **服务端**:`ctx.ui.ssr()` 直接 await 工厂 → 数据进 HTML(无占位)
1828
- - 工厂缓存绑定页面上下文:路由导航/登录登出时自动失效,工厂以新 ctx 重新执行
1829
- - 会变的数据:初始值 seed 自服务端数据(`$.count = data.count`),交互改 `$`;初始状态必须确定性(禁止 `window.innerWidth` 直接初始化 → SSR/hydration mismatch)
1830
-
1831
- ---
1832
-
1833
- ## router + RouteView — 前端路由
1834
-
1835
- ```tsx
1836
- import { createApp, router, RouteView } from 'weifuwu/client'
1837
- import type { RouteDef, WfuiContext } from 'weifuwu/client'
1838
-
1839
- const routes: RouteDef[] = [
1840
- { path: '/', component: Home },
1841
- { path: '/users', component: UserList },
1842
- { path: '/users/:id', component: UserDetail },
1843
- ]
1844
-
1845
- createApp()
1846
- .use(router({
1847
- routes,
1848
- mode: 'history', // 或 'hash'
1849
- notFound: NotFoundPage,
1850
- }))
1851
- .mount('#root', () => () => <RouteView />) // 根组件也要两阶段:外层返回 render 函数
1852
- ```
1853
-
1854
- ### 嵌套布局
1855
-
1856
- ```tsx
1857
- const routes = [
1858
- {
1859
- path: '/dashboard',
1860
- layout: DashboardLayout, // 持久布局(包含 RouteView)
1861
- children: [
1862
- { path: '/overview', component: Overview },
1863
- { path: '/settings', component: Settings },
1864
- ],
1865
- },
1866
- ]
1867
-
1868
- function DashboardLayout(_props: {}, ctx: WfuiContext) {
1869
- return (props) => (
1870
- <div style="display:flex">
1871
- <aside>导航菜单</aside>
1872
- <main><RouteView /></main> {/* 渲染子路由 */}
1873
- </div>
1874
- )
1875
- }
1876
- ```
1877
-
1878
- ### 编程式导航
1879
-
1880
- ```tsx
1881
- // 在任意组件中
1882
- ctx.app?.navigate('/users/123?tab=profile')
1883
- ```
1884
-
1885
- | ctx 注入 | 类型 | 说明 |
1886
- |----------|------|------|
1887
- | `ctx.route.path` | `string` | 当前路由路径 |
1888
- | `ctx.route.params` | `Record<string, string>` | URL 参数 |
1889
- | `ctx.route.query` | `Record<string, string>` | 查询参数 |
1890
- | `ctx.app.navigate(path)` | `(string) => void` | 编程式导航 |
1891
-
1892
- | RouterOptions | 类型 | 默认值 | 说明 |
1893
- |---------------|------|--------|------|
1894
- | `routes` | `RouteDef[]` | — | 路由定义 |
1895
- | `mode` | `'history' \| 'hash'` | `'history'` | 路由模式 |
1896
- | `notFound` | `Component` | — | 404 页面 |
1897
-
1898
- | RouteDef | 类型 | 说明 |
1899
- |----------|------|------|
1900
- | `path` | `string` | 路径(支持 `:param`) |
1901
- | `component` | `Component` | 页面组件 |
1902
- | `layout` | `Component` | 布局组件(内含 `<RouteView />`) |
1903
- | `children` | `RouteDef[]` | 子路由 |
1904
- | `auth` | `boolean` | 是否需要认证(配合 auth 中间件) |
1905
- | `title` | `string` | 页面标题(自动设置 `document.title`) |
1906
-
1907
- ---
1908
-
1909
- ## api — HTTP 客户端中间件
1910
-
1911
- ```tsx
1912
- import { createApp, api } from 'weifuwu/client'
1913
-
1914
- createApp()
1915
- .use(api({ baseURL: '/api' }))
1916
- .mount('#root', App)
1917
-
1918
- // 在组件中使用
1919
- async function loadUsers(ctx: WfuiContext) {
1920
- const users = await ctx.api?.get<User[]>('/users')
1921
- const user = await ctx.api?.get<User>('/users/1')
1922
- const created = await ctx.api?.post<User>('/users', { name: 'Alice' })
1923
- await ctx.api?.put('/users/1', { name: 'Bob' })
1924
- await ctx.api?.patch('/users/1', { name: 'Bob' })
1925
- await ctx.api?.delete('/users/1')
1926
- }
1927
- ```
1928
-
1929
- | 选项 | 类型 | 默认值 | 说明 |
1930
- |------|------|--------|------|
1931
- | `baseURL` | `string` | `''` | API 基础路径 |
1932
- | `headers` | `Record<string, string>` | `{ 'Content-Type': 'application/json' }` | 默认请求头 |
1933
- | `onRequest` | `(req) => { url, init }` | — | 请求拦截器 |
1934
- | `onResponse` | `(res) => Promise<T>` | — | 响应拦截器 |
1935
- | `timeout` | `number` | `0`(无超时) | 请求超时(ms)|
1936
-
1937
- | ctx.api 方法 | 签名 | 说明 |
1938
- |-------------|------|------|
1939
- | `api.get(url, opts?)` | `<T>(string, ApiRequestOptions?) => Promise<T>` | GET |
1940
- | `api.post(url, body?, opts?)` | `<T>(string, unknown?, ApiRequestOptions?) => Promise<T>` | POST |
1941
- | `api.put(url, body?, opts?)` | `<T>(string, unknown?, ApiRequestOptions?) => Promise<T>` | PUT |
1942
- | `api.patch(url, body?, opts?)` | `<T>(string, unknown?, ApiRequestOptions?) => Promise<T>` | PATCH |
1943
- | `api.delete(url, opts?)` | `<T>(string, ApiRequestOptions?) => Promise<T>` | DELETE |
1944
-
1945
- ```ts
1946
- // 错误处理
1947
- try {
1948
- await ctx.api!.get('/users')
1949
- } catch (e) {
1950
- if (e instanceof ApiError) {
1951
- console.log(e.status, e.body) // e.g. 404, 'Not Found'
1952
- }
1953
- }
1954
- ```
1955
-
1956
- `ApiError`:`{ status: number, body: string }`,继承 `Error`。
1957
-
1958
- | ApiRequestOptions | 类型 | 说明 |
1959
- |-------------------|------|------|
1960
- | `headers` | `Record<string, string>` | 本次请求自定义请求头 |
1961
- | `signal` | `AbortSignal` | 取消请求 |
1962
-
1963
- ---
1964
-
1965
- ## auth — 认证中间件
1966
-
1967
- ```tsx
1968
- import { createApp, auth } from 'weifuwu/client'
1969
-
1970
- createApp()
1971
- .use(auth())
1972
- .mount('#root', App)
1973
-
1974
- // 在组件中
1975
- function Profile(_props: {}, ctx: WfuiContext) {
1976
- return (props) => {
1977
- if (!ctx.auth?.isLoggedIn) return <p>请登录</p>
1978
- return <p>欢迎, {ctx.auth?.user?.name}</p>
1979
- }
1980
- }
1981
-
1982
- // 登录
1983
- ctx.auth?.login(token, { id: 1, name: 'Alice' }, refreshToken)
1984
-
1985
- // 登出
1986
- ctx.auth?.logout()
1987
-
1988
- // 更新用户信息
1989
- ctx.auth?.setUser({ id: 1, name: 'Bob' })
1990
-
1991
- // 刷新 token
1992
- await ctx.auth?.refresh() // → boolean
1993
- ```
1994
-
1995
- | 选项 | 类型 | 默认值 | 说明 |
1996
- |------|------|--------|------|
1997
- | `storage` | `Storage` | `localStorage` | 存储方式 |
1998
- | `tokenKey` | `string` | `'weifuwu_token'` | Token 存储 key |
1999
- | `userKey` | `string` | `'weifuwu_user'` | 用户信息存储 key |
2000
- | `refreshTokenKey` | `string` | `'weifuwu_refresh'` | Refresh token 存储 key |
2001
- | `refreshEndpoint` | `string` | `'/api/auth/refresh'` | 刷新端点 |
2002
-
2003
- | ctx.auth | 类型 | 说明 |
2004
- |----------|------|------|
2005
- | `.token` | `string \| null` | JWT token |
2006
- | `.user` | `any` | 用户对象 |
2007
- | `.isLoggedIn` | `boolean` | 是否已登录(基于 token 存在) |
2008
- | `.login(token, user, refreshToken?)` | `void` | 登录 |
2009
- | `.logout()` | `void` | 登出(清除存储) |
2010
- | `.setUser(user)` | `void` | 更新用户信息 |
2011
- | `.refresh()` | `Promise<boolean>` | 刷新 token(自动检测过期) |
2012
-
2013
- 启动时自动检测 token 是否过期(JWT `exp` 提前 30 秒),过期则自动调用 `refresh()`。
2014
-
2015
- ---
2016
-
2017
- ## ws — WebSocket 客户端中间件
2018
-
2019
- ```tsx
2020
- import { createApp, ws } from 'weifuwu/client'
2021
-
2022
- createApp()
2023
- .use(ws({ url: '/ws' }))
2024
- .mount('#root', App)
2025
-
2026
- // 发送消息
2027
- ctx.ws?.send({ type: 'chat', body: 'hello' })
2028
-
2029
- // 接收消息 — 返回 unsubscribe 函数
2030
- const unsubscribe = ctx.ws?.onMessage((msg) => {
2031
- console.log('收到:', msg)
2032
- })
2033
-
2034
- // 清理
2035
- unsubscribe?.()
2036
- ```
2037
-
2038
- | 选项 | 类型 | 默认值 | 说明 |
2039
- |------|------|--------|------|
2040
- | `url` | `string` | `'/ws'` | WebSocket 连接地址 |
2041
- | `reconnectInterval` | `number` | `3000` | 重连间隔(ms) |
2042
- | `maxReconnect` | `number` | `10` | 最大重连次数 |
2043
- | `pingInterval` | `number` | `30000` | 心跳发送间隔 |
2044
- | `pingTimeout` | `number` | `10000` | 心跳超时断开 |
2045
-
2046
- | ctx.ws | 类型 | 说明 |
2047
- |--------|------|------|
2048
- | `.send(msg)` | `(unknown) => void` | 发送 JSON 消息 |
2049
- | `.onMessage(fn)` | `(fn) => () => void` | 订阅消息,返回 unsubscribe |
2050
- | `.isConnected` | `boolean` | 连接状态 |
2051
- | `.close()` | `() => void` | 断开连接 |
2052
-
2053
- 自动重连(指数退避)、心跳保活、JSON 序列化/反序列化。
2054
-
2055
- ---
2056
-
2057
- ## i18n — 国际化中间件
2058
-
2059
- ```tsx
2060
- import { createApp, i18n } from 'weifuwu/client'
2061
-
2062
- createApp()
2063
- .use(i18n({
2064
- locale: 'zh-CN',
2065
- messages: {
2066
- 'title': '仪表盘',
2067
- 'welcome': '欢迎光临',
2068
- },
2069
- }))
2070
- .mount('#root', App)
2071
-
2072
- // 组件中使用
2073
- <h1>{ctx.i18n?.t('title')}</h1>
2074
- <p>{ctx.i18n?.t('welcome')}</p>
2075
-
2076
- // 运行时切换语言
2077
- ctx.i18n?.setLocale('en-US')
2078
- // → 自动触发根组件重渲染(所有组件使用新语言文案)
2079
- ```
2080
-
2081
- | I18nOptions | 类型 | 默认值 | 说明 |
2082
- |-------------|------|--------|------|
2083
- | `locale` | `string` | `'zh-CN'` | 初始语言 |
2084
- | `messages` | `Record<string, string>` | `{}` | 翻译键值对 |
2085
- | `components` | `Record<string, Record<string, string>>` | `{}` | 组件文案覆盖 |
2086
-
2087
- | ctx.i18n | 类型 | 说明 |
2088
- |----------|------|------|
2089
- | `.t(key, fallback?)` | `(string, string?) => string` | 翻译 |
2090
- | `.locale` | `string` | 当前语言 |
2091
- | `.setLocale(lang)` | `(string) => void` | 切换语言(触发重渲染) |
2092
- | `.components` | `Record<string, Record<string, string>>` | 组件文案映射 |
2093
-
2094
- 内置语言包:
2095
-
2096
- ```ts
2097
- import { zhCN, enUS } from 'weifuwu/client'
2098
- ```
2099
-
2100
- - `zh-CN`:默认中文
2101
- - `en-US`:英文
2102
-
2103
- 组件文案(Button 的 `加载中...`、FileUpload 的 `点击或拖拽上传文件` 等)随语言自动切换。组件内部通过 `ctx.i18n?.components?.ComponentName.field` 读取。
2104
-
2105
- 组件支持 `props.locale` 局部覆盖语言。
2106
-
2107
- ---
2108
-
2109
- ## ErrorBoundary — 错误边界
2110
-
2111
- ```tsx
2112
- import { ErrorBoundary } from 'weifuwu/client'
2113
-
2114
- <ErrorBoundary fallback={<p>出错了,请刷新页面</p>}>
2115
- <UserProfile />
2116
- </ErrorBoundary>
2117
-
2118
- // fallback 也可以是一个接收 error 的函数
2119
- <ErrorBoundary fallback={({ error }) => (
2120
- <div>
2121
- <p>出错了: {String(error)}</p>
2122
- <button onClick={() => location.reload()}>重试</button>
2123
- </div>
2124
- )}>
2125
- <UserProfile />
2126
- </ErrorBoundary>
2127
- ```
2128
-
2129
- | ErrorBoundaryProps | 类型 | 默认值 | 说明 |
2130
- |--------------------|------|--------|------|
2131
- | `fallback` | `VNode \| ((props: { error }) => VNode) \| null` | `null` | 错误时渲染的内容 |
2132
- | `children` | `any` | — | 子组件 |
2133
-
2134
- 捕获子组件 render 时的错误 → 渲染 fallback。清除 `error` 即可重试。
2135
-
2136
- ---
2137
-
2138
- ## confirm — 确认对话框
2139
-
2140
- 两种用法,共享同一视觉与行为(基于 Modal 封装):
2141
-
2142
- **① 命令式 `ctx.confirm()`(推荐,操作前询问)**
2143
-
2144
- ```tsx
2145
- import { createApp } from 'weifuwu/client'
2146
- import { confirm } from 'weifuwu/components'
2147
-
2148
- createApp()
2149
- .use(confirm())
2150
- .mount('#root', App)
2151
-
2152
- // 任意代码中(组件事件、async 逻辑)
2153
- async function handleDelete(ctx: WfuiContext) {
2154
- const ok = await ctx.confirm?.('确定删除这条记录?', {
2155
- title: '确认删除',
2156
- confirmText: '删除',
2157
- cancelText: '取消',
2158
- variant: 'danger', // 'primary' | 'danger'
2159
- })
2160
- if (ok) {
2161
- // 执行删除...
2162
- }
2163
- }
2164
- ```
2165
-
2166
- **② 声明式 `<Confirm>`(需要受控状态时)**
2167
-
2168
- ```tsx
2169
- import { Confirm } from 'weifuwu/components'
2170
-
2171
- <Confirm
2172
- open={confirming}
2173
- title="确认删除"
2174
- message="确定删除这条记录?"
2175
- confirmText="删除"
2176
- variant="danger"
2177
- onConfirm={() => doDelete()}
2178
- onCancel={() => setConfirming(false)}
2179
- />
2180
- ```
2181
-
2182
- | ConfirmOptions | 类型 | 默认值 | 说明 |
2183
- |----------------|------|--------|------|
2184
- | `title` | `string` | `'确认操作'` | 对话框标题 |
2185
- | `confirmText` | `string` | `'确定'` | 确认按钮文字 |
2186
- | `cancelText` | `string` | `'取消'` | 取消按钮文字 |
2187
- | `variant` | `'primary' \| 'danger'` | `'primary'` | 按钮样式变体 |
2188
- | `width` | `string` | Modal 默认 | 对话框宽度 |
2189
-
2190
- - `ctx.confirm()` 返回 `Promise<boolean>`,ESC / 点击遮罩 / 取消 → resolve(false)
2191
- - 组件化渲染(Modal + portal),自动锁定滚动 + 焦点陷阱,i18n 文案可配置
2192
- - 多次调用各自独立渲染(叠放语义),互不干扰
2193
-
2194
- ---
2195
-
2196
- ## toast — 命令式消息提示
2197
-
2198
- `ctx.toast()` 是 `<Toast>` 组件的全局命令式封装:任意代码中一行调用,自动消失、自动清理,无需宿主状态。
2199
-
2200
- ```tsx
2201
- import { createApp } from 'weifuwu/client'
2202
- import { toast } from 'weifuwu/components'
2203
-
2204
- createApp()
2205
- .use(toast({ position: 'top-right', duration: 3000, max: 3 }))
2206
- .mount('#root', App)
2207
-
2208
- // 任意代码中(组件事件、api 拦截器、WS 回调、定时器)
2209
- ctx.toast?.('保存成功', 'success')
2210
- ctx.toast?.('请求失败', 'error')
2211
- ctx.toast?.('普通消息') // 默认 type = 'info'
2212
- ```
2213
-
2214
- | ToastOptions | 类型 | 默认值 | 说明 |
2215
- |-------------|------|--------|------|
2216
- | `position` | `ToastPosition` | `'top-right'` | 容器位置 |
2217
- | `duration` | `number` | `3000` | 默认自动消失时间(ms),0 = 不消失 |
2218
- | `max` | `number` | `3` | 最大显示条数,超出移除最早 |
2219
-
2220
- 单条可覆盖自动消失时间:`ctx.toast('慢一点消失', 'info', 5000)`。
2221
-
2222
- 与声明式 `<Toast toasts={...}/>` 共存:声明式用于局部列表(合并消息、自定义布局),命令式用于全局一次性反馈。
2223
-
2224
- ---
2225
-
2226
- ## ScrollLock / FocusTrap
2227
-
2228
- ```tsx
2229
- import { lockScroll, unlockScroll } from 'weifuwu/client'
2230
- import { trapFocus } from 'weifuwu/client'
2231
-
2232
- // 锁定/解锁滚动(支持嵌套计数)
2233
- lockScroll()
2234
- unlockScroll()
2235
-
2236
- // 焦点陷阱 — 返回 cleanup 函数
2237
- const cleanup = trapFocus(containerElement)
2238
- cleanup() // 恢复之前的焦点
2239
- ```
2240
-
2241
- | API | 说明 |
2242
- |-----|------|
2243
- | `lockScroll()` | 锁定 body 滚动(iOS 兼容) |
2244
- | `unlockScroll()` | 解锁滚动,恢复滚动位置 |
2245
- | `trapFocus(el)` | Tab/Shift+Tab 在容器内循环,返回 cleanup |
2246
-
2247
- ---
2248
-
2249
- ## extendCtx — 上下文扩展
2250
-
2251
- ```tsx
2252
- import { extendCtx } from 'weifuwu/client'
2253
-
2254
- // 在 AppMiddleware 中创建新 ctx,原 ctx getter 通过原型链继承
2255
- function myMw(ctx: WfuiContext): WfuiContext {
2256
- return extendCtx(ctx, { myField: 'value' })
2257
- }
2258
- ```
2259
-
2260
- `extendCtx` 使用 `Object.create(ctx)` 保持原型链,再用 `Object.assign` 添加新字段。保证 getter 不被快照化。
2261
-
2262
- ---
2263
-
2264
- ## 前端类型
2265
-
2266
- ```tsx
2267
- import type { VNode, VNodeType, Component, WfuiContext, AppMiddleware, RouteDef } from 'weifuwu/client'
2268
- import type { ApiClient, ApiOptions, ApiRequestOptions, ApiError } from 'weifuwu/client'
2269
- import type { AuthClient, AuthOptions } from 'weifuwu/client'
2270
- import type { ErrorBoundaryProps } from 'weifuwu/client'
2271
- import type { I18nOptions, I18nState, LocalePackage } from 'weifuwu/client'
2272
- import type { PopupPositionOptions, PopupPosition } from 'weifuwu/client'
2273
- import type { ConfirmProps, ConfirmOptions } from 'weifuwu/components'
2274
- import type { ToastOptions, ToastPosition } from 'weifuwu/components'
2275
- import type { RouterOptions } from 'weifuwu/client'
2276
- ```
2277
-
2278
- | 类型 | 说明 |
2279
- |------|------|
2280
- | `VNode` | `{ type, props, key? }` |
2281
- | `VNodeType` | `string \| Component \| typeof Fragment` |
2282
- | `Component<P>` | `(initProps: P, ctx: WfuiContext) => (props: P) => VNode \| null` |
2283
- | `WfuiContext` | `{ ui, route?, app?, ws?, api?, auth?, i18n?, confirm?, toast?, [key]: unknown }` |
2284
- | `AppMiddleware` | `(ctx: WfuiContext) => WfuiContext` |
2285
- | `RouteDef` | `{ path, component?, layout?, children?, auth?, title? }` |
2286
- | `ApiClient` | `{ get, post, put, patch, delete }` |
2287
- | `ApiError` | `class { status, body } extends Error` |
2288
- | `AuthClient` | `{ token, user, isLoggedIn, login, logout, setUser, refresh }` |
2289
- | `I18nOptions` | `{ locale?, messages?, components? }` |
2290
- | `I18nState` | `{ locale, t, setLocale, components }` |
2291
- | `ErrorBoundaryProps` | `{ fallback?, children? }` |
2292
- | `ConfirmProps` | `{ open?, title?, message?, confirmText?, cancelText?, variant?, width?, onConfirm?, onCancel? }` |
2293
- | `ConfirmOptions` | `{ title?, confirmText?, cancelText?, variant?, width? }` — 命令式 ctx.confirm 选项 |
2294
- | `ToastOptions` | `{ position?, duration?, max? }` — 命令式 ctx.toast 配置 |
2295
- | `PopupPositionOptions` | `{ el, isOpen, compute }` — 弹层位置跟踪配置(见 usePopupPosition) |
2296
- | `PopupPosition` | `{ top, left, width?, refresh }` — 弹层位置跟踪器 |
2297
-
2298
- ---
2299
-
2300
- # 组件库 (`weifuwu/components`)
2301
-
2302
- 92 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
2303
-
2304
- > **组件速查(weifuwu 组件 ↔ antd / Element Plus / shadcn-ui 对应 + 迁移示例)**:见 [`docs/components-map.md`](./docs/components-map.md)——从其他组件库迁来的开发者按功能直接找对应组件。
2305
-
2306
- ```ts
2307
- import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
2308
- import 'weifuwu/components/style.css' // 包含 Token + 67 布局原语 + 组件样式,一次性引入
2309
- ```
2310
-
2311
- ### 使用示例
2312
-
2313
- ```tsx
2314
- // ├─ 按钮
2315
- <Button variant="primary" onClick={() => alert('提交')}>提交</Button>
2316
- <Button variant="ghost" loading>加载中</Button>
2317
- <Button variant="danger" size="lg" block>删除</Button>
2318
-
2319
- // ├─ 输入框
2320
- <Input placeholder="请输入邮箱" />
2321
- <Input label="用户名" name="username" required error="必填" />
2322
- <Input type="password" hint="至少6位" />
2323
- <Input name="email" type="email" disabled placeholder="name@example.com" />
2324
-
2325
- // ├─ 选择器
2326
- <Select options={[{ value: 'a', label: '选项A' }]} placeholder="请选择" />
2327
- <Select searchable options={options} onChange={v => setVal(v)} />
2328
-
2329
- // ├─ 复选框 / 开关 / 单选
2330
- <Checkbox checked={agree} onChange={setAgree} label="同意协议" />
2331
- <Switch checked={enabled} onChange={setEnabled} />
2332
- <RadioGroup options={[{ value: '1', label: '男' }, { value: '2', label: '女' }]} value={gender} />
2333
-
2334
- // ├─ 表格
2335
- <Table columns={[{ key: 'id', label: 'ID', sortable: true }, { key: 'name', label: '名称' }]}
2336
- data={rows} sortKey="id" sortOrder="asc" onSort={(k, o) => setSort(k, o)} />
2337
-
2338
- // ├─ 模态框 / 确认框 / 抽屉
2339
- <Modal open={show} title="提示" onClose={() => setShow(false)} width="500px" closable>
2340
- <p>确认删除?</p>
2341
- </Modal>
2342
- <Confirm open={confirming} message="确定删除?" variant="danger" onConfirm={doDelete} onCancel={() => setConfirming(false)} />
2343
- // 命令式:await ctx.confirm?.('确定删除?') —— 组件里直接调用
2344
- <Drawer open={open} title="详情" onClose={() => setOpen(false)} position="right">内容</Drawer>
2345
-
2346
- // ├─ 消息提示
2347
- <Toast toasts={items} position="top-right" max={5} onRemove={id => remove(id)} />
2348
- <Alert variant="warning" closable>注意:磁盘空间不足</Alert>
2349
-
2350
- // ├─ 标签 / 徽标 / 头像
2351
- <Badge variant="primary">消息</Badge>
2352
- <Badge variant="success" dot>通过</Badge>
2353
- <Tag variant="primary" closable onClose={() => {}}>标签</Tag>
2354
- <Avatar name="张三" size="lg" />
2355
-
2356
- // ├─ 卡片 / 统计卡片
2357
- <Card variant="outlined" padding="md">卡片内容</Card>
2358
- <StatCard label="总用户" value="1,234" trend="up" trendLabel="12%" />
2359
-
2360
- // ├─ 标签页 / 下拉菜单
2361
- <Tabs items={[{ key: 'a', label: '标签A' }, { key: 'b', label: '标签B' }]} active="a" onChange={setTab} />
2362
- <Dropdown items={[{ label: '编辑', onClick: () => {} }, { label: '删除', variant: 'danger' }]}>操作</Dropdown>
2363
-
2364
- // ├─ 分页 / 步骤条
2365
- <Pagination total={100} page={1} pageSize={10} onChange={setPage} />
2366
- <Steps items={[{ key: 's1', label: '第一步' }, { key: 's2', label: '第二步' }]} current={1} />
2367
-
2368
- // ├─ 滑块 / 进度条
2369
- <Slider min={0} max={100} value={50} onChange={setValue} />
2370
- <ProgressBar value={75} label="75%" />
2371
-
2372
- // ├─ 面包屑 / 分割线
2373
- <Breadcrumb items={[{ label: '首页' }, { label: '用户管理' }]} />
2374
- <Divider />
2375
- <Divider>分割文字</Divider>
2376
-
2377
- // ├─ 加载 / 空状态 / 骨架屏
2378
- <Loading text="加载中..." />
2379
- <EmptyState text="暂无数据" hint="请先创建一条记录"><Button>新建</Button></EmptyState>
2380
- <Skeleton variant="text" lines={3} />
2381
- <Skeleton variant="table" lines={5} cols={4} />
2382
- <Skeleton variant="avatar" />
2383
- <Skeleton variant="image" />
2384
-
2385
- // ├─ 表单验证
2386
- <Form validation={{ email: [{ required: true, message: '请输入邮箱' }] }}
2387
- onSubmit={values => ctx.api?.post('/login', values)} // ctx.api 由中间件注入
2388
- onError={errors => setErrors(errors)}>
2389
- <Field label="邮箱" error={errors.email}>
2390
- <Input name="email" />
2391
- </Field>
2392
- <Button type="submit">登录</Button>
2393
- </Form>
2394
-
2395
- // ├─ 新增批次组件(全量实现)
2396
- <Rate value={3} onChange={setRate} /> // 评分
2397
- <ToggleGroup type="single" options={toolbar} value={fmt} /> // 工具栏切换
2398
- <CheckboxGroup options={members} value={selected} onChange={setSelected} /> // 多选列表
2399
- <PinInput length={6} value={code} onChange={setCode} /> // 验证码
2400
- <CopyButton value="https://weifuwu.dev" label="复制" /> // 复制
2401
- <ColorPicker value={color} showInput onChange={setColor} /> // 颜色
2402
- <Notification /> + notification.success({ title, description }) // 队列通知
2403
- <Collapse items={docs} active={open} /> // 行内折叠
2404
- <Tree data={orgTree} checkable checkedKeys={keys} /> // 树形(父子联动)
2405
- <Cascader options={regions} value={['zj','hz']} /> // 级联选择
2406
- <Transfer data={members} targetKeys={selected} /> // 穿梭框
2407
- <Command items={commands} open={open} onOpenChange={setOpen} /> // ⌘K 命令面板
2408
- <Carousel autoplay>{slides}</Carousel> // 轮播
2409
- <Resizable defaultSize={180}>…</Resizable> // 拖拽分割
2410
- <Calendar month={5} year={2025} events={events} /> // 月历
2411
- <Watermark text="内部资料">…</Watermark> // 水印
2412
- <VirtualList height={400} itemHeight={36} items={rows} renderItem={render} /> // 虚拟列表
2413
- <QRCode value="https://weifuwu.dev" size={128} /> // 二维码(自研编码)
2414
- <Img src="photo.png" preview /> // 图片点击放大
2415
- <BackTop /> <Affix offsetTop={64}>…</Affix> // 回顶 / 固定
2416
- <HoverCard content={<UserCard />}>…</HoverCard> // 悬停富内容
2417
- <Mentions options={users} value={text} /> // @提及
2418
- <ContextMenu items={actions}>…</ContextMenu> // 右键菜单
2419
- <Menubar menus={menus} /> // 水平菜单
2420
- <InfiniteScroll hasMore onLoadMore>…</InfiniteScroll> // 无限滚动
2421
- ```
2422
-
2423
- > 所有组件引用 `--wf-*` CSS 变量做主题,详见下文的「样式定制指南」。
2424
-
2425
- ### 生命周期映射
2426
-
2427
- 组件没有生命周期函数。每个阶段对应到代码的明确位置:
2428
-
2429
- ```
2430
- mount ──────────────────────────────────────────
2431
- const Counter = (_init, ctx) => { ← mount(只一次)
2432
- let count = 0 ← 初始化状态
2433
- return (props) => { ← render 函数
2434
- // ... ← 每次 dirty/props 变化执行
2435
- }
2436
- }
2437
-
2438
- ref ────────────────────────────────────────────
2439
- h('div', {
2440
- ref: (el) => {
2441
- if (el) { /* 元素已创建 */ } ← 相当于 onmounted
2442
- else { /* 元素已移除 */ } ← 相当于 onunmount
2443
- }
2444
- })
2445
-
2446
- props 变化 ─────────────────────────────────────
2447
- return (props) => {
2448
- // 每次 render 都收到最新 props ← 相当于 onupdate
2449
- if (props.value !== prevValue) { ... }
2450
- }
2451
- ```
2452
-
2453
- | 旧概念 | 新写法 |
2454
- |--------|--------|
2455
- | `onmount` | mount 外层函数直接写 |
2456
- | `onmounted` | `ref` 的 `if (el)` 分支 |
2457
- | `onunmount` | `ref` 的 `else` 分支 |
2458
- | `onupdate` | render 内层函数收新 props 自行比较 |
2459
- | `全局刷新` | `ctx.ui.render(['_wf_root'])` |
2460
- | `局部刷新` | `ctx.ui.render()` 或 `$.x = val` |
2461
- | `跨组件刷新` | `ctx.ui.selfId('name')` + `render(['name'])` |
2462
-
2463
- ## 组件列表
2464
-
2465
- ### 表单核心
2466
-
2467
- | 组件 | 导入名 | 关键 Props | 说明 |
2468
- |-----|--------|-----------|------|
2469
- | Button | `Button` | `variant`, `size`, `loading`, `disabled`, `block`, `type` | 按钮 |
2470
- | Input | `Input` | `label`, `name`, `type`, `value`, `placeholder`, `required`, `disabled`, `error`, `hint`, `onInput`, `onChange` | 输入框 |
2471
- | Textarea | `Textarea` | `rows`, `maxLength`, `showCount`, `error` | 文本域 |
2472
- | Select | `Select` | `options: SelectOption[]`, `placeholder`, `searchable` | 下拉选择 |
2473
-
2474
- ### 表单选择
2475
-
2476
- | 组件 | 导入名 | 关键 Props | 说明 |
2477
- |-----|--------|-----------|------|
2478
- | Checkbox | `Checkbox` | `checked`, `label`, `onChange` | 复选框 |
2479
- | Switch | `Switch` | `checked`, `label`, `onChange` | 开关 |
2480
- | RadioGroup | `RadioGroup` | `options: RadioOption[]`, `value`, `name` | 单选组 |
2481
- | Slider | `Slider` | `min`, `max`, `step`, `value`, `onChange` | 滑块 |
2482
-
2483
- ### 表单增强
2484
-
2485
- | 组件 | 导入名 | 关键 Props | 说明 |
2486
- |-----|--------|-----------|------|
2487
- | Form | `Form` | `onSubmit`, `validation` | 表单容器 |
2488
- | Field | `Field` | `label`, `error`, `required`, `hint` | 字段包装 |
2489
- | FileUpload | `FileUpload` | `accept`, `multiple`, `maxSize`, `onChange` | 文件上传 |
2490
- | SearchInput | `SearchInput` | `value`, `placeholder`, `onInput`, `onClear` | 搜索框 |
2491
- | SegmentedControl | `SegmentedControl` | `options: SegmentedOption[]`, `value`, `onChange`, `size` | 分段选择器 |
2492
- | ProgressBar | `ProgressBar` | `value`, `max`, `label`, `showValue` | 进度条 |
2493
- | InputNumber | `InputNumber` | `value`, `min`, `max`, `step`, `precision`, `onChange` | 数字输入(增减按钮) |
2494
- | PasswordInput | `PasswordInput` | `value`, `onInput`, `autoComplete` | 密码输入(可见性切换) |
2495
- | TagsInput | `TagsInput` | `value: string[]`, `maxTags`, `allowDuplicates` | 标签输入(中文输入法感知) |
2496
-
2497
- ### 数据展示
2498
-
2499
- | 组件 | 导入名 | 关键 Props | 说明 |
2500
- |-----|--------|-----------|------|
2501
- | Table | `Table` | `columns: TableColumn[]`, `data`, `loading`, `sortKey`, `sortOrder`, `onSort`, `onRowClick` | 表格 |
2502
- | Card | `Card` | `variant`, `outlined`, `padding`, `clickable`, `hover`, `active`, `onClick` | 卡片 |
2503
- | Badge | `Badge` | `variant: BadgeVariant`, `dot` | 徽标 |
2504
- | Tag | `Tag` | `variant: 'default'\|'primary'\|'success'\|'danger'`, `closable`, `onClose` | 标签 |
2505
- | Avatar | `Avatar` | `src`, `name`, `size`, `color` | 头像 |
2506
- | AvatarGroup | `AvatarGroup` | `items`, `max`, `size` | 头像组(堆叠 + 溢出 +N) |
2507
- | Timeline | `Timeline` | `items: TimelineItem[]`, `mode`, `reverse` | 时间线(执行日志/历史) |
2508
- | Descriptions | `Descriptions` | `items: DescriptionItem[]`, `column`, `bordered` | 描述列表(详情页字段) |
2509
- | Markdown | `Markdown` | `content` | AI 回复渲染(安全子集 parser) |
2510
- | CodeBlock | `CodeBlock` | `code`, `lang`, `title` | 代码块(语言标签 + 复制) |
2511
- | Highlight | `Highlight` | `text`, `query: string \| string[]` | 搜索词高亮(mark) |
2512
- | List | `List` | `items`, `renderItem`, `divided`, `header/footer/empty` | 通用列表 |
2513
- | Result | `Result` | `status`, `title`, `desc`, `extra` | 结果页(成功/失败/警告/信息) |
2514
- | Icon | `Icon` | `name: IconName`, `size` | 图标(内置 25 个 stroke 图标,currentColor 随字号) |
2515
- | StatCard | `StatCard` | `label`, `value`, `trend: 'up'\|'down'`, `trendLabel`, `icon`, `animate` | 统计卡片 |
2516
- | PageHeader | `PageHeader` | `title`, `sub`, `display` | 页面标题(actions 放 children) |
2517
- | Img | `Img` | `src`, `alt`, `fallback`, `loading`, `width`, `height` | 图片(含 fallback) |
2518
- | InView | `InView` | `once`, `threshold`, `rootMargin`, `placeholder`, `onEnter` | 进入视窗后懒加载内容 |
2519
-
2520
- ### 数据反馈
2521
-
2522
- | 组件 | 导入名 | 关键 Props | 说明 |
2523
- |-----|--------|-----------|------|
2524
- | Modal | `Modal` | `open`, `title`, `onClose`, `width`, `footer`, `closable` | 模态框 |
2525
- | Confirm | `Confirm` | `open`, `message`, `confirmText`, `cancelText`, `variant`, `onConfirm`, `onCancel` | 确认对话框(同 `ctx.confirm()` 命令式) |
2526
- | Drawer | `Drawer` | `open`, `title`, `position: DrawerPosition`, `onClose`, `footer` | 抽屉 |
2527
- | Tooltip | `Tooltip` | `content`, `position: TooltipPosition`, `disabled` | 工具提示(hover/focus 触发) |
2528
- | Popover | `Popover` | `content`, `position: PopoverPosition`, `trigger`, `open`, `onOpenChange`, `disabled` | 弹出层 |
2529
- | Toast | `Toast` | `toasts: ToastItem[]`, `position`, `max`, `onRemove` | 消息提示 |
2530
- | Alert | `Alert` | `variant: AlertVariant`, `closable`, `onClose` | 警告提示(内容放 children) |
2531
- | Loading | `Loading` | `text` | 加载中 |
2532
- | EmptyState | `EmptyState` | `icon`, `text`, `hint` | 空状态(操作放 children) |
2533
- | Skeleton | `Skeleton` | `variant: SkeletonVariant`, `lines`, `cols`, `width`, `height` | 骨架屏 |
2534
-
2535
- ### 导航组件
2536
-
2537
- | 组件 | 导入名 | 关键 Props | 说明 |
2538
- |-----|--------|-----------|------|
2539
- | Breadcrumb | `Breadcrumb` | `items: BreadcrumbItem[]` | 面包屑 |
2540
- | Menu | `Menu` | `items: MenuItem[]`, `activeKey`, `onSelect` | 侧栏导航(分组 + 图标 + 方向键) |
2541
- | Tabs | `Tabs` | `items: TabItem[]`, `active`, `onChange` | 标签页 |
2542
- | Dropdown | `Dropdown` | `trigger`, `items: DropdownItem[]`, `open`, `onOpenChange` | 下拉菜单 |
2543
- | Pagination | `Pagination` | `total`, `page`, `pageSize`, `onChange` | 分页 |
2544
- | Steps | `Steps` | `items: StepItem[]`(`{ key, label }`), `current`, `active` | 步骤条 |
2545
- | Accordion | `Accordion` | `items: AccordionItem[]`, `multiple` | 手风琴 |
2546
-
2547
- ### 新增批次(全量 92 组件)
2548
-
2549
- | 组件 | 导入名 | 关键 Props | 说明 |
2550
- |-----|--------|-----------|------|
2551
- | Rate | `Rate` | `value`, `count`, `onChange`, `allowClear`, `readOnly`, `size` | 评分(键盘方向键/Home/End) |
2552
- | Typography | `Title` `Text` `Paragraph` | `Title: level 1-5`;`Text: type/strong/underline/strike/mark/code`;`Paragraph: ellipsis` | 语义排版(Title/Text/Paragraph 三组件) |
2553
- | Label | `Label` | `htmlFor`, `required` | 独立标签(必填星号) |
2554
- | AspectRatio | `AspectRatio` | `ratio` | 宽高比容器(内容填满) |
2555
- | Toggle | `Toggle` | `pressed`, `onPressedChange`, `variant`, `size` | 切换按钮(shadcn 对齐) |
2556
- | ToggleGroup | `ToggleGroup` | `type: 'single'\|'multiple'`, `options`, `value`, `onChange` | 切换组 |
2557
- | CheckboxGroup | `CheckboxGroup` | `options`, `value: string[]`, `onChange`, `cols` | 复选框组(栅格列数) |
2558
- | PinInput | `PinInput` | `length`, `value`, `onChange`, `type` | 验证码输入(自动聚焦/粘贴分派/回退) |
2559
- | CopyButton | `CopyButton` | `value`, `label`, `onCopy` | 复制按钮(clipboard + execCommand 降级) |
2560
- | ColorPicker | `ColorPicker` | `value`, `onChange`, `showInput`, `preset` | 颜色选择(预设色板 + hex 输入) |
2561
- | HoverCard | `HoverCard` | `content`, `position`, `openDelay`, `closeDelay` | 悬停富内容卡(shadcn) |
2562
- | Notification | `Notification` | 命令式 `notification.success/error/warning/open` | 队列式通知(antd 对齐) |
2563
- | BackTop | `BackTop` | `visibilityHeight`, `target`, `smooth` | 回到顶部(滚动超阈值显示) |
2564
- | Affix | `Affix` | `offsetTop`, `target` | 固定定位(滚动超阈值钉住) |
2565
- | ContextMenu | `ContextMenu` | `items: ContextMenuItem[]`(`{ label, onClick, variant: 'danger' }`) | 右键菜单(光标定位 + 方向键) |
2566
- | Mentions | `Mentions` | `options: { value, label }[]`, `value`, `onChange`, `prefix` | @提及(composition 抑制) |
2567
- | Collapse | `Collapse` | `items: CollapseItem[]`(`{ key, title, content, loading }`), `active`, `multiple` | 行内折叠(异步 loading) |
2568
- | Tree | `Tree` | `data: TreeNode[]`, `expandedKeys`, `checkedKeys`, `checkable`, `selectedKeys`, `onCheck/onExpand/onSelect` | 树(递归 + 勾选父子联动 + 半选传播) |
2569
- | Cascader | `Cascader` | `options: CascaderOption[]`, `value: string[]`, `onChange` | 级联选择(多列推进) |
2570
- | Transfer | `Transfer` | `data: { key, label }[]`, `targetKeys`, `onChange`, `titles` | 穿梭框(选中 + 批量移动) |
2571
- | Command | `Command` | `items: CommandItem[]`, `open`, `onOpenChange`, `shortcut` | 命令面板(⌘K 全局 + 键盘流) |
2572
- | Menubar | `Menubar` | `menus: { key, label, items }[]` | 水平菜单栏(←→ 切换 + ↓ 展开) |
2573
- | Carousel | `Carousel` | `children`, `autoplay`, `interval`, `loop`, `showArrows/Dots` | 轮播(箭头/圆点/循环/自动播放) |
2574
- | Resizable | `Resizable` | `direction`, `defaultSize`, `min/maxSize` | 拖拽分割面板(pointer + 键盘方向键) |
2575
- | Calendar | `Calendar` | `month`, `year`, `events`, `selectedDate`, `onMonthChange/onSelectDate` | 月历(事件点 + 月切换 + 选日) |
2576
- | Watermark | `Watermark` | `text`, `fontSize`, `rotate`, `zIndex` | 水印(canvas 平铺) |
2577
- | VirtualList | `VirtualList` | `items`, `height`, `itemHeight`, `renderItem`, `overscan` | 虚拟列表(spacer + 可见窗口,1000+ 条) |
2578
- | InfiniteScroll | `InfiniteScroll` | `hasMore`, `loadMore`, `children`, `loader` | 触底加载(IntersectionObserver) |
2579
- | QRCode | `QRCode` | `value`, `ecLevel`, `size`, `color`, `bgColor` | 二维码(自研 Reed-Solomon,版本 1-6) |
2580
-
2581
- ### 图表
2582
-
2583
- | 组件 | 导入名 | 关键 Props | 说明 |
2584
- |-----|--------|-----------|------|
2585
- | Chart | `Chart` | `type: ChartType`, `data`, `options`, `title`, `area` | SVG 图表(line/bar/pie)|
2586
- | DatePicker | `DatePicker` | `mode: DatePickerMode`, `value`, `onChange`, `placeholder`, `disabled` | 日期选择器(date/datetime/time/range)|
2587
- | Editor | `Editor` | `value`, `onChange`, `toolbar`, `placeholder`, `disabled` | 富文本编辑器,零依赖 |
2588
-
2589
- ### 布局
2590
-
2591
- | 组件 | 导入名 | 关键 Props | 说明 |
2592
- |-----|--------|-----------|------|
2593
- | Divider | `Divider` | `vertical` | 分割线(水平带文字放 children,`vertical` 垂直) |
2594
-
2595
- ### AI 交互原语(wf: 协议配套)
2596
-
2597
- | 组件 | 导入名 | 关键 Props | 说明 |
2598
- |-----|--------|-----------|------|
2599
- | AiChat | `AiChat` | `chat`, `maxHeight?`, `labels?`, `renderMessage?`, `renderToolArgs?` | 标准 AI 对话界面:气泡 + 工具卡 + 审批卡 + 自动滚动 + 错误重试(接收 `ctx.ui.useChat()` handle) |
2600
- | MessageBubble | `MessageBubble` | `content`, `role`, `status`, `actions` | 独立消息气泡(业务聊天页复用) |
2601
- | ToolCallCard | `ToolCallCard` | `call`, `progress?`, `result?`, `renderArgs?` | 工具调用卡片:running(进度条)/ ok / error 三态(协议 §4) |
2602
- | ApprovalCard | `ApprovalCard` | `request`, `status?`, `onApprove`, `onReject` | 人工审批卡片:待批(允许/拒绝+备注)/ 已批 / 已拒 / 超时(协议 §4.5) |
2603
-
2604
- ### 全局工具
2605
-
2606
- | 组件 | 导入名 | 关键 Props | 说明 |
2607
- |-----|--------|-----------|------|
2608
- | ThemeSwitch | `ThemeSwitch` | `mode: 'auto'\|'light'\|'dark'`, `onChange`, `storageKey` | 主题切换(auto/light/dark,localStorage 持久化);另有 `applyTheme()` / `getTheme()` 命令式工具 |
2609
-
2610
- ---
2611
-
2612
- # 布局系统 (`weifuwu/layout`)
2613
-
2614
- 纯 CSS 布局原语 + 工具类 + 141 个主题 Token。不绑定任何 JS 框架。
2615
-
2616
- > **学习路径与命名规范**:见 [`docs/style-guide.md`](./docs/style-guide.md)——统一语法 `wf-<域>-<名>`、三档学习(组件 → 10 核心原语 → 完整速查)、场景速查、变量定制。
2617
-
2618
- > **全栈 weifuwu 项目**:`weifuwu/components/style.css` 已包含布局系统,一条 import 就够了,无需单独引用本页。
2619
- > 本页仅适用于**非 weifuwu 项目**或**只需 CSS 布局**的场景。
2620
-
2621
- ```html
2622
- <link rel="stylesheet" href="/node_modules/weifuwu/layout">
2623
- ```
2624
-
2625
- 或在 weifuwu 服务端通过 `ctx.ui.css` 直接引用包名(`ctx.ui.css` 自动解析 exports map):
2626
-
2627
- ```ts
2628
- // 方案 A:组件 + 布局全部搞定(推荐)
2629
- app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css'))
2630
-
2631
- // 方案 B:只用布局
2632
- app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
2633
- ```
2634
-
2635
- 也支持相对路径:`ctx.ui.css('./src/style.css')`。
2636
-
2637
- ## 67 个布局原语
2638
-
2639
- | 类别 | 原语 | 效果 |
2640
- |------|------|------|
2641
- | **排列** | `wf-stack` `wf-stack@sm/md/lg` | 纵向 flex + gap(断点变体→横向) |
2642
- | | `wf-stack-reverse` `@sm/md/lg` | 纵向反向 |
2643
- | | `wf-row` `wf-row@sm/md/lg` | 横向 flex + wrap + gap |
2644
- | | `wf-row-reverse` `@sm/md/lg` | 横向反向 |
2645
- | | `wf-nowrap` | flex-wrap: nowrap |
2646
- | | `wf-cluster` | 换行居中簇 |
2647
- | **分布** | `wf-split` | justify-content: space-between |
2648
- | | `wf-center` | 双轴居中 |
2649
- | | `wf-right` | justify-content: flex-end |
2650
- | | `wf-around` | space-around |
2651
- | | `wf-evenly` | space-evenly |
2652
- | **对齐** | `wf-top` | align-items: flex-start |
2653
- | | `wf-bottom` | align-items: flex-end |
2654
- | | `wf-stretch` | align-items: stretch |
2655
- | **弹性** | `wf-fill` | flex: 1 + min-width: 0 |
2656
- | | `wf-fixed` | flex: none |
2657
- | | `wf-auto` | flex: auto |
2658
- | | `wf-shrink` | min-width/height: 0 |
2659
- | **Z轴** | `wf-cover` | position: fixed + inset: 0 |
2660
- | | `wf-pop` | position: absolute |
2661
- | | `wf-anchor` | position: relative |
2662
- | | `wf-layer` | position: relative + z-index |
2663
- | | `wf-sticky` | position: sticky |
2664
- | **容器** | `wf-surface` | 基础面(border-radius + shadow + bg) |
2665
- | | `wf-grid` | display: grid + --wf-cols |
2666
- | | `wf-container` | max-width + margin: auto |
2667
- | | `wf-scroll` | overflow: auto |
2668
- | | `wf-clip` | overflow: hidden |
2669
- | **显隐** | `wf-hidden` `wf-hidden@sm/md/lg` | display: none |
2670
- | | `wf-block` `wf-block@sm/md/lg` | display: block |
2671
- | | `wf-inline` | display: inline |
2672
- | | `wf-inline-block` | display: inline-block |
2673
- | | `wf-contents` | display: contents |
2674
- | **间距** | `wf-p-*` / `wf-px-*` / `wf-py-*`(xs~2xl) | padding:全/水平/垂直,引用 `--wf-space-*` |
2675
- | | `wf-mt-*` / `wf-mb-*` / `wf-my-*`(xs~2xl) | margin:top/bottom/垂直 |
2676
- | | `wf-mx-auto` / `wf-my-auto` | margin: auto 居中 |
2677
- | | `wf-gap-*`(xs~2xl) | 为 flex/grid 原语设置 `--wf-gap` |
2678
- | **尺寸** | `wf-w-full` / `wf-h-full` / `wf-w-auto` | 宽/高 100%、auto |
2679
- | **边框** | `wf-border` / `wf-border-t/b/l/r` | 1px 边框(`--wf-border-width` + `--wf-color-border`) |
2680
- | **面工具** | `wf-bg-secondary/tertiary/brand/success/warning/error/info` | 语义背景色(`--wf-color-*-bg`) |
2681
- | | `wf-pill` | 胶囊圆角(999px,状态徽章/标签/色块) |
2682
- | | `wf-rounded-sm` `wf-rounded` `wf-rounded-md` `wf-rounded-lg` | 圆角工具(`--wf-radius-*`) |
2683
- | **气泡** | `wf-bubble` / `wf-bubble--own` / `wf-bubble--ai` | 聊天气泡(pre-wrap + 折行内建) |
2684
- | **打印** | `wf-print-hidden` / `wf-print-block` | 导出 PDF 时隐藏工具区 / 恢复块级 |
2685
- | **行高** | `wf-leading-tight` `wf-leading-base` `wf-leading-relaxed` | line-height(`--wf-line-height-*`) |
2686
- | **指针** | `wf-pointer` / `wf-not-allowed` | cursor: pointer / not-allowed |
2687
- | **内容排版** | `wf-prose` | 富文本正文(文章/博客/文档,一个类包 h2/p/ul/blockquote/pre…) |
2688
- | **外壳** | `wf-app-shell` | 应用外壳:侧边栏 + 主区 grid(`--wf-sidebar-width`) |
2689
- | | `wf-sidebar` `wf-sidebar-header` `wf-sidebar-body` `wf-sidebar-footer` | 侧边栏:品牌区/导航区/底部用户区,sticky 全高 |
2690
- | | `wf-nav` `wf-nav-group` `wf-nav-item` `wf-nav-icon` | 导航:分组标题 + 链接项(`--active` 激活态) |
2691
- | | `wf-main` | 主内容区(padding + min-width: 0) |
2692
- | | `wf-text-*` 排版工具 | 见下文「排版工具」 |
2693
-
2694
- ### 排版工具(`wf-text-*`)
2695
-
2696
- | 工具 | 效果 |
2697
- |------|------|
2698
- | `wf-text-left/center/right` | text-align |
2699
- | `wf-text-xs…5xl` | 字号(`--wf-font-size-*`) |
2700
- | `wf-text-secondary/tertiary/disabled/brand` | 中性色阶 |
2701
- | `wf-text-success/warning/error/info` | 语义色文本(`--wf-color-*`) |
2702
- | `wf-text-medium/semibold/bold` | 字重 |
2703
- | `wf-tracking-normal/wide/wider` | letter-spacing |
2704
- | `wf-uppercase/lowercase/capitalize` | text-transform |
2705
- | `wf-pre-wrap` | white-space: pre-wrap + word-break(聊天气泡/代码) |
2706
- | `wf-break-word` | overflow-wrap + word-break |
2707
- | `wf-text-nowrap` | white-space: nowrap |
2708
- | `wf-truncate` | 单行省略(ellipsis) |
2709
- | `wf-line-clamp-2/3` | 多行截断 |
2710
-
2711
- ## 141 个主题 Token
2712
-
2713
- **双层结构**:原始层(Primitive,色值只定义一次,品牌/暗色调校改这里)+ 语义层(Semantic,组件消费)。
2714
-
2715
- ```css
2716
- /* ── 原始层 — 品牌/中性色值 + 暗色值,主题定制改这一层 ── */
2717
- --wf-brand-500 / --wf-brand-600 / --wf-brand-50 /* 品牌主色/悬停/浅底 */
2718
- --wf-slate-900…50 / --wf-white /* 中性阶 */
2719
- --wf-dark-* /* 暗色值(暗色模式经间接层引用,零硬编码) */
2720
-
2721
- /* ── 语义层 — 组件消费,暗色/主题切换覆盖这里 ── */
2722
- /* 品牌色 */
2723
- --wf-color-primary / --wf-color-primary-hover / --wf-color-primary-bg
2724
-
2725
- /* 语义色 */
2726
- --wf-color-success / --wf-color-success-bg
2727
- --wf-color-warning / --wf-color-warning-bg
2728
- --wf-color-error / --wf-color-error-bg
2729
- --wf-color-info / --wf-color-info-bg
2730
-
2731
- /* 语义文字色(P2):浅底可读 700 级,文字用 -text、填充用 500 级 */
2732
- --wf-color-primary-text / --wf-color-success-text / --wf-color-warning-text / --wf-color-error-text / --wf-color-info-text
2733
- --wf-color-on-brand /* 实心品牌/语义底上的文字与图标 */
2734
- --wf-overlay /* 浮层遮罩(Modal/Drawer),暗色自动加深 */
2735
-
2736
- /* 文字色 */
2737
- --wf-color-text / --wf-color-text-secondary / --wf-color-text-tertiary / --wf-color-text-disabled
2738
-
2739
- /* 背景色 */
2740
- --wf-color-bg / --wf-color-bg-secondary / --wf-color-bg-tertiary
2741
-
2742
- /* 边框色 */
2743
- --wf-color-border / --wf-color-border-light / --wf-color-border-dark
2744
-
2745
- /* 字体 */
2746
- --wf-font-sans / --wf-font-mono
2747
-
2748
- /* 字号: xs sm base lg xl 2xl 3xl 4xl 5xl display */
2749
- --wf-font-size-*
2750
-
2751
- /* 字重: normal medium semibold bold */
2752
- --wf-font-weight-*
2753
-
2754
- /* 行高: tight normal relaxed */
2755
- --wf-line-height-*
2756
-
2757
- /* 字距: normal wide wider */
2758
- --wf-letter-spacing-*
2759
-
2760
- /* 间距: xs sm md lg xl 2xl */
2761
- --wf-space-*
2762
-
2763
- /* 间隔: xs sm md lg xl 2xl */
2764
- --wf-gap-*
2765
-
2766
- /* 圆角: sm md lg xl */
2767
- --wf-radius-*
2768
-
2769
- /* 阴影: sm md lg */
2770
- --wf-shadow-*
2771
-
2772
- /* 动效(P0):时长阶梯/缓动曲线/位移量,全站动效统一引用 */
2773
- --wf-dur-fast / --wf-dur-base / --wf-dur-slow
2774
- --wf-ease-out / --wf-ease-in / --wf-ease-snap
2775
- --wf-motion-sm / --wf-motion-md / --wf-motion-lg
2776
-
2777
- /* 表头/分组标题(P5):CJK 感知,默认 none/0,英文可覆盖 */
2778
- --wf-heading-case / --wf-heading-tracking
2779
-
2780
- /* 数字(P5):tabular-nums 防宽度抖动(wf-nums 工具类) */
2781
- --wf-nums
2782
-
2783
- /* 其他 */
2784
- --wf-border-width / --wf-focus-ring
2785
- --wf-transition-duration / --wf-transition-timing
2786
- --wf-accent-color / --wf-caret-color
2787
- --wf-opacity-disabled / --wf-opacity-overlay
2788
- --wf-pop-z / --wf-cover-z
2789
-
2790
- /* 应用外壳 */
2791
- --wf-sidebar-width
2792
- ```
2793
-
2794
- ### 暗色模式
2795
-
2796
- 两种激活方式(显式 `data-theme` 优先级更高):
2797
-
2798
- ```ts
2799
- // 1. 手动切换
2800
- // document.documentElement.setAttribute('data-theme', 'dark')
2801
- // document.documentElement.setAttribute('data-theme', 'light') // 强制亮色
2802
-
2803
- // 2. 自动:系统暗色偏好(无需任何代码)
2804
- // 系统为暗色时自动生效;加 data-theme="light" 可强制亮色
2805
- ```
2806
-
2807
- 暗色值定义在原始层 `--wf-dark-*`(只写一次),`_dark.css` 两段仅做语义映射——改暗色调校只动原始层,无硬编码色值。
2808
-
2809
- ---
2810
-
2811
- # 样式定制指南
2812
-
2813
- ## 组件定制钩子(零覆盖 CSS)
2814
-
2815
- 关键组件视觉全部变量化——定制只需设一个变量(默认值 = 现有 token):
2816
-
2817
- ```html
2818
- <style>
2819
- :root {
2820
- --wf-brand-500: #7c3aed; /* 品牌换色:改原始层一个值,全站跟随 */
2821
- --wf-dark-brand-500: #a78bfa; /* 暗色品牌(可选) */
2822
- --wf-modal-width: 640px; /* 组件定制:设一个变量 */
2823
- --wf-btn-radius: 999px;
2824
- --wf-field-height: 44px;
2825
- --wf-card-shadow: 0 8px 24px rgba(0,0,0,.12);
2826
- }
2827
- </style>
2828
- ```
2829
-
2830
- 钩子清单:`--wf-btn-*` `--wf-card-*` `--wf-field-*` `--wf-modal-*` `--wf-drawer-width` `--wf-toast-*` `--wf-alert-radius` `--wf-badge-radius` `--wf-tag-radius` `--wf-switch-radius` `--wf-popover-*` `--wf-tooltip-radius` `--wf-dropdown-min-width` `--wf-datepicker-*`。
2831
-
2832
- **覆盖优先级(@layer)**:`@layer tokens, base, layout, utilities, components`——你写的未分层 CSS 天然最高优先级;也可用 `@layer utilities` 精准覆盖我们。
2833
-
2834
- ## 零自定义 CSS 模式(推荐)
2835
-
2836
- 一个项目只需要引用**一个 CSS 文件**(`weifuwu/components/style.css`,内含 Token + 布局原语 + 组件样式),
2837
- 业务代码全部由组件 + `wf-*` 原语承担,**不需要再写 `style.css`**:
2838
-
2839
- ```tsx
2840
- // 组件 → 页面功能块;wf-* 原语 → 块之间的空间关系;--wf-* → 业务色值
2841
- <PageHeader title="仪表盘" sub="欢迎回来">
2842
- <Button variant="primary">+ 新建</Button>
2843
- </PageHeader>
2844
- <div class="wf-row wf-gap-lg">
2845
- <StatCard label="总用户" value="1,234" trend="up" trendLabel="12%" />
2846
- </div>
2847
- ```
2848
-
2849
- 主题定制(品牌色/圆角/字体)不需要独立文件——**内联在 HTML 的 `<style>` 里即可**:
2850
-
2851
- ```html
2852
- <style>
2853
- :root {
2854
- --wf-color-primary: #6366f1;
2855
- --wf-radius: 8px;
2856
- }
2857
- </style>
2858
- ```
2859
-
2860
- 完整的零样式示例:`apps/components-demo`(组件 + 原语即插即用,无手写样式)。
2861
-
2862
- 诚实例外(合理场景,仍可内联 `<style>` 解决):打印/PDF 导出规则、第三方库宿主样式、
2863
- 业务特有的一次性视觉(如色板选择器交互)。
2864
-
2865
- ## 全局主题变量
2866
-
2867
- 所有组件引用 `--wf-*` CSS 变量。在根元素覆盖即可定制主题:
2868
-
2869
- ```css
2870
- :root {
2871
- --wf-color-primary: #6366f1;
2872
- --wf-color-primary-hover: #4f46e5;
2873
- --wf-radius: 8px;
2874
- --wf-font-sans: 'Inter', system-ui, sans-serif;
2875
- }
2876
- ```
2877
-
2878
- ## 暗色模式
2879
-
2880
- 两种激活方式(显式 `data-theme` 优先级高于系统偏好):
2881
-
2882
- ```ts
2883
- // 手动切换
2884
- document.documentElement.setAttribute('data-theme', 'dark')
2885
-
2886
- // 强制亮色(系统为暗色时也保持亮色)
2887
- document.documentElement.setAttribute('data-theme', 'light')
2888
- ```
2889
-
2890
- 未设置 `data-theme` 时,自动跟随系统偏好:`@media (prefers-color-scheme: dark)` 下自动切换暗色。
2891
-
2892
- 所有 `--wf-*` 变量在暗色下自动切换。可自定义暗色变量:
2893
-
2894
- ```css
2895
- [data-theme="dark"] {
2896
- --wf-color-bg: #1a1a2e;
2897
- --wf-color-text: #e0e0e0;
2898
- --wf-color-border: #2a2a4a;
2899
- }
2900
-
2901
- /* 自定义系统自动暗色的变量(需与上面同步) */
2902
- @media (prefers-color-scheme: dark) {
2903
- :root:not([data-theme="light"]) {
2904
- --wf-color-bg: #1a1a2e;
2905
- --wf-color-text: #e0e0e0;
2906
- --wf-color-border: #2a2a4a;
2907
- }
2908
- }
2909
- ```
2910
-
2911
- ## 组件级覆盖
2912
-
2913
- ```css
2914
- /* 覆盖 Button 主色 */
2915
- .wf-btn--primary {
2916
- background: #06b6d4;
2917
- border-color: #06b6d4;
2918
- }
2919
-
2920
- /* 覆盖 Modal 圆角 */
2921
- .wf-modal-content {
2922
- border-radius: 16px;
2923
- }
2924
- ```
2925
-
2926
- ## 作用域主题
2927
-
2928
- ```html
2929
- <div style="--wf-color-primary: #f59e0b;">
2930
- <!-- 此区域内组件使用金色主题,外部不受影响 -->
2931
- <button class="wf-btn wf-btn--primary">金色按钮</button>
2932
- </div>
2933
- ```
2934
-
2935
- CSS 变量会沿 DOM 树继承,利用这一点可实现多主题共存。
2936
-
2937
- ---
2938
-
2939
- # 组合场景示例
2940
-
2941
- ## 登录表单
2942
-
2943
- ```tsx
2944
- const LoginPage = (_init, ctx) => {
2945
- const $ = ctx.ui.$()
2946
- $.errors = {}
2947
- $.submitting = false
2948
-
2949
- return (props) =>
2950
- h('div', { class: 'wf-stack', style: { maxWidth: 400, margin: '40px auto' } },
2951
- h(Card, { padding: 'lg' },
2952
- h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
2953
- h('h2', {}, '登录'),
2954
- h(Form, {
2955
- validation: {
2956
- email: [{ required: true, pattern: /@/, message: '请输入有效邮箱' }],
2957
- password: [{ required: true, minLength: 6, message: '密码至少6位' }],
2958
- },
2959
- onSubmit: async (values) => {
2960
- $.submitting = true
2961
- await ctx.api?.post('/login', values) // api 客户端由中间件注入 ctx.api
2962
- $.submitting = false
2963
- },
2964
- onError: (errors) => { $.errors = errors },
2965
- }, [
2966
- h(Field, { label: '邮箱', error: $.errors.email },
2967
- h(Input, { name: 'email', type: 'email', placeholder: 'name@example.com' })),
2968
- h(Field, { label: '密码', error: $.errors.password },
2969
- h(Input, { name: 'password', type: 'password' })),
2970
- h(Button, { type: 'submit', loading: $.submitting, block: true }, '登录'),
2971
- ])
2972
- )
2973
- )
2974
- )
2975
- }
2976
- ```
2977
-
2978
- ## 数据列表 + 搜索
2979
-
2980
- ```tsx
2981
- const UserList = (_init, ctx) => {
2982
- const $ = ctx.ui.$()
2983
- $.keyword = ''
2984
- $.sortKey = 'name'
2985
- $.sortOrder = 'asc'
2986
- const users = [
2987
- { id: 1, name: '张三', email: 'zhang@example.com', role: '管理员' },
2988
- { id: 2, name: '李四', email: 'li@example.com', role: '编辑' },
2989
- ]
2990
-
2991
- return (props) => {
2992
- // 派生数据必须在 render 内计算(每次 render 读最新 $.keyword)
2993
- const filtered = users.filter(u =>
2994
- !$.keyword || u.name.includes($.keyword) || u.email.includes($.keyword)
2995
- )
2996
-
2997
- return h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
2998
- h('div', { class: 'wf-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
2999
- h(SearchInput, { placeholder: '搜索用户...', value: $.keyword, onInput: (e: Event) => { $.keyword = (e.target as HTMLInputElement).value } }),
3000
- h(Button, { variant: 'primary' }, '新建用户'),
3001
- ),
3002
- h(Table, {
3003
- columns: [
3004
- { key: 'id', label: 'ID', width: 60 },
3005
- { key: 'name', label: '姓名', sortable: true },
3006
- { key: 'email', label: '邮箱', sortable: true },
3007
- { key: 'role', label: '角色' },
3008
- ],
3009
- data: filtered,
3010
- sortKey: $.sortKey,
3011
- sortOrder: $.sortOrder,
3012
- onSort: (key, order) => { $.sortKey = key; $.sortOrder = order },
3013
- emptyText: '无匹配用户',
3014
- }),
3015
- h(Pagination, { total: filtered.length, page: 1, pageSize: 10, onChange: (p: number) => {} }),
3016
- )
3017
- }
3018
- }
3019
- ```
3020
-
3021
- ## 消息提示
3022
-
3023
- ```tsx
3024
- // 在任意组件中调用
3025
- let toastId = 0
3026
-
3027
- function showToast(ctx: WfuiContext, type: ToastType, message: string) {
3028
- // 通过 ctx 管理 Toast 列表
3029
- const $ = ctx.ui.$()
3030
- $.toasts = $.toasts ?? []
3031
- const id = String(++toastId)
3032
- $.toasts = [...$.toasts, { id, type, message }]
3033
-
3034
- // 自动消失
3035
- if (type !== 'error') {
3036
- setTimeout(() => {
3037
- $.toasts = $.toasts.filter((t: any) => t.id !== id)
3038
- }, 3000)
3039
- }
3040
- }
3041
-
3042
- // 页面中使用
3043
- const App = (_init, ctx) => {
3044
- const $ = ctx.ui.$()
3045
- $.toasts = []
3046
-
3047
- return (props) =>
3048
- h('div', {}, [
3049
- h(Button, {
3050
- onClick: () => showToast(ctx, 'success', '操作成功'),
3051
- }, '显示提示'),
3052
- h(Toast, {
3053
- toasts: $.toasts,
3054
- position: 'top-right',
3055
- max: 3,
3056
- onRemove: (id) => { $.toasts = $.toasts.filter((t: any) => t.id !== id) },
3057
- }),
3058
- ])
3059
- }
3060
- ```
3061
-
3062
- ---
3063
-
3064
- # 环境变量
3065
-
3066
- | 变量 | 用途 | 模块 |
3067
- |------|------|------|
3068
- | `DATABASE_URL` | PostgreSQL 连接字符串 | `postgres()` |
3069
- | `REDIS_URL` | Redis 连接字符串 | `redis()` |
3070
-
3071
- ---
3072
-
3073
- # 开发命令
3074
-
3075
- ```bash
3076
- npm run build # 构建 dist/
3077
- npm run typecheck # TypeScript 类型检查
3078
- npm test # 运行 node --test
3079
- node scripts/release.mjs <version> # 发布
3080
- ```
3081
-
3082
- ```bash
3083
- # 测试前启动依赖服务
3084
- docker compose up -d
3085
- ```
3086
-
3087
- ---
3088
-
3089
- # SaaS 地基模块(rateLimit / email / userSystem / messager / queue)
3090
-
3091
- 五个模块(限流 / 邮件 / 用户系统 / 消息系统 / 队列)以中间件形态随包提供,`app.use(...)` 一行接入。
3092
-
3093
- 四个内建模块组成一个"基本 SaaS 底座":认证、异步任务、限流、邮件——零新增依赖
3094
- (只依赖已自研的 redis / postgres 客户端与 node 标准库)。
3095
-
3096
- ## rateLimit — 限流
3097
-
3098
- ```ts
3099
- import { rateLimit } from 'weifuwu'
3100
-
3101
- app.use(redis()) // 依赖 ctx.redis
3102
- app.use(rateLimit({ windowMs: 60_000, max: 100 })) // 全局限流(默认固定窗口)
3103
-
3104
- app.get('/api/search', async (req, ctx) => {
3105
- await ctx.limit('search', { max: 30, windowMs: 60_000 }) // 手动限流,超限抛 429
3106
- })
3107
-
3108
- // 登录/注册防爆破:ctx.limit 默认按 IP 维度(每 IP 独立计数)
3109
- app.post('/api/auth/register', async (req, ctx) => {
3110
- await ctx.limit('register', { max: 5, windowMs: 60_000 }) // 每 IP 每分钟 5 次
3111
- })
3112
-
3113
- // 系统级总量限制:scope: 'global' 全局共享维度
3114
- await ctx.limit('total-jobs', { max: 1000, windowMs: 60_000, scope: 'global' })
3115
-
3116
- // 登录防爆破(配合 userSystem):组合键 ip:email(key 接收标准 Request,取头拿 IP)
3117
- app.use(rateLimit({ key: (req) => `login:${req.headers.get('x-forwarded-for')}:${req.headers.get('x-user-email')}`, max: 5, windowMs: 15 * 60_000 }))
3118
- ```
3119
-
3120
- | 选项 | 默认 | 说明 |
3121
- |------|------|------|
3122
- | `windowMs` | `60000` | 时间窗口 |
3123
- | `max` | `100` | 窗口内最大请求 |
3124
- | `key` | X-Forwarded-For | 限流键(生产环境配置反向代理注入) |
3125
- | `algorithm` | `fixed` | `fixed`(INCR+EXPIRE,原子)\| `sliding`(ZSET,仅 redis) |
3126
- | `store` | `redis` | `redis`(多实例一致)\| `memory`(仅单实例/开发) |
3127
-
3128
- - 响应自动带 `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` / `Retry-After`
3129
- - 多实例共享计数:计数在 redis,水平扩展天然一致
3130
-
3131
- ## email — 邮件发送
3132
-
3133
- ```ts
3134
- import { email } from 'weifuwu'
3135
-
3136
- app.use(email({ from: 'no-reply@your.app', adapter: 'resend', resend: { apiKey: process.env.RESEND_API_KEY } }))
3137
- // 或 adapter: 'smtp' + smtp: { host, port, user, pass }(自研 SMTP 客户端,零依赖)
3138
-
3139
- app.post('/api/notify', async (req, ctx) => {
3140
- await ctx.email.send({ to: 'user@x.com', subject: '通知', html: '<h1>hi</h1>' })
3141
- })
3142
- ```
3143
-
3144
- - 适配器:`resend`(默认,一个 POST)/ `smtp`(自研 node:net + node:tls:EHLO/STARTTLS/AUTH PLAIN/DATA/dot-stuffing,非 ASCII subject 自动 RFC2047 编码)/ 自定义函数
3145
- - 裁剪:附件、退信/送达率(服务商职责)、批量营销不支持
3146
-
3147
- ## userSystem — 用户系统
3148
-
3149
- ```ts
3150
- import { userSystem } from 'weifuwu'
3151
-
3152
- const db = postgres()
3153
- await db.migrate()
3154
- const users = userSystem({ sql: db.sql, secret: process.env.AUTH_SECRET })
3155
- await users.migrate() // 幂等建表(users + sessions)
3156
- app.use(db)
3157
- app.use(users) // 注入 ctx.user / ctx.auth
3158
- users.routes(app) // POST /api/auth/register|login|logout|refresh + GET /api/auth/me
3159
-
3160
- app.get('/me', (req, ctx) => ok(ctx.user)) // 已注入
3161
- app.post('/secure', (req, ctx) => { ctx.auth.requireAuth(); ... })
3162
- ```
3163
-
3164
- - **安全基线**:scrypt 密码哈希(per-user salt + timing-safe,异步不阻塞);access token = HMAC-SHA256 JWT(与 `weifuwu/client` 的 `auth()` 天然配对);refresh token = 不透明随机串,DB 只存哈希,logout/轮换即撤销
3165
- - **防枚举**:登录失败统一 401(不泄露邮箱是否存在)
3166
- - **`ctx.auth` 方法面**:`register` / `login` / `logout` / `requireAuth` / `setPassword(userId, newPwd)` / `createToken(type, payload, { ttlSeconds })`(邮箱验证/密码重置自接)
3167
- - **多租户感知**:`issueSession` 的 token payload 携带 `tenantId`(来自 `user.tenant`)——中间件自动注入 `ctx.tenantId`,并将会话字段(userId/tenantId/email/name/role)合并到 `ctx.auth`,多租户应用免写 token 解码/租户中间件(数据隔离 SQL 是应用职责)
3168
- - **`routes` 支持 `exclude`**:`users.routes(app, { exclude: ['register'] })`——应用自定义注册流程(如注册时建租户)时跳过框架路由
3169
- - **裁剪**:OAuth、邮箱验证邮件(给底层 API 自接)、多因素、RBAC 权限引擎(只留 `role` 字段)、租户隔离 SQL(框架只做感知,`WHERE tenant_id` 属应用层)
3170
-
3171
- ## messager — 消息系统
3172
-
3173
- ```ts
3174
- import { messager } from 'weifuwu'
3175
-
3176
- const db = postgres()
3177
- await db.migrate()
3178
- const msg = messager({ sql: db.sql, redis: rds }) // redis 可选:跨进程广播
3179
- await msg.migrate() // 幂等建表(conversations + members + messages)
3180
- app.use(db)
3181
- app.use(msg) // 注入 ctx.msg
3182
- msg.routes(app) // /api/messages/*(会话/历史/发消息/已读)
3183
- app.ws('/ws', msg.handler()) // 标准 WS 协议内置
3184
-
3185
- // 业务代码:持久化 + 鉴权 + 广播 + 未读 + 历史,一次调用
3186
- const conv = await ctx.msg.createConversation(ctx.user.id, { type: 'group', memberIds: ['u2'] })
3187
- await ctx.msg.sendMessage(conv.id, { senderType: 'user', senderId: ctx.user.id, content: '你好' })
3188
- ctx.msg.broadcast(`conv:${conv.id}`, { type: 'order_chat', orderId: 'o1' }) // 任意实时事件
3189
- ctx.msg.sendTo('u2', { type: 'mention' }) // 用户维度点对点
3190
- ```
3191
-
3192
- - **数据模型**:`_weifuwu_conversations` / `_weifuwu_conversation_members` / `_weifuwu_messages`(`sender_type + sender_id` 不 FK users——user/agent/system 消息天然可存);direct 会话同对用户唯一、历史游标分页、未读数(`last_read_at`)、编辑/删除软删
3193
- - **实时协议内置**:`handler()` 提供 `connected / subscribe→subscribed / unsubscribe / ping→pong`——前端 `ctx.ws.send({ type: 'subscribe', room })` 直接可用,两端协议由框架定义
3194
- - **跨进程**:`redis` 选项 → Redis pub/sub 广播(psubscribe 模式),多实例部署天然一致;无 redis 优雅降级单进程。
3195
- **环回去重**:`broadcast` = 本地直发 + Redis publish,本实例的 subscriber 会收到自己 publish 的消息——publish 携带实例唯一标识 `_pid`(`wf:{pid}:{seq}`),订阅回调跳过自己的环回,保证每个事件恰好投递一次(防 token 级事件重复/乱序)
3196
- - **与 userSystem 咬合**:`sendTo(ctx.user.id)` 按身份路由、`createConversation(ctx.user.id)` 创建者即身份、成员校验自动对齐——身份是消息的路由,消息是身份的交互
3197
- - **裁剪**:已读回执状态机(只做未读数)、附件存储、全文搜索、消息确认/重试(可靠投递用 queue)、移动端推送
3198
-
3199
- ## queue — 可靠任务队列
3200
-
3201
- ```ts
3202
- import { queue } from 'weifuwu'
3203
-
3204
- const q = queue() // 默认 REDIS_URL
3205
- app.use(q) // 注入 ctx.queue
3206
-
3207
- app.post('/api/generate', async (req, ctx) => {
3208
- await ctx.queue.add('llm.batch', { prompt: '...' }, { attempts: 3 })
3209
- return new Response(null, { status: 202 }) // 立即 202,任务后台执行
3210
- })
3211
-
3212
- // 消费者(独立进程或同进程均可,多开安全)
3213
- const worker = q.worker('llm.batch', async (job) => {
3214
- await runLLM(job.data) // 失败自动重试 → 用尽进 DLQ(q:llm.batch:dead)
3215
- }, { concurrency: 5, visibilityTimeout: 30_000 })
3216
- await worker.start()
3217
- await worker.stop() // 优雅停止
3218
- ```
3219
-
3220
- - **语义**:at-least-once(handler 可能重复执行——幂等由业务保证);Redis Streams 消费组,多 worker 实例不重复消费
3221
- - **可靠性**:失败 → 延迟重试(间隔 = `visibilityTimeout`,ZSET 延迟队列)→ attempts 用尽 → DLQ;worker 崩溃 → pending 由其他实例 `XAUTOCLAIM` 接管
3222
- - 裁剪:延迟调度(除重试外)、cron、优先级、指数退避、速率限制不支持
3223
-
3224
- ## ai — LLM 对话(自研协议 + 零依赖客户端)
3225
-
3226
- ```ts
3227
- import { ai } from 'weifuwu'
3228
-
3229
- const a = ai() // DEEPSEEK_API_KEY / BASE_URL / MODEL 自动读 env,默认 deepseek-v4-flash
3230
- app.use(a) // 注入 ctx.ai(worker/非请求场景也可直接 a.chat())
3231
-
3232
- // 流式对话:路由一行返回 SSE(wf: 协议,详见 docs/ai-contract.md)
3233
- app.post('/api/chat', async (req, ctx) => {
3234
- const { messages } = await req.json()
3235
- return ctx.ai.stream({ messages }, {
3236
- signal: req.signal, // 断开即取消 provider 请求
3237
- traceId: req.headers.get('x-trace-id') ?? undefined, // 追踪关联(协议 §7)
3238
- })
3239
- })
3240
-
3241
- // 非流式(worker/后台):
3242
- const res = await a.chat({ messages: [{ role: 'user', content: 'hi' }] })
3243
-
3244
- // agent 引擎:工具循环 + 人工审批(HITL)
3245
- const agent = a.agent({
3246
- systemPrompt: '你是助手。查询天气时调用 query_weather 工具。',
3247
- tools: [{
3248
- name: 'query_weather',
3249
- description: '查询城市天气',
3250
- parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
3251
- run: async (args, { emit }) => {
3252
- emit('wf:tool_progress', { toolCallId: 'x', step: 1, total: 2, message: '查询中…', status: 'running' })
3253
- return { city: args.city, temp: 25 }
3254
- },
3255
- }],
3256
- humanInTheLoop: true, // 每个工具执行前等人工审批
3257
- })
3258
-
3259
- app.post('/api/agent', async (req, ctx) => {
3260
- const { messages } = await req.json()
3261
- return agent.run(messages, { signal: req.signal, traceId: req.headers.get('x-trace-id') ?? undefined })
3262
- })
3263
-
3264
- // HITL 审批响应(前端点"允许/拒绝" → POST 到这里)
3265
- app.post('/api/approve', async (req, ctx) => {
3266
- ctx.ai.approve(await req.json()) // { id, decision, modifiedArgs?, note? }
3267
- return new Response(null, { status: 200 })
3268
- })
3269
- ```
3270
-
3271
- 前端解码(`weifuwu/client`):
3272
-
3273
- ```ts
3274
- import { aiStream } from 'weifuwu/client'
3275
-
3276
- const handle = aiStream('/api/chat', { messages }, {
3277
- onToken: (text) => { /* 增量 append 到消息 */ },
3278
- onToolCall: (call) => { /* 渲染工具卡片 */ },
3279
- onDone: () => { /* 收尾 */ },
3280
- onError: (e) => { /* 按 e.code 降级 */ },
3281
- onEvent: (name, data) => { /* x:* 自定义事件透传 */ },
3282
- })
3283
- handle.abort() // 用户停止/组件卸载/导航跳走
3284
- ```
3285
-
3286
- 前端对话层(会话语义 + 标准界面,协议对页面透明):
3287
-
3288
- ```tsx
3289
- // ctx.ui.useChat:会话语义(消息累积/工具内嵌/审批/重试),返回页面同一个 $
3290
- const $ = ctx.ui.useChat({ url: '/api/chat', approveUrl: '/api/approve' })
3291
- // $.messages / $.input / $.streaming / $.error / $.usage / $.step
3292
- // $.send() / $.stop() / $.retry() / $.clear() / $.approve('approved', note?)
3293
-
3294
- // AiChat:标准对话界面(气泡 / 工具卡 / 审批卡 / 自动滚动 / 错误重试)
3295
- return () => <AiChat chat={$} />
3296
-
3297
- // agent 模式消息内嵌:msg.toolCalls(ToolCallCard 直接消费)/ msg.approval(ApprovalCard)
3298
- ```
3299
-
3300
- > 分层:`ctx.ai`(后端协议)→ `aiStream`(传输解码)→ `useChat`(会话语义)→ `AiChat`(标准界面)。要完全自定义 UI 的应用用 useChat + 自有渲染;要 5 分钟出界面用 AiChat。
3301
-
3302
- - **协议**:`wf:` 命名空间(message_start/token/tool_call/tool_progress/usage/done/error + agent 扩展 step/approval_request),SSE 下行 + POST 上行,错误即值、未知事件透传、`x:*` 自定义事件(详见 [docs/ai-contract.md](./docs/ai-contract.md))
3303
- - **agent 引擎**:`a.agent({ systemPrompt, tools, humanInTheLoop })` 工具循环(LLM → tool_call → 执行 → 回喂 → 重复);工具可 `emit` 进度/自定义事件、接收 `signal` 取消;HITL 审批(`ctx.ai.approve` 响应,拒绝≠终止、modified 改参、超时兜底)
3304
- - **emitter 抽象**:`agent.stream(messages, { emit })`——`wf:*` 事件(step/token/tool_result/usage/done)可接任意通道(SSE/WS/回调),协议不焊死在传输层;`agent.runToResult(messages)` 返回结构化结果 `{ content, steps, usage }`(非流式/worker 场景)
3305
- - **embedding**:`ctx.ai.embed(text)` / `embedMany(texts)` 向量化(默认 `DASHSCOPE_API_KEY` + `text-embedding-v4`,compatible-mode 端点);未配置抛 `AiError('unsupported')`(惰性检查,不静默降级)——知识库/语义检索开箱即用
3306
- - **零依赖**:自研 OpenAI 兼容客户端(fetch + SSE 解析),默认 DeepSeek,`baseUrl` 可换任意 OpenAI 兼容端点(Ollama/vLLM/Moonshot…)
3307
- - **追踪**:前端自动生成 `X-Trace-Id` → 后端以之作为 `message_start.id` → 工具内请求继承同一 traceId,整个 agent run 一次搜完
3308
- - **裁剪**:Anthropic 原生协议、审批持久化(连接断=会话亡)暂不支持;多 agent 编排不承诺(子 agent = 工具已支持);embedding 仅文本(图片/多模态不做)
3309
-
3310
- ## 组合示例:注册 → 验证邮件 → 欢迎任务 → 登录防爆破
3311
-
3312
- ```ts
3313
- app.use(redis())
3314
- app.use(rateLimit({ key: (req) => `login:${req.headers.get('x-forwarded-for')}`, max: 5, windowMs: 60_000 })) // 防爆破
3315
- app.use(email({ from: 'no-reply@x.com', adapter: 'resend', resend: { apiKey } }))
3316
- app.use(db)
3317
- app.use(users)
3318
- users.routes(app)
3319
-
3320
- // 注册:限流守卫 → 用户系统 → 验证邮件 → 欢迎任务入队
3321
- app.post('/api/auth/register', async (req, ctx) => {
3322
- await ctx.limit(`register:${req.ip}`, { max: 10, windowMs: 60_000 })
3323
- const result = await ctx.auth.register(await req.json())
3324
- const token = ctx.auth.createToken('verify', { sub: result.user.id }, { ttlSeconds: 86400 })
3325
- await ctx.email.send({ to: result.user.email, subject: '验证邮箱', html: `...?token=${token}` })
3326
- await ctx.queue.add('welcome.flow', { userId: result.user.id })
3327
- return created(result)
3328
- })
3329
-
3330
- const worker = q.worker('welcome.flow', async (job) => { /* 欢迎流程 */ })
3331
- await worker.start()
3332
- ```
500
+ > `docs/` 用户文档随 npm 包发布(`files: ['dist/', 'README.md', 'docs/']`)——`node_modules/weifuwu/docs` 可离线查阅;`design/` 设计/计划文档仅仓库内。