weifuwu 0.40.0 → 0.50.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
@@ -10,30 +10,17 @@ npm install weifuwu
10
10
 
11
11
  ---
12
12
 
13
- ## 模块总览
13
+ ## 设计理念
14
14
 
15
- | 导入路径 | 模块 | 用途 | 依赖 |
16
- |---------|------|------|------|
17
- | `weifuwu` | **Router** | Trie 路由 + 中间件链 + WebSocket + GraphQL | — |
18
- | `weifuwu` | **serve** | HTTP 服务器 | Router |
19
- | `weifuwu` | **cors** | CORS 跨域中间件 | Router |
20
- | `weifuwu` | **serveStatic** | 静态文件服务(ETag/304/目录索引) | Router |
21
- | `weifuwu` | **postgres** | PostgreSQL 连接池 `ctx.sql` | Router, DATABASE_URL |
22
- | `weifuwu` | **redis** | Redis 客户端 → `ctx.redis` | Router, REDIS_URL |
23
- | `weifuwu` | **ui** | SSR 渲染 + esbuild JS/CSS 动态编译 `ctx.ui` | Router |
24
- | `weifuwu` | **graphql** | GraphQL 端点(支持 GraphiQL) | Router |
25
- | `weifuwu` | **createMiddleware** | 类型安全中间件工厂 | — |
26
- | `weifuwu` | **response** | HTTP 响应辅助函数(ok/badRequest/...) | — |
27
- | `weifuwu` | **parseBody** | JSON 请求体安全解析 | — |
28
- | `weifuwu/client` | **createApp** | 应用引导 + VDOM 渲染引擎 | — |
29
- | `weifuwu/client` | **router / RouteView** | 前端路由(history/hash 模式) | createApp |
30
- | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
31
- | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
32
- | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
33
- | `weifuwu/client` | **confirm** | Promise 化确认对话框 | createApp |
34
- | `weifuwu/client** | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
35
- | `weifuwu/components` | **38 个组件** | Button/Table/Modal/Toast/... | weifuwu/client |
36
- | `weifuwu/layout` | **CSS 布局** | 35 个布局原语 + 72 个主题 Token | — |
15
+ **零运行时依赖** 前端无 npm 运行时依赖,不引入 Virtual DOM 库、rxjs、immer 等重型依赖。esbuild 编译 TSX 的结果即可直接运行。
16
+
17
+ **两阶段组件模型** 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook。
18
+
19
+ **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发 VDOM patch。无需手动调用 `useState`/`useEffect`。
20
+
21
+ **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` 等),Handler/组件从 `ctx` 读取。
22
+
23
+ **SSR + 动态编译** 后端 `ctx.ui.js()` esbuild 实时编译 TSX,开发时改代码即刷即用,零构建步骤。
37
24
 
38
25
  ---
39
26
 
