weifuwu 0.75.0 → 0.77.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.
Files changed (75) hide show
  1. package/README.md +48 -53
  2. package/dist/ai/types.d.ts +2 -0
  3. package/dist/components/AiChat/AiChat.d.ts +5 -1
  4. package/dist/components/ApprovalCard/ApprovalCard.d.ts +5 -2
  5. package/dist/components/CitationCard/CitationCard.d.ts +38 -0
  6. package/dist/components/Command/Command.d.ts +1 -1
  7. package/dist/components/DatePicker/DatePicker.d.ts +1 -1
  8. package/dist/components/JsonSchemaForm/JsonSchemaForm.d.ts +48 -0
  9. package/dist/components/ReasoningBlock/ReasoningBlock.d.ts +25 -0
  10. package/dist/components/Select/Select.d.ts +11 -1
  11. package/dist/components/SessionList/SessionList.d.ts +42 -0
  12. package/dist/components/Tour/Tour.d.ts +1 -1
  13. package/dist/components/TreeSelect/TreeSelect.d.ts +3 -2
  14. package/dist/components/index.d.ts +10 -2
  15. package/dist/components/index.js +14 -14
  16. package/dist/components/style.css +625 -20
  17. package/dist/index.js +1349 -1529
  18. package/dist/layout/weifuwu-layout.css +9 -0
  19. package/dist/scheduler/index.d.ts +7 -2
  20. package/dist/test/client/setup.d.ts +12 -0
  21. package/dist/ui/index.d.ts +2 -2
  22. package/dist/ui-dom/Confirm.d.ts +5 -41
  23. package/dist/ui-dom/Notification.d.ts +8 -70
  24. package/dist/ui-dom/Toast.d.ts +8 -43
  25. package/dist/ui-dom/hooks/chat.d.ts +11 -0
  26. package/dist/ui-dom/hooks/events.d.ts +30 -0
  27. package/dist/ui-dom/hooks/external.d.ts +20 -0
  28. package/dist/ui-dom/hooks/index.d.ts +30 -0
  29. package/dist/ui-dom/hooks/input.d.ts +33 -0
  30. package/dist/ui-dom/hooks/media.d.ts +17 -0
  31. package/dist/ui-dom/hooks/popup.d.ts +28 -0
  32. package/dist/ui-dom/hooks/stable.d.ts +39 -0
  33. package/dist/ui-dom/hooks/test/hooks.test.d.ts +1 -0
  34. package/dist/ui-dom/hooks/types.d.ts +77 -0
  35. package/dist/ui-dom/index.d.ts +19 -17
  36. package/dist/ui-dom/index.js +11 -11
  37. package/dist/ui-dom/jsx-runtime.js +1 -1
  38. package/dist/ui-dom/store.d.ts +24 -0
  39. package/dist/ui-dom/testing.d.ts +74 -0
  40. package/dist/ui-dom/testing.js +1 -0
  41. package/dist/ui-dom/testing.test.d.ts +1 -0
  42. package/dist/ui-dom/types.d.ts +55 -50
  43. package/dist/ui-dom/use-chat.d.ts +19 -14
  44. package/dist/ui-dom/vdom/build.d.ts +41 -0
  45. package/dist/ui-dom/vdom/diff.d.ts +33 -0
  46. package/dist/ui-dom/vdom/hydration.d.ts +17 -0
  47. package/dist/ui-dom/vdom/index.d.ts +23 -0
  48. package/dist/ui-dom/vdom/mount.d.ts +58 -0
  49. package/dist/ui-dom/vdom/registry.d.ts +27 -0
  50. package/dist/ui-dom/vdom/render.d.ts +15 -0
  51. package/dist/ui-dom/vdom/serve.d.ts +30 -0
  52. package/dist/ui-dom/vdom/ssr.d.ts +44 -0
  53. package/dist/ui-dom/vnode.d.ts +19 -31
  54. package/docs/components-map.md +18 -1
  55. package/docs/components.md +12 -7
  56. package/docs/custom-components.md +90 -60
  57. package/docs/examples.md +35 -41
  58. package/docs/frontend-middleware.md +3 -4
  59. package/docs/frontend-ui-dom.md +21 -18
  60. package/docs/frontend.md +243 -168
  61. package/docs/layout.md +2 -2
  62. package/docs/mobile.md +2 -2
  63. package/docs/realtime.md +8 -3
  64. package/package.json +5 -1
  65. package/dist/ui/ssr.d.ts +0 -35
  66. package/dist/ui-dom/diff.d.ts +0 -28
  67. package/dist/ui-dom/focus-trap.d.ts +0 -4
  68. package/dist/ui-dom/hydration.d.ts +0 -13
  69. package/dist/ui-dom/reactive.d.ts +0 -6
  70. package/dist/ui-dom/registry.d.ts +0 -27
  71. package/dist/ui-dom/render.d.ts +0 -46
  72. package/dist/ui-dom/scroll-lock.d.ts +0 -5
  73. package/dist/ui-dom/serve.d.ts +0 -28
  74. package/dist/ui-dom/ssr.d.ts +0 -57
  75. package/dist/ui-dom/ui.d.ts +0 -78
@@ -7,7 +7,7 @@
7
7
  * --jsxImportSource=weifuwu/ui-dom
8
8
  */
9
9
  import type { WfuiContext } from './types.ts';
10
- export type VNodeType = string | Component<any, any> | AsyncComponent | typeof Fragment | typeof Portal;
10
+ export type VNodeType = string | Component<any, any> | typeof Fragment | typeof Portal;
11
11
  /**
12
12
  * VNode 子节点合法值——组件可返回/渲染的多态内容。
13
13
  * 递归联合:string/number/VNode/array/null/boolean 任意组合。
@@ -24,55 +24,40 @@ export interface VNode {
24
24
  _remoteEl?: HTMLElement | undefined;
25
25
  /** VNode 的 DOM 归属:'local' 在父 DOM 树下,'remote' 在别处 */
26
26
  _placement?: 'local' | 'remote';
27
- /** 两阶段组件的 render 函数(mount 返回的函数) */
28
- _render?: (props: Record<string, unknown>) => VNode | null;
27
+ /** 两阶段组件的 render 函数(mount 返回的函数——强制异步:props 变化时可 await 数据) */
28
+ _render?: (props: Record<string, unknown>) => Promise<VNode | null>;
29
29
  /** 组件实例 ID(如 '_wf_0') */
30
30
  _id?: string;
31
31
  /** 自定义组件 ID(ctx.ui.selfId() 注册,跨组件精准刷新) */
32
32
  _customId?: string;
33
33
  /** 组件输出的 DOM 父节点 */
34
34
  _parentNode?: Node;
35
+ /** 父 vnode 引用(动态挂载补全向上找持有组件) */
36
+ _parentVNode?: VNode;
35
37
  /** 组件输出的第一个 DOM 节点 */
36
38
  _refNode?: Node | null;
37
- /** 原生 async 组件缓存(vnode 级按实例):in-flight Promise → resolved renderFn(diff 传递继承) */
38
- _asyncDef?: ((props: Record<string, unknown>) => VNode | null) | Promise<((props: Record<string, unknown>) => VNode | null)> | null;
39
39
  /** Fragment 展开后的多个直属 DOM 节点范围(diff 对齐用,见 diff.ts) */
40
40
  _childNodes?: Node[];
41
- /** 组件 mount/render 时的 ctx 版本号(供三态 skip 判定) */
41
+ /** 组件 renderFn 上次执行时的 ctx 版本号(buildVNode 剪枝 + diff 三态 skip 的版本比较——
42
+ * bumpCtxVersion 递增后版本不同 → 强制重跑 renderFn,如 i18n 切换语言) */
42
43
  _ctxVersion?: number;
43
44
  }
44
45
  /**
45
- * 两阶段组件:外层 = mount(一次),内层 = render(每次 dirty/props 变化)。
46
+ * 两阶段异步组件(weifuwu 唯一组件形态):
47
+ * async (initProps, ctx) => Promise<renderFn>
48
+ * 外层 = mount(一次,可 await 数据),内层 = renderFn(每次 dirty/props 变化——**强制异步**,
49
+ * 可 await 数据;统一异步心智:两阶段都可 await,无「同步组件 vs 异步组件」二元形态)。
46
50
  * P = props 类型(JSX 自动推断),C = 组件依赖的 ctx 注入(如 ApiInjected & RouteInjected)
47
- */
48
- export type Component<P = {}, C extends object = {}> = (initProps: P, ctx: WfuiContext & C) => ((props: P) => VNode | null) | null;
49
- /**
50
- * 异步组件(统一签名——与 Component 同参,唯一差别是 async):
51
- * async (initProps, ctx) => Promise<renderFn | null>
52
51
  *
53
- * 渲染器统一判别「返回值 instanceof Promise」:
54
- * 客户端:占位 → resolve 后整树重渲染(vnode 级 _asyncDef 按实例缓存)
55
- * SSR/hydration:直接 await(无占位)
52
+ * renderFn 签名:async (props) => Promise<VNode | null>——同步 renderFn 是类型错误
53
+ * (diff 永不执行 renderFn——渲染器在 buildVNode 阶段 await,同步上下文拿不到 vnode)。
54
+ * 渲染器按「返回值是 Promise」判别(主路径 buildVNode await 全部工厂 + renderFn)。
56
55
  */
57
- export type AsyncComponent<C extends object = {}, P = {}> = (initProps: P, ctx: WfuiContext & C) => Promise<((props: P) => VNode | null) | null>;
56
+ export type RenderFn<P> = (props: P) => Promise<VNode | null>;
57
+ export type Component<P = {}, C extends object = {}> = (initProps: P, ctx: WfuiContext & C) => Promise<RenderFn<P> | null>;
58
58
  export declare const Fragment: unique symbol;
59
59
  /** Portal — 将子 VNode 渲染到 document.body 下的独立容器 */
60
60
  export declare const Portal: unique symbol;
61
- /**
62
- * 占位显示策略组件(原生 async 组件未 resolve 时的占位 vnode)。
63
- * 渲染时查 ctx 原型链上最近的 Suspense 边界:有 → fallback;无 → null(向后兼容)。
64
- */
65
- export declare const Placeholder: Component;
66
- /**
67
- * Suspense 边界(可选):子树内 async 组件占位时显示 fallback。
68
- * 渲染器对 type === Suspense 的组件在 childCtx 挂 _suspense(Object.create 继承 → 子树可见)。
69
- * 语义:占位处局部显示 fallback(非 React 整树回滚);无边界时占位为 null。
70
- *
71
- * ```tsx
72
- * h(Suspense, { fallback: h(Spinner) }, h(UserProfile, {}))
73
- * ```
74
- */
75
- export declare const Suspense: Component;
76
61
  /** JSX 类型声明 — 使 TypeScript 理解自定义 JSX 运行时 */
77
62
  export declare function jsx(type: VNodeType, props: Record<string, any> | null, key?: string | null): VNode;
78
63
  export declare const jsxs: typeof jsx;