@@ -68,8 +55,9 @@ serve(app, { port: 3000 })
68
55
  ```tsx
69
56
  // src/main.tsx
70
57
  import { createApp, router, RouteView } from 'weifuwu/client'
58
+ import type { Component } from 'weifuwu/client'
71
59
 
72
- function Home() { return <h1>Hello weifuwu</h1> }
60
+ const Home: Component = () => () => <h1>Hello weifuwu</h1>
73
61
 
74
62
  createApp()
75
63
  .use(router({ routes: [{ path: '/', component: Home }] }))
@@ -78,8 +66,68 @@ createApp()
78
66
 
79
67
  ---
80
68
 
69
+ ## 模块总览
70
+
71
+ | 导入路径 | 模块 | 用途 | 依赖 |
72
+ |---------|------|------|------|
73
+ | `weifuwu` | **Router** | Trie 路由 + 中间件链 + WebSocket + GraphQL | — |
74
+ | `weifuwu` | **serve** | HTTP 服务器 | Router |
75
+ | `weifuwu` | **cors** | CORS 跨域中间件 | Router |
76
+ | `weifuwu` | **serveStatic** | 静态文件服务(ETag/304/目录索引) | Router |
77
+ | `weifuwu` | **postgres** | PostgreSQL 连接池 → `ctx.sql` | Router, DATABASE_URL |
78
+ | `weifuwu` | **redis** | Redis 客户端 → `ctx.redis` | Router, REDIS_URL |
79
+ | `weifuwu` | **ui** | SSR 渲染 + esbuild JS/CSS 动态编译 → `ctx.ui` | Router |
80
+ | `weifuwu` | **graphql** | GraphQL 端点(支持 GraphiQL) | Router |
81
+ | `weifuwu` | **createMiddleware** | 类型安全中间件工厂 | — |
82
+ | `weifuwu` | **response** | HTTP 响应辅助函数(ok/badRequest/...) | — |
83
+ | `weifuwu` | **parseBody** | JSON 请求体安全解析 | — |
84
+ | `weifuwu/client` | **createApp** | 应用引导 + VDOM 渲染引擎 | — |
85
+ | `weifuwu/client` | **router / RouteView** | 前端路由(history/hash 模式) | createApp |
86
+ | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
87
+ | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
88
+ | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
89
+ | `weifuwu/client` | **confirm** | Promise 化确认对话框 | createApp |
90
+ | `weifuwu/client` | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
91
+ | `weifuwu/components` | **41 个组件** | Button/Table/Modal/Toast/... | weifuwu/client |
92
+ | `weifuwu/layout` | **CSS 布局** | 35 个布局原语 + 72 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
93
+
94
+ ---
95
+
96
+ ## 核心概念
97
+
98
+ ### 中间件模式(前后端一致)
99
+
100
+ ```
101
+ 后端: app.use(cors())
102
+ app.use(postgres())
103
+ app.get('/users', (req, ctx) => { ctx.sql`SELECT *` })
104
+ // ctx 已注入 ctx.sql
105
+
106
+ 前端: createApp()
107
+ .use(api({ baseURL: '/api' }))
108
+ .use(auth())
109
+ .mount('#root', App)
110
+ // ctx 已注入 ctx.api, ctx.auth
111
+ ```
112
+
113
+ ### 状态管理
114
+
115
+ | 模式 | 后端 | 前端 |
116
+ |------|------|------|
117
+ | 注入 | 中间件注入 ctx.field | 中间件注入 ctx.field |
118
+ | 读取 | handler 读取 ctx | 组件读取 ctx |
119
+ | 渲染 | 返回 Response | `ctx.ui.render()` / `ctx.ui.dirty()` 触发 VDOM patch |
120
+
121
+ ### Closeable 接口
122
+
123
+ 所有有状态模块(postgres、redis)实现 `close(): Promise<void>`,serve 关闭时自动调用。
124
+
125
+ ---
126
+
81
127
  # 后端 API (`weifuwu`)
82
128
 
129
+ > 以下为完整 API 参考,按需查阅。新手建议先阅读上文的「核心概念」和「快速开始」。
130
+
83
131
  ## Router
84
132
 
85
133
  Trie 路由,支持 URL 参数、通配符、中间件链、WebSocket、GraphQL。
@@ -417,7 +465,7 @@ app.use(ui())
417
465
  | `ctx.ui.html` | `` (strings, ...values) => Response `` | HTML 模板 (转义防 XSS) |
418
466
  | `ctx.ui.html.unsafe(str)` | `(string) => string` | 插入原始 HTML |
419
467
  | `ctx.ui.js(entryPath)` | `(string) => Promise<Response>` | esbuild 编译 TSX → JS bundle |
420
- | `ctx.ui.css(entryPath)` | `(string) => Promise<Response>` | 读取/编译 CSS(自动 PostCSS + Tailwind) |
468
+ | `ctx.ui.css(entryPath)` | `(string) => Promise<Response>` | 读取 CSS 文件 → CSS Response(如安装 postcss + @tailwindcss/postcss 则自动编译) |
421
469
 
422
470
  ### ctx.ui.html — HTML 模板
423
471
 
@@ -433,7 +481,8 @@ app.get('/page', (req, ctx) => ctx.ui.html`
433
481
  ### ctx.ui.js — 编译 TSX → JS
434
482
 
435
483
  ```ts
436
- app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx'))
484
+ app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx')) // 相对路径
485
+ app.get('/app.js', (req, ctx) => ctx.ui.js('weifuwu/client')) // 或包名
437
486
  ```
438
487
 
439
488
  使用 esbuild 编译:
@@ -444,12 +493,14 @@ app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx'))
444
493
  ### ctx.ui.css — CSS 编译
445
494
 
446
495
  ```ts
447
- app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css'))
496
+ app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css')) // 相对路径
497
+ app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css')) // 或包名
448
498
  ```
449
499
 
450
- - CSS 文件直接返回
451
- - 检测到 `postcss` + `@tailwindcss/postcss` 时自动编译 Tailwind CSS
452
- - mtime 缓存验证
500
+ - 无编译工具时直接返回原始 CSS
501
+ - 检测到已安装 `postcss` + `@tailwindcss/postcss` 时自动编译 Tailwind CSS
502
+ - 支持包名(`weifuwu/layout/style.css`, `weifuwu/components/style.css`)或文件路径
503
+ - 带 mtime 缓存验证(开发时编辑文件后自动失效)
453
504
 
454
505
  ---
455
506
 
@@ -659,7 +710,9 @@ import type { GraphQLOptions, GraphQLHandler } from 'weifuwu'
659
710
 
660
711
  # 前端 API (`weifuwu/client`)
661
712
 
662
- 零外部 npm 运行时依赖。组件模型:纯函数 `(props, ctx) => VNode`。
713
+ > 以下为完整 API 参考,按需查阅。新手建议先阅读上文的「组件模型」和「状态管理」。
714
+
715
+ 零外部 npm 运行时依赖。组件签名:`(initProps, ctx) => (props) => VNode`(两阶段模型,外层 mount 只一次,内层 render 每次变化时执行)。无状态组件可简写为 `() => () => VNode`。
663
716
 
664
717
  构建配置(esbuild):
665
718
 
@@ -714,25 +767,25 @@ const Counter: Component = (_init, ctx) => {
714
767
  // ── mount ──
715
768
  let count = 0
716
769
 
717
- ctx.ui.onmounted((el) => {
718
- console.log('DOM 已创建', el)
719
- })
720
-
721
770
  // ── render ──
722
771
  return (props) =>
723
772
  h('button', { onClick: () => { count++; ctx.ui.render() } }, count)
724
773
  }