@@ -80,6 +65,9 @@ export declare function jsxDEV(type: VNodeType, props: Record<string, any> | nul
80
65
  /** `h`(hyperscript)支持 variadic children: `h('div', {class:'x'}, child1, child2)` */
81
66
  export declare function h(type: VNodeType, props: Record<string, any> | null, ...children: VNodeChild[]): VNode;
82
67
  export declare function isNative(vnode: VNode): boolean;
68
+ /** 递归文本/数组归一化(children 数组展开——嵌套数组扁平化,DOM 范围对齐)。
69
+ * 栈展开(索引遍历替代 shift/unshift 头部操作——长数组 O(n) 而非 O(n²));逆序入栈 + pop 保持原顺序 */
70
+ export declare function normalizeChildren(c: VNodeChild | undefined | null): VNodeChild[];
83
71
  export declare function isComponent(vnode: VNode): boolean;
84
72
  export declare function isFragment(vnode: VNode): boolean;
85
73
  export declare function isPortal(vnode: VNode): boolean;
@@ -38,7 +38,7 @@
38
38
  |------|---------|------|-------------|-----------|---------|
39
39
  | 输入框 | `<Input label error hint variant>` | Input | Input | Input | weifuwu 自带 label/error/hint 包装(antd 需 Form.Item);`variant="borderless"` 内联编辑 |
40
40
  | 文本域 | `<Textarea rows showCount maxLength>` | Input.TextArea | Input textarea | Textarea | weifuwu 独立组件 + 字数统计 |
41
- | 选择器 | `<Select options searchable multiple>` | Select | Select | Select / Combobox | **searchable + 键盘 ↑↓ + multiple 已实现**(近 Combobox) |
41
+ | 选择器 | `<Select options searchable multiple>` | Select | Select | Select / Combobox | **searchable + 键盘 ↑↓ + multiple + optgroup 分组已实现**(近 Combobox) |
42
42
  | 复选框 | `<Checkbox checked label>` | Checkbox | Checkbox | Checkbox | 等价 |
43
43
  | 复选组 | `<CheckboxGroup options value>` | Checkbox.Group | Checkbox 组 | — | 数组受控 + 栅格列数 |
44
44
  | 单选 | `<RadioGroup options value>` | Radio.Group | Radio | RadioGroup | 等价 |
@@ -115,6 +115,10 @@
115
115
  | `<CodeBlock code lang>` | 代码块 + 语言标签 + 复制按钮 |
116
116
  | `<MessageBubble role status>` | 聊天气泡(独立复用,AiChat 抽取) |
117
117
  | `<ToolCallCard>` | AI 工具调用状态机(running/ok/error) |
118
+ | `<JsonSchemaForm schema value onSubmit>` | JSON Schema → 参数输入表单:类型映射 + 必填/范围校验 + 嵌套/数组(工具参数输入面;不支持项告警降级) |
119
+ | `<ReasoningBlock content>` | CoT 推理折叠展示(thinking 模式 reasoning_content;流式脉冲) |
120
+ | `<CitationCard items>` | RAG 引用来源列表:折叠 + 序号/标题/来源/片段/链接 + 溢出 +N |
121
+ | `<SessionList sessions>` | 会话管理:分组(今天/昨天/更早)+ 搜索 + 重命名/删除/新建 + 键盘导航 |
118
122
  | `<ApprovalCard>` | HITL 人工审批卡片(pending/approved/rejected/timeout) |
119
123
  | `<Editor>` | 富文本编辑器(contentEditable + 工具栏/表格/图片,零依赖) |
120
124
  | `<ThemeSwitch>` | 明暗主题切换(CSS 变量驱动,配 `data-theme`) |
@@ -122,6 +126,19 @@
122
126
  | `<AspectRatio>` | 宽高比容器(内容填满) |
123
127
  | `<CopyButton>` | 复制(clipboard + execCommand 降级 + 成功状态机) |
124
128
 
129
+ ## 第九批:AI 差异化(109 → 111 组件,2026-08)
130
+
131
+ > 三库业务组件覆盖 ~100% 后的差异化期——补 AI **输入层 + 推理展示层**。
132
+ > 完整设计/裁剪见 `design/ai-differentiation-plan.md`。
133
+
134
+ | 组件 | 类型 | 三库等价 | 要点 |
135
+ |------|------|---------|------|
136
+ | **JsonSchemaForm** | 新增 | @rjsf(依赖重,weifuwu 零依赖子集) | JSON Schema(对象子集)→ 参数输入表单:string/number/integer/boolean/enum/object/array 类型映射 + required/范围校验 + 嵌套/数组增删;不支持项($ref/组合 schema)告警降级 |
137
+ | **ReasoningBlock** | 新增 | 三库无 | CoT 推理折叠展示:aria-expanded + 键盘可达 + 流式脉冲 |
138
+ | **AiChat 集成** | 增强 | — | `WfDone.reasoning`(additive,收尾一次性下发)+ use-chat 聚合 + toChatMessages 回传 `reasoning_content`(thinking 模式闭环) |
139
+
140
+ > 协议变更:`wf:done` 新增可选 `reasoning` 字段(向后兼容),见 `design/ai-contract.md` §3.4。
141
+
125
142
  ## 快速迁移路径(三库 → weifuwu)
126
143
 
127
144
  ```tsx
@@ -2,14 +2,14 @@
2
2
 
3
3
  > 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
4
4
 
5
- 109 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
5
+ 113 个 HTML 原语组件。每个是 `async (initProps, ctx) => (props) => Promise<VNode>`(两阶段组件,与前端框架同一模型——外层工厂 + 内层 renderFn 强制异步),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
6
6
 
7
7
  > **组件速查(weifuwu 组件 ↔ antd / Element Plus / shadcn-ui 对应 + 迁移示例)**:见 [`docs/components-map.md`](components-map.md)——从其他组件库迁来的开发者按功能直接找对应组件。
8
8
  > **自定义组件开发**:见 [docs/custom-components.md](custom-components.md)——usePopup/useControlled/对话框/AI 组件/类型纪律逐步指南。
9
9
 
10
10
  ```ts
11
11
  import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
12
- import 'weifuwu/components/style.css' // 包含 Token + 57 布局原语 + 136 工具类 + 组件样式,一次性引入
12
+ import 'weifuwu/components/style.css' // 包含 Token + 58 布局原语 + 136 工具类 + 组件样式,一次性引入
13
13
  ```
14
14
 
15
15
  ### 使用示例
@@ -26,9 +26,10 @@ import 'weifuwu/components/style.css' // 包含 Token + 57 布局原语 + 136
26
26
  <Input type="password" hint="至少6位" />
27
27
  <Input name="email" type="email" disabled placeholder="name@example.com" />
28
28
 
29
- // ├─ 选择器
29
+ // ├─ 选择器(options 支持平铺项与分组混用:{ label, options } → optgroup)
30
30
  <Select options={[{ value: 'a', label: '选项A' }]} placeholder="请选择" />
31
31
  <Select searchable options={options} onChange={v => setVal(v)} />
32
+ <Select options={[{ label: '一线', options: [{ value: 'bj', label: '北京' }] }, { value: 'other', label: '其他' }]} />
32
33
 
33
34
  // ├─ 复选框 / 开关 / 单选
34
35
  <Checkbox checked={agree} onChange={setAgree} label="同意协议" />
@@ -132,9 +133,9 @@ import 'weifuwu/components/style.css' // 包含 Token + 57 布局原语 + 136
132
133
 
133
134
  ```
134
135
  mount ──────────────────────────────────────────
135
- const Counter = (_init, ctx) => { ← mount(只一次)
136
+ const Counter = async (_init, ctx) => { ← mount(只一次,可 await)
136
137
  let count = 0 ← 初始化状态
137
- return (props) => { ← render 函数
138
+ return async (props) => { ← renderFn(强制异步)
138
139
  // ... ← 每次 dirty/props 变化执行
139
140
  }
140
141
  }
@@ -148,7 +149,7 @@ ref ─────────────────────────
148
149
  })
149
150
 
150
151
  props 变化 ─────────────────────────────────────
151
- return (props) => {
152
+ return async (props) => {
152
153
  // 每次 render 都收到最新 props ← 相当于 onupdate
153
154
  if (props.value !== prevValue) { ... }
154
155
  }
@@ -647,7 +648,11 @@ props 变化 ──────────────────────
647
648
  | AiChat | `AiChat` | `chat`, `maxHeight?`, `labels?`, `renderMessage?`, `renderToolArgs?` | 标准 AI 对话界面:气泡 + 工具卡 + 审批卡 + 自动滚动 + 错误重试(接收 `ctx.ui.useChat()` handle) |
648
649
  | MessageBubble | `MessageBubble` | `content`, `role`, `status`, `actions` | 独立消息气泡(业务聊天页复用) |
649
650
  | ToolCallCard | `ToolCallCard` | `call`, `progress?`, `result?`, `renderArgs?` | 工具调用卡片:running(进度条)/ ok / error 三态(协议 §4) |
650
- | ApprovalCard | `ApprovalCard` | `request`, `status?`, `onApprove`, `onReject` | 人工审批卡片:待批(允许/拒绝+备注)/ 已批 / 已拒 / 超时(协议 §4.5) |
651
+ | JsonSchemaForm | `JsonSchemaForm` | `schema`, `value?`, `onChange?`, `onSubmit?`, `submitLabel?` | JSON Schema(对象子集)→ 参数输入表单:类型映射 + required/范围校验 + 嵌套/数组(工具参数输入面;不支持项告警降级) |
652
+ | ReasoningBlock | `ReasoningBlock` | `content`, `label?`, `defaultExpanded?`, `streaming?` | CoT 推理折叠展示(thinking 模式 `reasoning_content`;aria-expanded + 键盘可达 + 流式脉冲) |
653
+ | CitationCard | `CitationCard` | `items`, `label?`, `maxVisible?`, `defaultExpanded?`, `onOpen?` | RAG 引用来源展示:折叠「引用 N 条」+ 条目(序号/标题/来源/片段/链接)+ 溢出 +N;onOpen 时全部可点(调用方处理跳转) |
654
+ | SessionList | `SessionList` | `sessions`, `activeId?`, `onSelect?`, `onNew?`, `onRename?`, `onDelete?`, `searchable?` | 会话管理列表:分组(今天/昨天/更早)+ 选中高亮 + 搜索 + 行内重命名(Enter 确认/Escape 取消)/删除 + 键盘方向键导航 |
655
+ | ApprovalCard | `ApprovalCard` | `request`, `status?`, `onApprove(modifiedArgs?)`, `onReject`, `argsSchema?` | 人工审批卡片:待批(允许/拒绝+备注)/ 已批 / 已拒 / 超时;`argsSchema` 提供时渲染「修改参数」(JsonSchemaForm 预填 args,提交带修改后参数 → `onApprove(modifiedArgs)` → 父层选 modified 决策) |
651
656
 
652
657
  ### 全局工具
653
658
 
@@ -13,31 +13,51 @@
13
13
  import { h, type Component } from 'weifuwu/ui-dom'
14
14
 
15
15
  // Component<P, C>:P = props(JSX 自动推断),C = ctx 注入依赖(默认 {})
16
- const Badge: Component<{ text: string; color?: string }> = () =>
17
- (props) => h('span', { class: 'my-badge', style: { color: props.color } }, props.text)
16
+ const Badge: Component<{ text: string; color?: string }> = async () =>
17
+ async (props) => h('span', { class: 'my-badge', style: { color: props.color } }, props.text)
18
18
  ```
19
19
 
20
- - 两阶段:外层 `(initProps, ctx) => …` 只执行一次(mount),内层 `(props) => VNode` 每次渲染执行
21
- - 不需要状态就不调 `ctx.ui.$()`;需要交互才用
20
+ - 两阶段:外层 `(initProps, ctx) => …` 只执行一次(mount),内层 `(props) => Promise<VNode>` 每次渲染执行(renderFn 强制异步——可 await 数据)
21
+ - 有状态组件用闭包 `let` + 事件里 `ctx.ui.render()`(render-only);不需要渲染的状态不调 `render()`
22
+
23
+ ### mount 与 render 的职责(事件函数写在哪层)
24
+
25
+ | | mount(外层工厂,一次) | render(内层 renderFn,每次) |
26
+ |---|---|---|
27
+ | 职责 | 初始化状态 / 订阅 / 定时器 / **定义依赖稳定引用的回调** | 读最新 props / 派生数据 / **定义依赖它们的回调** / 输出视图 |
28
+ | 可访问 | `initProps`(首次)、`ctx`、mount `let`、稳定 handle | 最新 `props`、mount 闭包、`ctx` |
29
+ | 事件函数 | **只依赖稳定引用**(ctx / mount let / 稳定 handle 如 useChat 的 `chat`)→ mount 定义,天然引用恒等 | 依赖最新 props / 派生状态(如 Table 的 `rowSelection`、Menu 的 `openSet`)→ render 内定义(闭包捕获最新值) |
30
+
31
+ ```tsx
32
+ const AiChat = async (initProps, ctx) => {
33
+ const chat = initProps.chat // 稳定 handle(useChat 返回,引用不变)
34
+ const onSend = () => chat.send() // ✅ mount 定义:只依赖稳定引用——引用恒等,零重绑
35
+ return async (props) => {
36
+ const onSelect = (k: string) => props.onSelect?.(k) // ✅ render 定义:依赖最新 props——闭包捕获当前值
37
+ return h('button', { onClick: onSelect }, '选')
38
+ }
39
+ }
40
+ ```
41
+
42
+ **规则**:回调只依赖 ctx / mount `let` / 稳定 handle → **mount 定义**(天然稳定,不重绑);依赖最新 props / 派生数据 → **render 内定义**(闭包捕获最新值;引用变化导致事件重绑是**正确性要求**——必须读最新状态,框架不做稳定引用魔法)。
22
43
 
23
44
  ## 1. 有状态组件
24
45
 
25
46
  ```tsx
26
- const Toggle: Component = (_init, ctx) => {
27
- const $ = ctx.ui.$() // 深度 Proxy:赋值自动触发渲染(微任务批量)
28
- $.on = false
47
+ const Toggle: Component = async (_init, ctx) => {
48
+ let on = false // 普通对象状态(render-only:无 $ Proxy)
29
49
 
30
- return (props) => h('button', {
50
+ return async (props) => h('button', {
31
51
  class: 'my-toggle',
32
- onClick: () => $.on = !$.on,
33
- }, $.on ? '开' : '关')
52
+ onClick: () => { on = !on; ctx.ui.render() }, // 改状态后显式 render()
53
+ }, on ? '开' : '关')
34
54
  }
35
55
  ```
36
56
 
37
57
  | 状态类型 | 存放位置 | 触发渲染 |
38
58
  |---------|---------|---------|
39
- | 自动 UI 状态 | `$.xxx` | 赋值自动(微任务) |
40
- | 手动 UI 状态 | 闭包 `let` | 需 `ctx.ui.render()` |
59
+ | 组件内部状态 | 闭包 `let` | 改后调 `ctx.ui.render()` |
60
+ | 共享状态 | `createStore` + `ctx.ui.useExternal()` | store 变更自动 |
41
61
  | 内部缓存 | 闭包 `let` | 不触发 |
42
62
 
43
63
  ## 2. 带弹层的组件(最高频的自定义场景)
@@ -45,23 +65,22 @@ const Toggle: Component = (_init, ctx) => {
45
65
  用 `ctx.ui.usePopup`——一个组合器收敛 open 状态 + 触发(hover→tap 降级/longpress)+ Escape + 外部点击 + 定位/视口 clamp + portal:
46
66
 
47
67
  ```tsx
48
- const MyPopover: Component<{ content: string }> = (_init, ctx) => {
49
- const $ = ctx.ui.$()
50
- $.open = false
68
+ const MyPopover: Component<{ content: string }> = async (_init, ctx) => {
69
+ let open = false
51
70
  let wrapEl: HTMLElement | null = null
52
71
  const wrapRef = (el: HTMLElement | null) => { wrapEl = el }
53
72
 
54
73
  const popup = ctx.ui.usePopup({
55
74
  trigger: 'hover', // 触屏自动降级 tap(useHoverCapable 内部判定)
56
75
  el: () => wrapEl, // 锚点
57
- isOpen: () => $.open,
58
- setOpen: (v) => { $.open = v }, // $ 赋值自动渲染
76
+ isOpen: () => open,
77
+ setOpen: (v) => { open = v; ctx.ui.render() }, // 改状态 + render
59
78
  width: 320, // 自动 clamp 视口
60
79
  closeOnOutside: true, // 外部点击关闭(默认)
61
80
  closeOnEscape: true, // Escape 关闭(默认,document 级——portal 焦点也生效)
62
81
  })
63
82
 
64
- return (props) =>
83
+ return async (props) =>
65
84
  h('span', { class: 'anchor', ref: wrapRef, ...popup.wrapProps },
66
85
  props.children,
67
86
  popup.portal(h('div', { class: 'wf-panel' }, props.content)),
@@ -73,54 +92,62 @@ const MyPopover: Component<{ content: string }> = (_init, ctx) => {
73
92
 
74
93
  ## 3. 对话框类组件(Modal 系)
75
94
 
76
- 全屏对话框(焦点 trap + 滚动锁 + 退场动画)不在 usePopup 范围——用 **`ctx.ui.useDialog`** 组合器(Modal/Drawer 同款:退场状态机 + 滚动锁 + 焦点 trap + animationend 卸载):
95
+ 全屏对话框(焦点 trap + 滚动锁 + 退场动画)是 usePopup 的**会话级模态模式**(`presence/trapFocus/lockScroll/positioning: 'none'`——Modal/Drawer 同款):
77
96
 
78
97
  ```tsx
79
- import { createPortal } from 'weifuwu/ui-dom'
80
-
81
- const MyDialog: Component<{ open: boolean; onClose: () => void }> = (_init, ctx) => {
82
- const dialog = ctx.ui.useDialog({ name: 'MyDialog' }) // mount 创建
83
-
84
- return (props) => {
85
- const phase = dialog.sync(!!props.open) // render 同步 open
98
+ const MyDialog: Component<{ open: boolean; onClose: () => void }> = async (_init, ctx) => {
99
+ let latestOpen = false
100
+ const popup = ctx.ui.usePopup({
101
+ presence: true, // 退场状态机(open → exit → closed + animationend)
102
+ trapFocus: true, // 焦点 trap(面板挂载锁定/卸载归还)
103
+ lockScroll: true, // 滚动锁(打开锁 / 面板卸载释放)
104
+ positioning: 'none', // 组件自定义定位(.wf-modal inset:0 居中)
105
+ closeOnOutside: false, closeOnEscape: false, // 关闭语义组件自控
106
+ isOpen: () => latestOpen,
107
+ setOpen: () => {},
108
+ }) // mount 创建
109
+
110
+ return async (props) => {
111
+ latestOpen = !!props.open
112
+ const phase = popup.sync!(latestOpen) // render 同步 open
86
113
  if (phase === 'closed') return null
87
114
 
88
- return createPortal(h('div', {
115
+ return popup.portal(h('div', {
89
116
  class: 'wf-overlay',
90
117
  onClick: (e: any) => { if (e.target === e.currentTarget) props.onClose() },
91
118
  }, h('div', {
92
119
  class: `wf-modal ${phase === 'exit' ? 'wf-modal--exit' : 'wf-modal--enter'}`,
93
- ref: dialog.panelRef, // 焦点 trap 目标
94
120
  onKeyDown: (e: any) => { if (e.key === 'Escape') props.onClose() }, // Escape 语义组件层
95
- }, props.children)), document.body)
121
+ }, props.children)), 'modal') // portalKey 语义化(#__wf_portal 容器标记)
96
122
  }
97
123
  }
98
124
  ```
99
125
 
100
- > `dialog.rootRef` 挂到 portal 根(lockScroll + animationend 退场监听);`panelRef` 挂到面板(trapFocus)。
101
- > 低层原语 `trapFocus`/`lockScroll`/`animateOut` 仍从 `weifuwu/ui-dom` 导出(特殊场景组装用)。
126
+ > **ref 接线**:`trapFocus`/`lockScroll`/presence 退场监听全部由 usePopup 内部接线到 portal 面板
127
+ > (`portalPanelRef`——content 的 `ref` prop 会被转发调用)——组件层无需手挂 `rootRef`/`panelRef`。
128
+ > 低层原语 `trapFocus`/`lockScroll` 已收编为 usePopup 内部实现(**不对外导出**——AGENTS.md §5.4);
129
+ > `animateOut` 仍从 `weifuwu/ui-dom` 导出(非弹窗动画场景用)。
102
130
 
103
131
  ## 4. AI 组件
104
132
 
105
133
  会话语义由 `ctx.ui.useChat` 提供(消息/流式/工具/审批/stop/retry 全封装),返回的 handle 与 `$` 同一容器:
106
134
 
107
135
  ```tsx
108
- const ChatPanel: Component = (_init, ctx) => {
109
- const $ = ctx.ui.useChat({
136
+ const ChatPanel: Component = async (_init, ctx) => {
137
+ const chat = ctx.ui.useChat({
110
138
  url: '/api/chat',
111
139
  approveUrl: '/api/approve', // HITL 审批上行(缺省 approve() 只清卡片)
112
140
  body: (messages) => ({ messages, mode: 'agent' }),
113
141
  })
114
142
 
115
143
  return () => h('div', { class: 'chat' },
116
- $.messages.map((m) => h('div', { class: `msg-${m.role}` }, m.content)),
117
- h('input', { value: $.input, onInput: (e: any) => $.input = e.target.value }),
118
- h('button', { onClick: () => $.send() }, $.streaming ? '…' : '发送'),
144
+ h(AiChat, { chat }), // 标准界面:输入/消息/流式/工具卡全内置
145
+ h('button', { onClick: () => chat.send() }, chat.streaming ? '…' : '发送'),
119
146
  )
120
147
  }
121
148
  ```
122
149
 
123
- **共享 `$` 给子组件**(如 `<AiChat chat={$} />`):父组件 dirty 不驱动子组件(三态 skip),子组件 mount 期 `initProps.chat.__watch?.(() => ctx.ui.dirty())` 自订阅。
150
+ **共享 handle 给子组件**(如 `<AiChat chat={chat} />`):会话 handle 带 `subscribe(cb)`——子组件 mount 期 `ctx.ui.useExternal(initProps.chat)` 自订阅(AiChat 已内置),会话状态变化自动重渲染订阅组件。
124
151
 
125
152
  ## 5. 异步组件(数据声明在工厂层)
126
153
 
@@ -129,29 +156,27 @@ const ChatPanel: Component = (_init, ctx) => {
129
156
  ```tsx
130
157
  const UserCard = async (initProps, ctx) => {
131
158
  const user = await ctx.data.get(`/api/user/${initProps.userId}`) // 三场景:SSR→__DATA__ / hydration 种子 / SPA fetch
132
- const $ = ctx.ui.$()
133
- $.liked = false
134
- return (props) => h('div', {}, user.name, h('button', { onClick: () => $.liked = !$.liked }))
159
+ let liked = false
160
+ return async (props) => h('div', {}, user.name, h('button', { onClick: () => { liked = !liked; ctx.ui.render() } }))
135
161
  }
136
162
  ```
137
163
 
138
- - 渲染器按「返回值是 Promise」判别:客户端未 resolve → 占位(`Placeholder`),resolve 后整树补全;SSR 直接 await(无占位)
164
+ - 渲染器按「返回值是 Promise」判别:主路径 `buildVNode` await 全部(无占位);运行时首次挂载的 async 组件在 buildVNode 阶段 await(无占位/补全回调);SSR 直接 await
139
165
  - 工厂按实例执行;**数据必须走 ctx.data**(缓存+并发合并,重复执行零成本);禁止副作用裸写工厂
140
- - 占位显示:无边界 → null;`<Suspense fallback={...}>` → 占位处显示 fallback(可选)
141
- - **个性化数据不进 ctx.data**(SSR 会序列化给所有客户端)——留在客户端 `$` + fetch
166
+ - **个性化数据不进 ctx.data**(SSR 会序列化给所有客户端)——留在客户端 `let` + fetch + `render()`
142
167
 
143
168
  ## 6. 类型纪律(编译期防线)
144
169
 
145
170
  ```tsx
146
- const Badge: Component<{ variant: 'primary' | 'muted' }> = () =>
147
- (props) => h('span', { class: `badge-${props.variant}` }, props.children)
171
+ const Badge: Component<{ variant: 'primary' | 'muted' }> = async () =>
172
+ async (props) => h('span', { class: `badge-${props.variant}` }, props.children)
148
173
 
149
174
  // 负例:variant 传错 → tsc 报错(@ts-expect-error 是类型流测试的写法)
150
175
  // @ts-expect-error variant 不允许 'bogus'
151
176
  const bad: { variant: 'primary' | 'muted' } = { variant: 'bogus' }
152
177
 
153
178
  // ctx 注入声明(C 泛型):声明了才能用,未声明编译期报错
154
- const Page: Component<{}, { api: ApiInjected['api'] }> = (_init, ctx) => {
179
+ const Page: Component<{}, { api: ApiInjected['api'] }> = async (_init, ctx) => {
155
180
  ctx.api.get('/x')
156
181
  return () => null
157
182
  }
@@ -167,17 +192,23 @@ const Page: Component<{}, { api: ApiInjected['api'] }> = (_init, ctx) => {
167
192
 
168
193
  ## 7. 测试写法
169
194
 
195
+ **官方测试原语 `weifuwu/ui-dom/testing`**(子路径,随包发布)——自研组件测试用官方工具,不手抄:
196
+
170
197
  ```tsx
171
- function renderVNode(Comp: any, props: any, ctx: any) {
172
- const result = Comp(props, ctx)
173
- return typeof result === 'function' ? result(props) : result
174
- }
198
+ import { renderVNode, mountComponent, findByClass, createTestCtx } from 'weifuwu/ui-dom/testing'
199
+ // 仓库内开发用相对路径:from '../../ui-dom/testing.ts'
175
200
 
176
- const ctx = { ui: { $: () => ({}), render: () => {}, dirty: () => {}, useControlled: (o: any) => ({ value: o.value, setValue: o.onChange ?? (() => {}), controlled: o.value !== undefined }) } }
177
- const vnode = renderVNode(Toggle, {}, ctx)
201
+ const vnode = renderVNode(Toggle, {}, createTestCtx())
178
202
  // 断言 vnode 结构(子组件 VNode.type 是组件函数,不是标签名)
203
+
204
+ // 交互流转(内部 let 状态)用 mountComponent(同实例 re-render):
205
+ const render = mountComponent(Toggle, {}, createTestCtx())
206
+ render() // 初始
207
+ // ... 触发点击/输入 ...
208
+ render() // 重渲染,状态保留
179
209
  ```
180
210
 
211
+ - 弹层组件:`createPopupMock(isOpen)` 注入 `createTestCtx({ ui: { usePopup: () => popup } })`
181
212
  - 类型流测试:`@ts-expect-error` 负例(见 [type-flow.test.ts](../src/components/type-flow.test.ts))
182
213
  - 组件测试跑在 node --test;DOM 事件级测试需 `document.body.appendChild(container)`
183
214
 
@@ -186,8 +217,8 @@ const vnode = renderVNode(Toggle, {}, ctx)
186
217
  新受控组件**必须**用 `ctx.ui.useControlled`(受控判定 + 缺回调 warn 一次 + 非受控内部状态跨渲染保持):
187
218
 
188
219
  ```tsx
189
- const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => void }> = (_init, ctx) => {
190
- return (props) => {
220
+ const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => void }> = async (_init, ctx) => {
221
+ return async (props) => {
191
222
  const ctrl = ctx.ui.useControlled<boolean>({ value: props.active, onChange: props.onChange, name: 'CollapseItem' })
192
223
  return h('button', {
193
224
  onClick: () => ctrl.setValue(!(ctrl.value ?? false)), // 受控走 onChange;非受控内部状态
@@ -215,10 +246,10 @@ const CollapseItem: Component<{ active?: boolean; onChange?: (v: boolean) => voi
215
246
  > SSR 安全(shim 安全默认)+ 测试 mock 单点 + 环境差异隔离。
216
247
 
217
248
  ```tsx
218
- const MyComp: Component = (_init, ctx) => {
249
+ const MyComp: Component = async (_init, ctx) => {
219
250
  // mount 层取 browser(ctx.browser 优先,测试/无注入环境 fallback jsdom)
220
251
  const browser = ctx.browser ?? createClientBrowser()
221
- return (props) =>
252
+ return async (props) =>
222
253
  h('button', {
223
254
  onClick: () => {
224
255
  // 复制/查询/存储/滚动——全部经 browser
@@ -260,9 +291,8 @@ const MyComp: Component = (_init, ctx) => {
260
291
 
261
292
  ## 已知边界(诚实裁剪)
262
293
 
263
- - `usePopup` 覆盖浮层(Tooltip/Popover/Dropdown/Mentions/Cascader/ContextMenu);**全屏对话框**(Modal/Drawer)用 useDialog(Escape 语义留组件层);Command/Img preview 保持独立实现
294
+ - `usePopup` 是**统一弹窗能力层**:锚定浮层(Tooltip/Popover/Dropdown/Select/AutoComplete/Mentions/Cascader/ContextMenu/NavMenu/Popconfirm/TreeSelect)+ 会话级模态(Modal/Drawer/Confirm——presence/trapFocus/lockScroll/positioning 'none',Escape 语义留组件层)+ mask 模式(Command/Img preview/Tour——mask/maskCentered/自定义 mask VNode)+ focus 触发(DatePicker)+ positioning 'none' 常驻容器(Toast/Notification)——**全部弹窗单一入口**
264
295
  - **事件监听纪律**:组件库内部浏览器事件监听**统一走 `ctx.ui.useXXX`**——滚动/观察/弹层/对话框/快捷键/拖拽/DnD 全覆盖:
265
- `useInView`(InfiniteScroll)、`useScrollPosition`(AiChat/Affix/BackTop/VirtualList)、`usePopupPosition`(Affix 阈值重算)、`usePopup`(ContextMenu 自由定位)、`useDialog`(Modal/Drawer)、`useGlobalKey`(Command 快捷键/Img preview Escape)、`useDrag`(Resizable)、`useDragDrop`(FileUpload)、`useControlled`/`useStableRef`(状态/ref)
266
- - 唯一保留:**DatePicker 元素级 `animationend`**(入场动画完成后坐标 settle——与 usePopup panelRef/motion.animateOut 同款框架基础设施,非浏览器全局事件)
267
- - **Select/DatePicker** 是 inline/absolute 菜单(自适宽),不迁移 usePopup——菜单直接挂在锚点下
296
+ `useInView`(InfiniteScroll)、`useScrollPosition`(AiChat/Affix/BackTop/VirtualList)、`usePopupPosition`(Affix 阈值重算)、`usePopup`(弹窗统一——ContextMenu 自由定位 + Modal/Drawer 模态模式 + mask 遮罩)、`useGlobalKey`(Command 快捷键/Img preview Escape)、`useDrag`(Resizable)、`useDragDrop`(FileUpload)、`useControlled`/`useStableRef`(状态/ref)
297
+ - **唯一保留 usePopupPosition 独立使用**:Affix / Chart(坐标工具——非弹窗组合器,滚动跟随自动)
268
298
  - `createReactiveState` 已导出:组件外建全局 store(`createReactiveState(() => {})` + `$.__watch(cb)` 订阅)
package/docs/examples.md CHANGED
@@ -5,12 +5,11 @@
5
5
  ## 登录表单
6
6
 
7
7
  ```tsx
8
- const LoginPage = (_init, ctx) => {
9
- const $ = ctx.ui.$()
10
- $.errors = {}
11
- $.submitting = false
8
+ const LoginPage = async (_init, ctx) => {
9
+ let errors: Record<string, string> = {}
10
+ let submitting = false
12
11
 
13
- return (props) =>
12
+ return async (props) =>
14
13
  h('div', { class: 'wf-stack', style: { maxWidth: 400, margin: '40px auto' } },
15
14
  h(Card, { padding: 'lg' },
16
15
  h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
@@ -21,17 +20,19 @@ const LoginPage = (_init, ctx) => {
21
20
  password: [{ required: true, minLength: 6, message: '密码至少6位' }],
22
21
  },
23
22
  onSubmit: async (values) => {
24
- $.submitting = true
23
+ submitting = true
24
+ ctx.ui.render()
25
25
  await ctx.api?.post('/login', values) // api 客户端由中间件注入 ctx.api
26
- $.submitting = false
26
+ submitting = false
27
+ ctx.ui.render()
27
28
  },
28
- onError: (errors) => { $.errors = errors },
29
+ onError: (errs) => { errors = errs; ctx.ui.render() },
29
30
  }, [
30
- h(Field, { label: '邮箱', error: $.errors.email },
31
+ h(Field, { label: '邮箱', error: errors.email },
31
32
  h(Input, { name: 'email', type: 'email', placeholder: 'name@example.com' })),
32
- h(Field, { label: '密码', error: $.errors.password },
33
+ h(Field, { label: '密码', error: errors.password },
33
34
  h(Input, { name: 'password', type: 'password' })),
34
- h(Button, { type: 'submit', loading: $.submitting, block: true }, '登录'),
35
+ h(Button, { type: 'submit', loading: submitting, block: true }, '登录'),
35
36
  ])
36
37
  )
37
38
  )
@@ -42,20 +43,19 @@ const LoginPage = (_init, ctx) => {
42
43
  ## 数据列表 + 搜索
43
44
 
44
45
  ```tsx
45
- const UserList = (_init, ctx) => {
46
- const $ = ctx.ui.$()
47
- $.keyword = ''
48
- $.sortKey = 'name'
49
- $.sortOrder = 'asc'
46
+ const UserList = async (_init, ctx) => {
47
+ let keyword = ''
48
+ let sortKey = 'name'
49
+ let sortOrder = 'asc'
50
50
  const users = [
51
51
  { id: 1, name: '张三', email: 'zhang@example.com', role: '管理员' },
52
52
  { id: 2, name: '李四', email: 'li@example.com', role: '编辑' },
53
53
  ]
54
54
 
55
- return (props) => {
56
- // 派生数据必须在 render 内计算(每次 render 读最新 $.keyword)
55
+ return async (props) => {
56
+ // 派生数据必须在 render 内计算(每次 render 读最新 keyword)
57
57
  const filtered = users.filter(u =>
58
- !$.keyword || u.name.includes($.keyword) || u.email.includes($.keyword)
58
+ !keyword || u.name.includes(keyword) || u.email.includes(keyword)
59
59
  )
60
60
 
61
61
  return h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
@@ -85,39 +85,33 @@ const UserList = (_init, ctx) => {
85
85
  ## 消息提示
86
86
 
87
87
  ```tsx
88
- // 在任意组件中调用
89
- let toastId = 0
88
+ // 官方推荐:命令式中间件(app.use(toast()) → ctx.toast('消息', 'success'))
89
+ app.use(toast())
90
+ // 任意组件:ctx.toast?.('操作成功', 'success')
90
91
 
91
- function showToast(ctx: WfuiContext, type: ToastType, message: string) {
92
- // 通过 ctx 管理 Toast 列表
93
- const $ = ctx.ui.$()
94
- $.toasts = $.toasts ?? []
95
- const id = String(++toastId)
96
- $.toasts = [...$.toasts, { id, type, message }]
92
+ // 自管理列表(render-only:let + render())
93
+ let toasts: { id: string; type: string; message: string }[] = []
94
+ let toastId = 0
97
95
 
98
- // 自动消失
96
+ function showToast(ctx: WfuiContext, type: string, message: string) {
97
+ toasts = [...toasts, { id: String(++toastId), type, message }]
98
+ ctx.ui.render()
99
99
  if (type !== 'error') {
100
- setTimeout(() => {
101
- $.toasts = $.toasts.filter((t: any) => t.id !== id)
102
- }, 3000)
100
+ setTimeout(() => { toasts = toasts.filter((t: any) => t.id !== String(toastId)); ctx.ui.render() }, 3000)
103
101
  }
104
102
  }
105
103
 
106
104
  // 页面中使用
107
- const App = (_init, ctx) => {
108
- const $ = ctx.ui.$()
109
- $.toasts = []
110
-
111
- return (props) =>
105
+ const App = async (_init, ctx) => {
106
+ toasts = []
107
+ return async (props) =>
112
108
  h('div', {}, [
113
- h(Button, {
114
- onClick: () => showToast(ctx, 'success', '操作成功'),
115
- }, '显示提示'),
109
+ h(Button, { onClick: () => showToast(ctx, 'success', '操作成功') }, '显示提示'),
116
110
  h(Toast, {
117
- toasts: $.toasts,
111
+ toasts,
118
112
  position: 'top-right',
119
113
  max: 3,
120
- onRemove: (id) => { $.toasts = $.toasts.filter((t: any) => t.id !== id) },
114
+ onRemove: (id) => { toasts = toasts.filter((t: any) => t.id !== id); ctx.ui.render() },
121
115
  }),
122
116
  ])
123
117
  }
@@ -24,8 +24,7 @@ uiServe(app, { root: '#root' }) // 客户端落地(hydrate: true 收养 SS
24
24
 
25
25
  ```tsx
26
26
  const DashboardLayout: UIMiddleware = async (_loc, ctx, children) => {
27
- const $ = ctx.ui.$()
28
- $.open = true
27
+ let open = true
29
28
  return async (loc, c) => {
30
29
  const child = await children(loc, c) // 子路由/嵌套路由内容
31
30
  return h('div', { class: 'wf-row' },
@@ -132,8 +131,8 @@ app.use(auth())
132
131
  uiServe(app, { root: '#root' })
133
132
 
134
133
  // 在组件中
135
- function Profile(_props: {}, ctx: WfuiContext) {
136
- return (props) => {
134
+ async function Profile(_props: {}, ctx: WfuiContext) {
135
+ return async (props) => {
137
136
  if (!ctx.auth?.isLoggedIn) return <p>请登录</p>
138
137
  return <p>欢迎, {ctx.auth?.user?.name}</p>
139
138
  }