774
+
775
+ // 无状态组件:只有 render
776
+ const Badge: Component = () =>
777
+ (props) => h('span', { class: `badge-${props.variant}` }, props.children)
725
778
  ```
726
779
 
727
780
  | 规则 | 说明 |
728
781
  |------|------|
729
782
  | 组件签名 | `(initProps: P, ctx: WfuiContext) => (props: P) => VNode \| null` |
730
- | mount 阶段 | 外层函数只执行一次,初始化状态/注册生命周期 |
783
+ | mount 阶段 | 外层函数只执行一次,初始化状态 |
731
784
  | render 阶段 | 内层函数每次 dirty/props 变化时执行,返回 VNode |
732
785
  | 无 class | 无 `this`,无实例方法 |
733
786
  | 无 hook | 无 `useState` / `useEffect` / `useMemo` |
734
787
  | 状态 | 闭包变量 + `ctx.ui.render()` 手动触发,或 `ctx.ui.$()` 响应式容器 |
735
- | 生命周期 | `ctx.ui.onmount / onmounted / onunmount / onupdate` |
788
+ | ref 引用 | `ref={el => { if (el) init; else cleanup }}` 获取 DOM |
736
789
 
737
790
  ### JSX 工厂
738
791
 
@@ -757,7 +810,15 @@ h('div', { class: 'x' }, child1, child2)
757
810
 
758
811
  ## 状态管理
759
812
 
760
- ### 闭包变量 + `ctx.ui.render()`(推荐)
813
+ ### Render 机制总览
814
+
815
+ | API | 触发时机 | 渲染方式 | 使用场景 |
816
+ |------|---------|---------|---------|
817
+ | `$.x = val` | 赋值后自动 | 微任务批量(异步) | **日常 UI 状态** — 表单输入、切换开关、异步数据加载等绝大多数场景 |
818
+ | `ctx.ui.dirty()` | 主动调用 | 微任务批量(异步) | **绕过 Proxy 后手动标记** — 批量修改深层次对象、第三方库直接修改了 `$` 内部数据 |
819
+ | `ctx.ui.render()` | 主动调用 | 立即同步 | **需要立即拿到最新 DOM** — DOM 测量、动画触发、第三方库在事件中同步读取 DOM |
820
+
821
+ ### 闭包变量 + `ctx.ui.render()`(简单场景)
761
822
 
762
823
  ```tsx
763
824
  const Counter: Component = (_init, ctx) => {
@@ -767,26 +828,140 @@ const Counter: Component = (_init, ctx) => {
767
828
  }
768
829
  ```
769
830
 
770
- ### `ctx.ui.$()` 响应式状态容器
831
+ 适合状态极少的简单组件。每次修改后手动调用 `ctx.ui.render()` 同步刷新 DOM。
832
+
833
+ ### `ctx.ui.$()` — 响应式 Proxy(推荐首选)
771
834
 
772
- `ctx.ui.$()` 返回浅 Proxy,赋值自动触发渲染(微任务批量合并):
835
+ `ctx.ui.$()` 返回**深度 Proxy** 容器。任意层级赋值操作自动触发渲染(微任务批量合并):
773
836
 
774
837
  ```tsx
775
838
  const FormPage: Component = (_init, ctx) => {
776
839
  const $ = ctx.ui.$()
777
840
  $.email = ''
841
+ $.loading = false
778
842
  return (props) =>
779
- h('input', { value: $.email, onInput: (e: any) => { $.email = e.target.value } })
843
+ h('input', {
844
+ value: $.email,
845
+ onInput: (e: any) => { $.email = e.target.value }
846
+ })
780
847
  }
781
848
  ```
782
849
 
783
- **注意**:mount/render/生命周期回调中 `$.x = val` 不触发渲染。仅事件/timer/Promise.then 中生效。
850
+ **深度 Proxy 拦截**:
851
+ - `$.x = val` → 自动排队重渲染
852
+ - `$.obj.a = 1` → 自动 dirty(嵌套对象递归包装)
853
+ - `$.arr.push(val)` / `$.arr[0].x = y` → 自动 dirty(数组变异 + 嵌套属性拦截)
854
+ - `delete $.x` → 自动 dirty
855
+ - 每个组件实例独立 Proxy,同名变量不冲突
784
856
 
785
- | API | 说明 |
786
- |-----|------|
787
- | `ctx.ui.$()` | 创建响应式状态容器 |
788
- | `ctx.ui.render()` | 立即同步渲染 |
789
- | `ctx.ui.dirty()` | 标记脏状态,下个微任务批量渲染 |
857
+ **注意**:mount/render `$.x = val` **不触发渲染**,仅事件/timer/Promise.then 中生效。这是有意设计——初始化和 mount 阶段设置状态不应触发额外渲染。
858
+
859
+ **何时用 `$`**:所有需要触发 UI 重新渲染的状态。90% 以上的场景用 `$` 就够。
860
+
861
+ **何时不用**:
862
+ - 不需要触发渲染的内部缓存(用闭包变量 `let`)
863
+ - 简单组件只有一两个状态变量(闭包变量 + `render()` 更轻量)
864
+
865
+ ### `ctx.ui.dirty()` — 手动标记脏状态
866
+
867
+ 当你绕过 Proxy 直接操作底层数据后,调用 `dirty()` 通知框架在下个微任务批量重渲染:
868
+
869
+ ```tsx
870
+ // 实际场景:在 mount 阶段需要手动触发渲染
871
+ // mount 期间 $.x = val 自动静默(不触发渲染)
872
+ $.initialized = true
873
+ // 如果非要在这里触发渲染,需要手动调用 dirty():
874
+ ctx.ui.dirty()
875
+ ```
876
+
877
+ **但实际上,绝大多数情况下你不需要 `dirty()`。** 深度 Proxy 已经拦截了所有常见的变更新为方式(深层属性赋值、数组 push/splice、delete 等)。先赋值给 `$` 永远是更清晰的做法。
878
+
879
+ ### `ctx.ui.render()` — 同步强制渲染
880
+
881
+ 与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。
882
+
883
+ **何时必须用 `render()`**:
884
+
885
+ ```tsx
886
+ // 1. DOM 测量(读取 offsetHeight/scrollWidth 等)
887
+ // 用 ref 在 DOM 创建后操作
888
+ ref: (el) => {
889
+ if (!el) return
890
+ el.style.height = 'auto'
891
+ ctx.ui.render()
892
+ const h = el.offsetHeight
893
+ el.style.height = h + 'px'
894
+ }
895
+
896
+ // 2. 动画触发(需要确保上一帧 DOM 已提交)
897
+ function startAnimation() {
898
+ $.animating = true
899
+ ctx.ui.render() // 同步刷新 DOM
900
+ el.startViewTransition(...) // 拿到最新 DOM 启动动画
901
+ }
902
+
903
+ // 3. 第三方库需要在事件回调中读取最新 DOM
904
+ onClick: () => {
905
+ $.selected = !$.selected
906
+ ctx.ui.render() // 确保 DOM 已更新
907
+ thirdPartyLib.measure(el) // 读取最新状态
908
+ }
909
+ ```
910
+
911
+ **规则**:能用 `$` 就用 `$`。只有当你**必须同步拿到最新 DOM 状态**时才用 `render()`。
912
+
913
+ ### 三种方式速查
914
+
915
+ ```tsx
916
+ // ✅ 推荐:ctx.ui.$() + $.x = val — 自动、批量、无脑
917
+ const $ = ctx.ui.$()
918
+ $.count++
919
+ $.name = 'hello' // 微任务合并,只渲染一次
920
+
921
+ // ✅ 简单场景:闭包变量 + ctx.ui.render() — 轻量同步
922
+ let count = 0
923
+ count++
924
+ ctx.ui.render() // DOM 立刻更新
925
+
926
+ // ⚠️ 罕见:ctx.ui.dirty() — 绕过 Proxy 后手动标记
927
+ ```
928
+
929
+ **性能说明**:
930
+ - `$.x = val` 和 `dirty()` 都是微任务批量合并:同一 tick 内 N 次赋值 → 1 次渲染
931
+ - `render()` 每次调用都触发一次完整 diff/patch,频繁调用可能影响性能
932
+
933
+ ### 实践建议:日常开发 vs 组件分享
934
+
935
+ **日常组件内**:优先用 `$.x = val`,无脑、自动、批量。
936
+
937
+ **制作可分享组件**(组件库、npm 包、跨项目复用)时,推荐用 `ctx.ui.dirty()` 或 `ctx.ui.render()` 精确控制刷新时机:
938
+
939
+ ```tsx
940
+ // 可分享的 Toast 组件:主动控制渲染,避免消费方上下文干扰
941
+ const Toast = (_init, ctx) => {
942
+ let items: ToastItem[] = []
943
+
944
+ return {
945
+ add(item: ToastItem) {
946
+ items = [...items, item]
947
+ ctx.ui.render() // 显式同步渲染,确保 DOM 立即可见
948
+ },
949
+ remove(id: string) {
950
+ items = items.filter(i => i.id !== id)
951
+ ctx.ui.dirty() // 显式标记脏,下个微任务批量渲染
952
+ },
953
+ render: (props) =>
954
+ h('div', { class: 'toast-container' },
955
+ items.map(item => h('div', { key: item.id }, item.msg))
956
+ ),
957
+ }
958
+ }
959
+ ```
960
+
961
+ 理由:
962
+ - 分享出去的组件可能被用在各种上下文,`$` 的隐式自动刷新可能不可控
963
+ - 暴露 `add/remove` 等命令式 API 时,`render()` / `dirty()` 让刷新时机**显式、可预测**
964
+ - 消费方不需要知道组件内部用 `$` 还是闭包,只需调用 API
790
965
 
791
966
  ---
792
967
 
@@ -807,43 +982,41 @@ const FormPage: Component = (_init, ctx) => {
807
982
 
808
983
  ---
809
984
 
810
- ## 生命周期
985
+ ## ref 管理 DOM
811
986
 
812
- 框架不提供 `ref` prop。使用 `ctx.ui.onmount / onmounted / onunmount / onupdate`:
813
-
814
- | API | 触发时机 | 参数 | 返回值 |
815
- |-----|---------|------|--------|
816
- | `ctx.ui.onmount(fn)` | render 前(DOM 未创建) | `() => void` | — |
817
- | `ctx.ui.onmounted(fn)` | 首次 render 后(DOM 已创建) | `(el: Element) => cleanup` | `() => void`(可选 cleanup)|
818
- | `ctx.ui.onunmount(fn)` | 组件移除前 | `() => void` | — |
819
- | `ctx.ui.onupdate(fn)` | props 变化时 | `(prevProps) => void` | — |
987
+ 使用 `ref` prop 获取元素引用,适合管理第三方库或读取 DOM:
820
988
 
821
989
  ```tsx
822
990
  const Timer: Component = (_init, ctx) => {
823
991
  let timer: ReturnType<typeof setInterval> | undefined
824
992
 
825
- ctx.ui.onmounted((el) => {
826
- timer = setInterval(() => console.log('tick'), 1000)
827
- return () => clearInterval(timer)
828
- })
829
-
830
- return (props) => h('div', {}, 'Timer')
993
+ return (props) =>
994
+ h('div', {
995
+ ref: (el) => {
996
+ if (el) {
997
+ timer = setInterval(() => console.log('tick'), 1000)
998
+ } else {
999
+ clearInterval(timer)
1000
+ }
1001
+ },
1002
+ }, 'Timer')
831
1003
  }
832
1004
  ```
833
1005
 
834
- **注意**:`onmounted` 在首次渲染后触发,此时 DOM 已创建。生命周期回调中 `$.x = val` 不触发渲染(仅事件/timer 中生效)。
1006
+ `ref` 在元素创建时调用 `ref(el)`,元素移除时调用 `ref(null)`。
1007
+ `ref` 不接受返回值,清理逻辑直接在 `else` 分支处理。
835
1008
 
836
- 对于**内嵌元素**(非根元素),用 `el.querySelector()`:
1009
+ 对于**内嵌元素**(非根元素),直接在目标元素上放 `ref`:
837
1010
 
838
1011
  ```tsx
839
- ctx.ui.onmounted((el) => {
840
- const input = el.querySelector('input[type="text"]') as HTMLElement
841
- input?.focus()
842
- })
1012
+ return h('div', {},
1013
+ h('input', {
1014
+ type: 'text',
1015
+ ref: (el) => el?.focus(),
1016
+ })
1017
+ )
843
1018
  ```
844
1019
 
845
- 所有钩子遵循替换模式:多次注册只保留最后一次。
846
-
847
1020
  ### 异步组件
848
1021
 
849
1022
  在 mount 阶段发起请求,数据通过 `$.x = val` 自动触发渲染:
@@ -1276,13 +1449,132 @@ import type { RouterOptions } from 'weifuwu/client'
1276
1449
 
1277
1450
  # 组件库 (`weifuwu/components`)
1278
1451
 
1279
- 38 个 HTML 原语组件。每个是 `(props, ctx) => VNode` 纯函数,引用 `--wf-*` CSS 变量做主题。
1452
+ 41 个 HTML 原语组件。每个是 `(props, ctx) => VNode` 纯函数,引用 `--wf-*` CSS 变量做主题。
1280
1453
 
1281
1454
  ```ts
1282
1455
  import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
1283
- import 'weifuwu/components/style.css'
1456
+ import 'weifuwu/components/style.css' // 包含 Token + 35 布局原语 + 组件样式,一次性引入
1457
+ ```
1458
+
1459
+ ### 使用示例
1460
+
1461
+ ```tsx
1462
+ // ├─ 按钮
1463
+ <Button variant="primary" onClick={() => alert('提交')}>提交</Button>
1464
+ <Button variant="ghost" loading>加载中</Button>
1465
+ <Button variant="danger" size="lg" block>删除</Button>
1466
+
1467
+ // ├─ 输入框
1468
+ <Input placeholder="请输入邮箱" />
1469
+ <Input label="用户名" required error="必填" />
1470
+ <Input type="password" hint="至少6位" prefix="🔒" />
1471
+
1472
+ // ├─ 选择器
1473
+ <Select options={[{ value: 'a', label: '选项A' }]} placeholder="请选择" />
1474
+ <Select searchable options={options} onChange={v => setVal(v)} />
1475
+
1476
+ // ├─ 复选框 / 开关 / 单选
1477
+ <Checkbox checked={agree} onChange={setAgree} label="同意协议" />
1478
+ <Switch checked={enabled} onChange={setEnabled} />
1479
+ <RadioGroup options={[{ value: '1', label: '男' }, { value: '2', label: '女' }]} value={gender} />
1480
+
1481
+ // ├─ 表格
1482
+ <Table columns={[{ key: 'id', label: 'ID', sortable: true }, { key: 'name', label: '名称' }]}
1483
+ data={rows} sortKey="id" sortOrder="asc" onSort={(k, o) => setSort(k, o)} />
1484
+
1485
+ // ├─ 模态框 / 抽屉
1486
+ <Modal open={show} title="提示" onClose={() => setShow(false)} width="500px" closable>
1487
+ <p>确认删除?</p>
1488
+ </Modal>
1489
+ <Drawer open={open} title="详情" onClose={() => setOpen(false)} position="right">内容</Drawer>
1490
+
1491
+ // ├─ 消息提示
1492
+ <Toast toasts={items} position="top-right" max={5} onRemove={id => remove(id)} />
1493
+ <Alert variant="warning" closable>注意:磁盘空间不足</Alert>
1494
+
1495
+ // ├─ 标签 / 徽标 / 头像
1496
+ <Badge count={5}>消息</Badge>
1497
+ <Badge variant="success">通过</Badge>
1498
+ <Tag variant="blue" closable onClose={() => {}}>标签</Tag>
1499
+ <Avatar name="张三" size="lg" />
1500
+
1501
+ // ├─ 卡片 / 统计卡片
1502
+ <Card title="卡片标题" extra={<a href="#">更多</a>}>卡片内容</Card>
1503
+ <StatCard title="总用户" value="1,234" trend={12.5} variant="primary" />
1504
+
1505
+ // ├─ 标签页 / 下拉菜单
1506
+ <Tabs items={[{ key: 'a', label: '标签A' }, { key: 'b', label: '标签B' }]} activeKey="a" onChange={setTab} />
1507
+ <Dropdown items={[{ label: '编辑', onClick: () => {} }, { label: '删除', danger: true }]}>操作</Dropdown>
1508
+
1509
+ // ├─ 分页 / 步骤条
1510
+ <Pagination total={100} page={1} pageSize={10} onChange={setPage} />
1511
+ <Steps items={[{ title: '第一步' }, { title: '第二步' }]} current={1} />
1512
+
1513
+ // ├─ 滑块 / 进度条
1514
+ <Slider min={0} max={100} value={50} onChange={setValue} />
1515
+ <ProgressBar value={75} variant="success" label="75%" />
1516
+
1517
+ // ├─ 面包屑 / 分割线
1518
+ <Breadcrumb items={[{ label: '首页' }, { label: '用户管理' }]} />
1519
+ <Divider />
1520
+ <Divider orientation="left">分割文字</Divider>
1521
+
1522
+ // ├─ 加载 / 空状态 / 骨架屏
1523
+ <Loading text="加载中..." />
1524
+ <EmptyState title="暂无数据" description="请先创建一条记录" action={<Button>新建</Button>} />
1525
+ <Skeleton variant="text" lines={3} />
1526
+ <Skeleton variant="table" lines={5} cols={4} />
1527
+ <Skeleton variant="avatar" />
1528
+ <Skeleton variant="image" />
1529
+
1530
+ // ├─ 表单验证
1531
+ <Form validation={{ email: [{ required: true, message: '请输入邮箱' }] }}
1532
+ onSubmit={values => api.post('/login', values)}
1533
+ onError={errors => setErrors(errors)}>
1534
+ <Field label="邮箱" error={errors.email}>
1535
+ <Input name="email" />
1536
+ </Field>
1537
+ <Button type="submit">登录</Button>
1538
+ </Form>
1284
1539
  ```
1285
1540
 
1541
+ > 所有组件引用 `--wf-*` CSS 变量做主题,详见下文的「样式定制指南」。
1542
+
1543
+ ### 生命周期映射
1544
+
1545
+ 组件没有生命周期函数。每个阶段对应到代码的明确位置:
1546
+
1547
+ ```
1548
+ mount ──────────────────────────────────────────
1549
+ const Counter = (_init, ctx) => { ← mount(只一次)
1550
+ let count = 0 ← 初始化状态
1551
+ return (props) => { ← render 函数
1552
+ // ... ← 每次 dirty/props 变化执行
1553
+ }
1554
+ }
1555
+
1556
+ ref ────────────────────────────────────────────
1557
+ h('div', {
1558
+ ref: (el) => {
1559
+ if (el) { /* 元素已创建 */ } ← 相当于 onmounted
1560
+ else { /* 元素已移除 */ } ← 相当于 onunmount
1561
+ }
1562
+ })
1563
+
1564
+ props 变化 ─────────────────────────────────────
1565
+ return (props) => {
1566
+ // 每次 render 都收到最新 props ← 相当于 onupdate
1567
+ if (props.value !== prevValue) { ... }
1568
+ }
1569
+ ```
1570
+
1571
+ | 旧概念 | 新写法 |
1572
+ |--------|--------|
1573
+ | `onmount` | mount 外层函数直接写 |
1574
+ | `onmounted` | `ref` 的 `if (el)` 分支 |
1575
+ | `onunmount` | `ref` 的 `else` 分支 |
1576
+ | `onupdate` | render 内层函数收新 props 自行比较 |
1577
+
1286
1578
  ## 组件列表
1287
1579
 
1288
1580
  ### 表单核心
@@ -1352,6 +1644,14 @@ import 'weifuwu/components/style.css'
1352
1644
  | Steps | `Steps` | `items: StepItem[]`, `current`, `direction`, `size` | 步骤条 |
1353
1645
  | Accordion | `Accordion` | `items: AccordionItem[]`, `multiple`, `defaultActive` | 手风琴 |
1354
1646
 
1647
+ ### 图表
1648
+
1649
+ | 组件 | 导入名 | 关键 Props | 说明 |
1650
+ |-----|--------|-----------|------|
1651
+ | Chart | `Chart` | `type: ChartType`, `data`, `options`, `title`, `area` | SVG 图表(line/bar/pie)|
1652
+ | DatePicker | `DatePicker` | `mode: DatePickerMode`, `value`, `onChange`, `placeholder` | 日期选择器(date/datetime/time/range)|
1653
+ | Editor | `Editor` | `value`, `onChange`, `toolbar`, `placeholder`, `disabled` | 富文本编辑器,零依赖 |
1654
+
1355
1655
  ### 布局
1356
1656
 
1357
1657
  | 组件 | 导入名 | 关键 Props | 说明 |
@@ -1364,14 +1664,24 @@ import 'weifuwu/components/style.css'
1364
1664
 
1365
1665
  纯 CSS 布局原语 + 72 个主题 Token。不绑定任何 JS 框架。
1366
1666
 
1667
+ > **全栈 weifuwu 项目**:`weifuwu/components/style.css` 已包含布局系统,一条 import 就够了,无需单独引用本页。
1668
+ > 本页仅适用于**非 weifuwu 项目**或**只需 CSS 布局**的场景。
1669
+
1367
1670
  ```html
1368
- <link rel="stylesheet" href="/node_modules/weifuwu/dist/layout/weifuwu-layout.css">
1671
+ <link rel="stylesheet" href="/node_modules/weifuwu/layout">
1369
1672
  ```
1370
1673
 
1371
- 或通过 `ctx.ui.css` 服务:
1674
+ 或在 weifuwu 服务端通过 `ctx.ui.css` 直接引用包名(`ctx.ui.css` 自动解析 exports map):
1372
1675
 
1373
1676
  ```ts
1374
- app.get('/layout.css', (req, ctx) => ctx.ui.css('./node_modules/weifuwu/dist/layout/weifuwu-layout.css'))
1677
+ // 方案 A:组件 + 布局全部搞定(推荐)
1678
+ app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css'))
1679
+
1680
+ // 方案 B:只用布局
1681
+ app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
1682
+ ```
1683
+
1684
+ 也支持相对路径:`ctx.ui.css('./src/style.css')`。
1375
1685
  ```
1376
1686
 
1377
1687
  ## 35 个布局原语
@@ -1478,34 +1788,185 @@ document.documentElement.setAttribute('data-theme', 'dark')
1478
1788
 
1479
1789
  ---
1480
1790
 
1481
- # 核心概念
1791
+ # 样式定制指南
1482
1792
 
1483
- ## 中间件模式(前后端一致)
1793
+ ## 全局主题变量
1484
1794
 
1795
+ 所有组件引用 `--wf-*` CSS 变量。在根元素覆盖即可定制主题:
1796
+
1797
+ ```css
1798
+ :root {
1799
+ --wf-color-primary: #6366f1;
1800
+ --wf-color-primary-hover: #4f46e5;
1801
+ --wf-radius: 8px;
1802
+ --wf-font-sans: 'Inter', system-ui, sans-serif;
1803
+ }
1485
1804
  ```
1486
- 后端: app.use(cors())
1487
- app.use(postgres())
1488
- app.get('/users', (req, ctx) => { ctx.sql`SELECT *` })
1489
- // ctx 已注入 ctx.sql
1490
1805
 
1491
- 前端: createApp()
1492
- .use(api({ baseURL: '/api' }))
1493
- .use(auth())
1494
- .mount('#root', App)
1495
- // ctx 已注入 ctx.api, ctx.auth
1806
+ ## 暗色模式
1807
+
1808
+ ```ts
1809
+ document.documentElement.setAttribute('data-theme', 'dark')
1496
1810
  ```
1497
1811
 
1498
- ## 状态管理
1812
+ 所有 `--wf-*` 变量在 `[data-theme="dark"]` 下自动切换。可自定义暗色变量:
1499
1813
 
1500
- | 模式 | 后端 | 前端 |
1501
- |------|------|------|
1502
- | 注入 | 中间件注入 ctx.field | 中间件注入 ctx.field |
1503
- | 读取 | handler 读取 ctx | 组件读取 ctx |
1504
- | 渲染 | 返回 Response | `ctx.ui.render()` / `ctx.ui.dirty()` 触发 VDOM patch |
1814
+ ```css
1815
+ [data-theme="dark"] {
1816
+ --wf-color-bg: #1a1a2e;
1817
+ --wf-color-text: #e0e0e0;
1818
+ --wf-color-border: #2a2a4a;
1819
+ }
1820
+ ```
1505
1821
 
1506
- ## Closeable 接口
1822
+ ## 组件级覆盖
1507
1823
 
1508
- 所有有状态模块(postgres、redis)实现 `close(): Promise<void>`,serve 关闭时自动调用。
1824
+ ```css
1825
+ /* 覆盖 Button 主色 */
1826
+ .wf-btn--primary {
1827
+ background: #06b6d4;
1828
+ border-color: #06b6d4;
1829
+ }
1830
+
1831
+ /* 覆盖 Modal 圆角 */
1832
+ .wf-modal-content {
1833
+ border-radius: 16px;
1834
+ }
1835
+ ```
1836
+
1837
+ ## 作用域主题
1838
+
1839
+ ```html
1840
+ <div style="--wf-color-primary: #f59e0b;">
1841
+ <!-- 此区域内组件使用金色主题,外部不受影响 -->
1842
+ <button class="wf-btn wf-btn--primary">金色按钮</button>
1843
+ </div>
1844
+ ```
1845
+
1846
+ CSS 变量会沿 DOM 树继承,利用这一点可实现多主题共存。
1847
+
1848
+ ---
1849
+
1850
+ # 组合场景示例
1851
+
1852
+ ## 登录表单
1853
+
1854
+ ```tsx
1855
+ const LoginPage = (_init, ctx) => {
1856
+ const $ = ctx.ui.$()
1857
+ $.errors = {}
1858
+ $.submitting = false
1859
+
1860
+ return (props) =>
1861
+ h('div', { class: 'wf-stack', style: { maxWidth: 400, margin: '40px auto' } },
1862
+ h(Card, { shadow: 'md' },
1863
+ h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
1864
+ h('h2', {}, '登录'),
1865
+ h(Form, {
1866
+ validation: {
1867
+ email: [{ required: true, pattern: /@/, message: '请输入有效邮箱' }],
1868
+ password: [{ required: true, minLength: 6, message: '密码至少6位' }],
1869
+ },
1870
+ onSubmit: async (values) => {
1871
+ $.submitting = true
1872
+ await api.post('/login', values)
1873
+ $.submitting = false
1874
+ },
1875
+ onError: (errors) => { $.errors = errors },
1876
+ }, [
1877
+ h(Field, { label: '邮箱', error: $.errors.email },
1878
+ h(Input, { name: 'email', type: 'email', placeholder: 'name@example.com' })),
1879
+ h(Field, { label: '密码', error: $.errors.password },
1880
+ h(Input, { name: 'password', type: 'password' })),
1881
+ h(Button, { type: 'submit', loading: $.submitting, block: true }, '登录'),
1882
+ ])
1883
+ )
1884
+ )
1885
+ )
1886
+ }
1887
+ ```
1888
+
1889
+ ## 数据列表 + 搜索
1890
+
1891
+ ```tsx
1892
+ const UserList = (_init, ctx) => {
1893
+ const $ = ctx.ui.$()
1894
+ $.keyword = ''
1895
+ $.sortKey = 'name'
1896
+ $.sortOrder = 'asc'
1897
+ const users = [
1898
+ { id: 1, name: '张三', email: 'zhang@example.com', role: '管理员' },
1899
+ { id: 2, name: '李四', email: 'li@example.com', role: '编辑' },
1900
+ ]
1901
+
1902
+ const filtered = users.filter(u =>
1903
+ !$.keyword || u.name.includes($.keyword) || u.email.includes($.keyword)
1904
+ )
1905
+
1906
+ return (props) =>
1907
+ h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
1908
+ h('div', { class: 'wf-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
1909
+ h(SearchInput, { placeholder: '搜索用户...', value: $.keyword, onSearch: (v: string) => { $.keyword = v } }),
1910
+ h(Button, { variant: 'primary' }, '新建用户'),
1911
+ ),
1912
+ h(Table, {
1913
+ columns: [
1914
+ { key: 'id', label: 'ID', width: 60 },
1915
+ { key: 'name', label: '姓名', sortable: true },
1916
+ { key: 'email', label: '邮箱', sortable: true },
1917
+ { key: 'role', label: '角色' },
1918
+ ],
1919
+ data: filtered,
1920
+ sortKey: $.sortKey,
1921
+ sortOrder: $.sortOrder,
1922
+ onSort: (key, order) => { $.sortKey = key; $.sortOrder = order },
1923
+ emptyText: '无匹配用户',
1924
+ }),
1925
+ h(Pagination, { total: filtered.length, page: 1, pageSize: 10, onChange: (p: number) => {} }),
1926
+ )
1927
+ }
1928
+ ```
1929
+
1930
+ ## 消息提示
1931
+
1932
+ ```tsx
1933
+ // 在任意组件中调用
1934
+ let toastId = 0
1935
+
1936
+ function showToast(ctx: WfuiContext, type: ToastType, message: string) {
1937
+ // 通过 ctx 管理 Toast 列表
1938
+ const $ = ctx.ui.$()
1939
+ $.toasts = $.toasts ?? []
1940
+ const id = String(++toastId)
1941
+ $.toasts = [...$.toasts, { id, type, message }]
1942
+
1943
+ // 自动消失
1944
+ if (type !== 'error') {
1945
+ setTimeout(() => {
1946
+ $.toasts = $.toasts.filter((t: any) => t.id !== id)
1947
+ }, 3000)
1948
+ }
1949
+ }
1950
+
1951
+ // 页面中使用
1952
+ const App = (_init, ctx) => {
1953
+ const $ = ctx.ui.$()
1954
+ $.toasts = []
1955
+
1956
+ return (props) =>
1957
+ h('div', {}, [
1958
+ h(Button, {
1959
+ onClick: () => showToast(ctx, 'success', '操作成功'),
1960
+ }, '显示提示'),
1961
+ h(Toast, {
1962
+ toasts: $.toasts,
1963
+ position: 'top-right',
1964
+ max: 3,
1965
+ onRemove: (id) => { $.toasts = $.toasts.filter((t: any) => t.id !== id) },
1966
+ }),
1967
+ ])
1968
+ }
1969
+ ```
1509
1970
 
1510
1971
  ---
1511
1972