weifuwu 0.53.0 → 0.54.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
@@ -8,6 +8,11 @@ npm install weifuwu
8
8
 
9
9
  一个包 = 后端 (`weifuwu`) + 前端 (`weifuwu/client`) + 组件库 (`weifuwu/components`) + 布局系统 (`weifuwu/layout`)。
10
10
 
11
+ > ⚠️ **注意:前后端都有 `ctx.ui`,但用途完全不同**
12
+ > - **后端** `ctx.ui`(SSR/编译):`ctx.ui.html`(HTML 模板)、`ctx.ui.js`(TSX→JS 动态编译)、`ctx.ui.css`(CSS 编译)
13
+ > - **前端** `ctx.ui`(渲染引擎):`ctx.ui.$()`(响应式状态)、`ctx.ui.render()` / `dirty()`(渲染控制)、`useMedia()` / `useBreakpoint()` / `usePopupPosition()`(浏览器事件监听)
14
+ > 后端的是「把页面和代码交给浏览器」,前端的是「在浏览器里驱动 UI」。
15
+
11
16
  ---
12
17
 
13
18
  ## 设计理念
@@ -61,9 +66,13 @@ const Home: Component = () => () => <h1>Hello weifuwu</h1>
61
66
 
62
67
  createApp()
63
68
  .use(router({ routes: [{ path: '/', component: Home }] }))
64
- .mount('#root', () => <RouteView />)
69
+ .mount('#root', () => () => <RouteView />) // 根组件也要两阶段:外层返回 render 函数
65
70
  ```
66
71
 
72
+ 运行 `node server.ts`,访问 `http://localhost:3000` 即可看到页面。后端 `ctx.ui.js()` 会实时编译 `src/main.tsx`,改代码刷新即生效,无需任何构建步骤。
73
+
74
+ > 想**零后端、零构建**最快跑起来?直接跳到下面的「CDN 快速原型」。
75
+
67
76
  ---
68
77
 
69
78
  ## CDN 快速原型(零构建、纯 HTML)
@@ -165,9 +174,9 @@ createApp()
165
174
  | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
166
175
  | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
167
176
  | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
168
- | `weifuwu/client` | **confirm** | Promise 化确认对话框 | createApp |
169
177
  | `weifuwu/client` | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
170
- | `weifuwu/components` | **41 个组件** | Button/Table/Modal/Toast/... | weifuwu/client |
178
+ | `weifuwu/client` | **popup** | 弹层 fixed 定位工具(`computeFixedPos` / `computeFixedPosRect`) | |
179
+ | `weifuwu/components` | **42 个组件** | Button/Table/Modal/Confirm/Toast/... + `confirm()` / `toast()` 命令式中间件 | weifuwu/client |
171
180
  | `weifuwu/layout` | **CSS 布局** | 35 个布局原语 + 72 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
172
181
 
173
182
  ---
@@ -889,6 +898,20 @@ h('div', { class: 'x' }, child1, child2)
889
898
 
890
899
  ## 状态管理
891
900
 
901
+ ### ctx.ui 方法速查
902
+
903
+ | 方法 | 签名 | 一句话说明 |
904
+ |------|------|-----------|
905
+ | `$()` | `$(): Record<string, any>` | 深度 Proxy 响应式状态容器,赋值自动触发渲染(**推荐首选**) |
906
+ | `render()` | `render(ids?: string[])` | 同步强制渲染;无参 = 当前组件,传参 = 指定组件列表 |
907
+ | `dirty()` | `dirty(ids?: string[])` | 异步渲染(微任务批处理合并);`$` 内部就是调它 |
908
+ | `selfId()` | `selfId(name: string)` | 注册组件自定义 ID,配合 `render(['id'])` 跨组件精准刷新 |
909
+ | `useMedia()` | `useMedia(query, cb)` | 响应式媒体查询,断点变化时自动回调 |
910
+ | `useBreakpoint()` | `useBreakpoint(cb \| bps, cb?)` | 命名断点 mobile/tablet/desktop |
911
+ | `usePopupPosition()` | `usePopupPosition(opts)` | 弹层坐标跟随:scroll/resize 时自动重算 fixed 坐标 |
912
+
913
+ > 每个方法的完整说明见下文对应章节。
914
+
892
915
  ### Render 机制总览
893
916
 
894
917
  | API | 触发时机 | 渲染方式 | 作用域 | 使用场景 |
@@ -899,6 +922,7 @@ h('div', { class: 'x' }, child1, child2)
899
922
  | `ctx.ui.render(['id'])` | 主动调用 | 立即同步 | 指定组件 | **跨组件精准刷新** — 全局事件、Portal 远程控制 |
900
923
  | `ctx.ui.useMedia()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **响应式媒体查询** — 断点变化时自动 dirty |
901
924
  | `ctx.ui.useBreakpoint()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **命名断点** — mobile/tablet/desktop 自动 dirty |
925
+ | `ctx.ui.usePopupPosition()` | 注册监听 | 浏览器事件驱动 | 当前组件 | **弹层坐标跟随** — scroll/resize 时自动重算 fixed 坐标 |
902
926
 
903
927
  `render()` 和 `dirty()` 无参 = 当前组件,传参 = 指定组件列表。三套 API 同一 scope 机制。
904
928
 
@@ -996,6 +1020,73 @@ ctx.ui.useBreakpoint(
996
1020
  )
997
1021
  ```
998
1022
 
1023
+ #### `ctx.ui.usePopupPosition(options)` — 弹层坐标跟随
1024
+
1025
+ 解决弹出层(Popover / Tooltip / Dropdown / DatePicker 等)在 **页面滚动 / 窗口缩放后不跟随触发元素** 的问题。基于 `position: fixed` + `getBoundingClientRect()`(视口坐标)的弹层,滚动后坐标需要重算——本 API 用全局 scroll/resize 监听(rAF 节流)自动重算并精准刷新当前组件。
1026
+
1027
+ ```tsx
1028
+ const DatePicker = (_init, ctx) => {
1029
+ let show = false
1030
+ let inputEl: HTMLElement | null = null
1031
+ let prevOpen = false
1032
+
1033
+ // mount 阶段注册:scroll/resize 时自动重算 pos
1034
+ const pos = ctx.ui.usePopupPosition({
1035
+ el: () => inputEl, // 锚定元素(ref 保存)
1036
+ isOpen: () => show, // 弹层是否显示
1037
+ compute: (r) => ({ top: r.bottom + 4, left: r.left }), // rect → 坐标
1038
+ })
1039
+
1040
+ return (props) => {
1041
+ const isOpen = show
1042
+ // 打开瞬间算一次初始坐标(受控/非受控统一覆盖)
1043
+ if (isOpen && !prevOpen) pos.refresh()
1044
+ prevOpen = isOpen
1045
+
1046
+ return h('div', {}, [
1047
+ h('input', {
1048
+ ref: (el) => { inputEl = el as HTMLElement },
1049
+ onClick: () => { show = !show; ctx.ui.render() },
1050
+ }),
1051
+ isOpen ? h('div', { style: { top: pos.top, left: pos.left } }) : null,
1052
+ ].filter(Boolean))
1053
+ }
1054
+ }
1055
+ ```
1056
+
1057
+ 要点:
1058
+
1059
+ - `pos` 是稳定对象,render 闭包直接读取 `top/left/width`,滚动重算原地更新,无需重新绑定
1060
+ - `pos.refresh()` 只重算不渲染——配合打开路径上已有的 `render()`,避免重复渲染
1061
+ - 监听是**全局单例**(capture 捕获所有嵌套滚动容器 + rAF 节流),按组件 selfId 注册,组件多时开销 O(1)
1062
+ - `compute` 是纯函数(rect → 坐标),可单独单测
1063
+
1064
+ 已内置接入的组件:**Popover / Tooltip / Dropdown / DatePicker / Chart**(tooltip)——它们的弹出层在页面滚动、嵌套容器滚动、窗口缩放时都会自动跟随触发元素,无需额外配置。
1065
+
1066
+ #### `ctx.ui.selfId(name)` — 跨组件精准刷新
1067
+
1068
+ 用于全局事件通知、Portal 远程控制、兄弟组件协调等场景——绕过多层 props 传递,直接按 ID 刷新目标组件:
1069
+
1070
+ ```tsx
1071
+ // 组件 A:mount 阶段注册自定义 ID
1072
+ const StatsPanel = (_init, ctx) => {
1073
+ ctx.ui.selfId('stats')
1074
+ const $ = ctx.ui.$()
1075
+ $.data = []
1076
+ return (props) => h('div', {}, String($.data.length))
1077
+ }
1078
+
1079
+ // 组件 B(或其他任何地方)用 ID 精准刷新
1080
+ ctx.ui.render(['stats']) // 同步刷新
1081
+ // 或:ctx.ui.dirty(['stats']) // 异步批处理版本
1082
+ ```
1083
+
1084
+ **语义**:
1085
+
1086
+ - 必须在 **mount 阶段**调用(组件初始化时),注册后组件即可被 `render(['id'])` / `dirty(['id'])` 精准定位
1087
+ - **同名冲突直接抛错**,每个自定义 ID 必须全局唯一
1088
+ - 配合 `selfId` 注册的组件在跨组件场景下无需把刷新逻辑层层传 props
1089
+
999
1090
  #### CSS 层响应式(不碰 JS)
1000
1091
 
1001
1092
  配合 `weifuwu/layout` 的断点变体,纯 CSS 实现布局方向切换:
@@ -1023,6 +1114,8 @@ ctx.ui.useBreakpoint(
1023
1114
 
1024
1115
  异步版本,无参 = 当前组件,传参 = 指定组件列表。多次调用合并为一次微任务渲染。`$` 内部就是调 `dirty()`。
1025
1116
 
1117
+ 与 `render()` 的区别:`dirty()` 是**异步**(微任务批量合并,同帧多次调用只渲染一次),`render()` 是**同步**(立即执行 VDOM diff + patch)。日常 UI 状态用 `$` 或 `dirty()`,需要立即拿到最新 DOM(测量/动画/第三方库)时用 `render()`。
1118
+
1026
1119
  ### `ctx.ui.render()` — 同步强制渲染
1027
1120
 
1028
1121
  与 `dirty()` 的微任务批量不同,`render()` 是**同步执行**的。调用后立即执行 VDOM diff + patch,DOM 立刻更新。无参时只刷新当前组件,传参时可精准刷新指定组件。
@@ -1107,7 +1200,7 @@ const DatePicker = (_init, ctx) => {
1107
1200
 
1108
1201
  ```tsx
1109
1202
  const OrderPage = (_init, ctx) => {
1110
- const $ = ctx.ui.$
1203
+ const $ = ctx.ui.$()
1111
1204
  $.orders = [] // $ 赋值自动触发渲染
1112
1205
  $.loading = false
1113
1206
  return (props) => h('div', {}, $.loading ? h(Spinner) : h(OrderList, { orders: $.orders }))
@@ -1223,7 +1316,7 @@ createApp()
1223
1316
  mode: 'history', // 或 'hash'
1224
1317
  notFound: NotFoundPage,
1225
1318
  }))
1226
- .mount('#root', () => <RouteView />)
1319
+ .mount('#root', () => () => <RouteView />) // 根组件也要两阶段:外层返回 render 函数
1227
1320
  ```
1228
1321
 
1229
1322
  ### 嵌套布局
@@ -1241,7 +1334,7 @@ const routes = [
1241
1334
  ]
1242
1335
 
1243
1336
  function DashboardLayout(_props: {}, ctx: WfuiContext) {
1244
- return (
1337
+ return (props) => (
1245
1338
  <div style="display:flex">
1246
1339
  <aside>导航菜单</aside>
1247
1340
  <main><RouteView /></main> {/* 渲染子路由 */}
@@ -1348,8 +1441,10 @@ createApp()
1348
1441
 
1349
1442
  // 在组件中
1350
1443
  function Profile(_props: {}, ctx: WfuiContext) {
1351
- if (!ctx.auth?.isLoggedIn) return <p>请登录</p>
1352
- return <p>欢迎, {ctx.auth?.user?.name}</p>
1444
+ return (props) => {
1445
+ if (!ctx.auth?.isLoggedIn) return <p>请登录</p>
1446
+ return <p>欢迎, {ctx.auth?.user?.name}</p>
1447
+ }
1353
1448
  }
1354
1449
 
1355
1450
  // 登录
@@ -1510,14 +1605,19 @@ import { ErrorBoundary } from 'weifuwu/client'
1510
1605
 
1511
1606
  ## confirm — 确认对话框
1512
1607
 
1608
+ 两种用法,共享同一视觉与行为(基于 Modal 封装):
1609
+
1610
+ **① 命令式 `ctx.confirm()`(推荐,操作前询问)**
1611
+
1513
1612
  ```tsx
1514
- import { createApp, confirm } from 'weifuwu/client'
1613
+ import { createApp } from 'weifuwu/client'
1614
+ import { confirm } from 'weifuwu/components'
1515
1615
 
1516
1616
  createApp()
1517
1617
  .use(confirm())
1518
1618
  .mount('#root', App)
1519
1619
 
1520
- // 在组件中使用
1620
+ // 任意代码中(组件事件、async 逻辑)
1521
1621
  async function handleDelete(ctx: WfuiContext) {
1522
1622
  const ok = await ctx.confirm?.('确定删除这条记录?', {
1523
1623
  title: '确认删除',
@@ -1531,17 +1631,63 @@ async function handleDelete(ctx: WfuiContext) {
1531
1631
  }
1532
1632
  ```
1533
1633
 
1634
+ **② 声明式 `<Confirm>`(需要受控状态时)**
1635
+
1636
+ ```tsx
1637
+ import { Confirm } from 'weifuwu/components'
1638
+
1639
+ <Confirm
1640
+ open={confirming}
1641
+ title="确认删除"
1642
+ message="确定删除这条记录?"
1643
+ confirmText="删除"
1644
+ variant="danger"
1645
+ onConfirm={() => doDelete()}
1646
+ onCancel={() => setConfirming(false)}
1647
+ />
1648
+ ```
1649
+
1534
1650
  | ConfirmOptions | 类型 | 默认值 | 说明 |
1535
1651
  |----------------|------|--------|------|
1536
1652
  | `title` | `string` | `'确认操作'` | 对话框标题 |
1537
1653
  | `confirmText` | `string` | `'确定'` | 确认按钮文字 |
1538
1654
  | `cancelText` | `string` | `'取消'` | 取消按钮文字 |
1539
1655
  | `variant` | `'primary' \| 'danger'` | `'primary'` | 按钮样式变体 |
1656
+ | `width` | `string` | Modal 默认 | 对话框宽度 |
1540
1657
 
1541
- - 直接 DOM 渲染(不经过 VDOM)
1542
- - 返回 `Promise<boolean>`
1543
- - ESC / 点击遮罩 → resolve(false)
1544
- - 自动锁定背景滚动
1658
+ - `ctx.confirm()` 返回 `Promise<boolean>`,ESC / 点击遮罩 / 取消 → resolve(false)
1659
+ - 组件化渲染(Modal + portal),自动锁定滚动 + 焦点陷阱,i18n 文案可配置
1660
+ - 多次调用各自独立渲染(叠放语义),互不干扰
1661
+
1662
+ ---
1663
+
1664
+ ## toast — 命令式消息提示
1665
+
1666
+ `ctx.toast()` 是 `<Toast>` 组件的全局命令式封装:任意代码中一行调用,自动消失、自动清理,无需宿主状态。
1667
+
1668
+ ```tsx
1669
+ import { createApp } from 'weifuwu/client'
1670
+ import { toast } from 'weifuwu/components'
1671
+
1672
+ createApp()
1673
+ .use(toast({ position: 'top-right', duration: 3000, max: 3 }))
1674
+ .mount('#root', App)
1675
+
1676
+ // 任意代码中(组件事件、api 拦截器、WS 回调、定时器)
1677
+ ctx.toast?.('保存成功', 'success')
1678
+ ctx.toast?.('请求失败', 'error')
1679
+ ctx.toast?.('普通消息') // 默认 type = 'info'
1680
+ ```
1681
+
1682
+ | ToastOptions | 类型 | 默认值 | 说明 |
1683
+ |-------------|------|--------|------|
1684
+ | `position` | `ToastPosition` | `'top-right'` | 容器位置 |
1685
+ | `duration` | `number` | `3000` | 默认自动消失时间(ms),0 = 不消失 |
1686
+ | `max` | `number` | `3` | 最大显示条数,超出移除最早 |
1687
+
1688
+ 单条可覆盖自动消失时间:`ctx.toast('慢一点消失', 'info', 5000)`。
1689
+
1690
+ 与声明式 `<Toast toasts={...}/>` 共存:声明式用于局部列表(合并消息、自定义布局),命令式用于全局一次性反馈。
1545
1691
 
1546
1692
  ---
1547
1693
 
@@ -1591,7 +1737,9 @@ import type { ApiClient, ApiOptions, ApiRequestOptions, ApiError } from 'weifuwu
1591
1737
  import type { AuthClient, AuthOptions } from 'weifuwu/client'
1592
1738
  import type { ErrorBoundaryProps } from 'weifuwu/client'
1593
1739
  import type { I18nOptions, I18nState, LocalePackage } from 'weifuwu/client'
1594
- import type { ConfirmOptions, ConfirmState } from 'weifuwu/client'
1740
+ import type { PopupPositionOptions, PopupPosition } from 'weifuwu/client'
1741
+ import type { ConfirmProps, ConfirmOptions } from 'weifuwu/components'
1742
+ import type { ToastOptions, ToastPosition } from 'weifuwu/components'
1595
1743
  import type { RouterOptions } from 'weifuwu/client'
1596
1744
  ```
1597
1745
 
@@ -1600,7 +1748,7 @@ import type { RouterOptions } from 'weifuwu/client'
1600
1748
  | `VNode` | `{ type, props, key? }` |
1601
1749
  | `VNodeType` | `string \| Component \| typeof Fragment` |
1602
1750
  | `Component<P>` | `(initProps: P, ctx: WfuiContext) => (props: P) => VNode \| null` |
1603
- | `WfuiContext` | `{ ui, route?, app?, ws?, api?, auth?, i18n?, confirm?, [key]: unknown }` |
1751
+ | `WfuiContext` | `{ ui, route?, app?, ws?, api?, auth?, i18n?, confirm?, toast?, [key]: unknown }` |
1604
1752
  | `AppMiddleware` | `(ctx: WfuiContext) => WfuiContext` |
1605
1753
  | `RouteDef` | `{ path, component?, layout?, children?, auth?, title? }` |
1606
1754
  | `ApiClient` | `{ get, post, put, patch, delete }` |
@@ -1609,13 +1757,17 @@ import type { RouterOptions } from 'weifuwu/client'
1609
1757
  | `I18nOptions` | `{ locale?, messages?, components? }` |
1610
1758
  | `I18nState` | `{ locale, t, setLocale, components }` |
1611
1759
  | `ErrorBoundaryProps` | `{ fallback?, children? }` |
1612
- | `ConfirmOptions` | `{ title?, confirmText?, cancelText?, variant? }` |
1760
+ | `ConfirmProps` | `{ open?, title?, message?, confirmText?, cancelText?, variant?, width?, onConfirm?, onCancel? }` |
1761
+ | `ConfirmOptions` | `{ title?, confirmText?, cancelText?, variant?, width? }` — 命令式 ctx.confirm 选项 |
1762
+ | `ToastOptions` | `{ position?, duration?, max? }` — 命令式 ctx.toast 配置 |
1763
+ | `PopupPositionOptions` | `{ el, isOpen, compute }` — 弹层位置跟踪配置(见 usePopupPosition) |
1764
+ | `PopupPosition` | `{ top, left, width?, refresh }` — 弹层位置跟踪器 |
1613
1765
 
1614
1766
  ---
1615
1767
 
1616
1768
  # 组件库 (`weifuwu/components`)
1617
1769
 
1618
- 41 个 HTML 原语组件。每个是 `(props, ctx) => VNode` 纯函数,引用 `--wf-*` CSS 变量做主题。
1770
+ 42 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
1619
1771
 
1620
1772
  ```ts
1621
1773
  import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
@@ -1632,8 +1784,9 @@ import 'weifuwu/components/style.css' // 包含 Token + 35 布局原语 + 组
1632
1784
 
1633
1785
  // ├─ 输入框
1634
1786
  <Input placeholder="请输入邮箱" />
1635
- <Input label="用户名" required error="必填" />
1636
- <Input type="password" hint="至少6位" prefix="🔒" />
1787
+ <Input label="用户名" name="username" required error="必填" />
1788
+ <Input type="password" hint="至少6位" />
1789
+ <Input name="email" type="email" disabled placeholder="name@example.com" />
1637
1790
 
1638
1791
  // ├─ 选择器
1639
1792
  <Select options={[{ value: 'a', label: '选项A' }]} placeholder="请选择" />
@@ -1648,10 +1801,12 @@ import 'weifuwu/components/style.css' // 包含 Token + 35 布局原语 + 组
1648
1801
  <Table columns={[{ key: 'id', label: 'ID', sortable: true }, { key: 'name', label: '名称' }]}
1649
1802
  data={rows} sortKey="id" sortOrder="asc" onSort={(k, o) => setSort(k, o)} />
1650
1803
 
1651
- // ├─ 模态框 / 抽屉
1804
+ // ├─ 模态框 / 确认框 / 抽屉
1652
1805
  <Modal open={show} title="提示" onClose={() => setShow(false)} width="500px" closable>
1653
1806
  <p>确认删除?</p>
1654
1807
  </Modal>
1808
+ <Confirm open={confirming} message="确定删除?" variant="danger" onConfirm={doDelete} onCancel={() => setConfirming(false)} />
1809
+ // 命令式:await ctx.confirm?.('确定删除?') —— 组件里直接调用
1655
1810
  <Drawer open={open} title="详情" onClose={() => setOpen(false)} position="right">内容</Drawer>
1656
1811
 
1657
1812
  // ├─ 消息提示
@@ -1695,7 +1850,7 @@ import 'weifuwu/components/style.css' // 包含 Token + 35 布局原语 + 组
1695
1850
 
1696
1851
  // ├─ 表单验证
1697
1852
  <Form validation={{ email: [{ required: true, message: '请输入邮箱' }] }}
1698
- onSubmit={values => api.post('/login', values)}
1853
+ onSubmit={values => ctx.api?.post('/login', values)} // ctx.api 由中间件注入
1699
1854
  onError={errors => setErrors(errors)}>
1700
1855
  <Field label="邮箱" error={errors.email}>
1701
1856
  <Input name="email" />
@@ -1751,7 +1906,7 @@ props 变化 ──────────────────────
1751
1906
  | 组件 | 导入名 | 关键 Props | 说明 |
1752
1907
  |-----|--------|-----------|------|
1753
1908
  | Button | `Button` | `variant`, `size`, `loading`, `disabled`, `block`, `type` | 按钮 |
1754
- | Input | `Input` | `variant`, `size`, `placeholder`, `disabled`, `error`, `prefix`, `suffix` | 输入框 |
1909
+ | Input | `Input` | `label`, `name`, `type`, `value`, `placeholder`, `required`, `disabled`, `error`, `hint`, `onInput`, `onChange` | 输入框 |
1755
1910
  | Textarea | `Textarea` | `rows`, `resize`, `maxLength`, `error` | 文本域 |
1756
1911
  | Select | `Select` | `options: SelectOption[]`, `placeholder`, `searchable` | 下拉选择 |
1757
1912
 
@@ -1793,14 +1948,15 @@ props 变化 ──────────────────────
1793
1948
  | 组件 | 导入名 | 关键 Props | 说明 |
1794
1949
  |-----|--------|-----------|------|
1795
1950
  | Modal | `Modal` | `open`, `title`, `onClose`, `width`, `footer`, `closable` | 模态框 |
1951
+ | Confirm | `Confirm` | `open`, `message`, `confirmText`, `cancelText`, `variant`, `onConfirm`, `onCancel` | 确认对话框(同 `ctx.confirm()` 命令式) |
1796
1952
  | Drawer | `Drawer` | `open`, `title`, `onClose`, `position: DrawerPosition`, `width` | 抽屉 |
1797
- | Tooltip | `Tooltip` | `content`, `position: TooltipPosition`, `trigger` | 工具提示 |
1798
- | Popover | `Popover` | `content`, `position: PopoverPosition`, `trigger` | 弹出层 |
1953
+ | Tooltip | `Tooltip` | `content`, `position: TooltipPosition`, `disabled` | 工具提示(hover/focus 触发) |
1954
+ | Popover | `Popover` | `content`, `position: PopoverPosition`, `trigger`, `open`, `onOpenChange`, `disabled` | 弹出层 |
1799
1955
  | Toast | `Toast` | `items: ToastItem[]`, `position`, `max` | 消息提示 |
1800
1956
  | Alert | `Alert` | `variant: AlertVariant`, `title`, `closable`, `icon` | 警告提示 |
1801
1957
  | Loading | `Loading` | `size`, `text`, `fullscreen` | 加载中 |
1802
1958
  | EmptyState | `EmptyState` | `title`, `description`, `action`, `icon` | 空状态 |
1803
- | Skeleton | `Skeleton` | `variant: SkeletonVariant`, `rows`, `width`, `height` | 骨架屏 |
1959
+ | Skeleton | `Skeleton` | `variant: SkeletonVariant`, `lines`, `cols`, `width`, `height` | 骨架屏 |
1804
1960
 
1805
1961
  ### 导航组件
1806
1962
 
@@ -1808,7 +1964,7 @@ props 变化 ──────────────────────
1808
1964
  |-----|--------|-----------|------|
1809
1965
  | Breadcrumb | `Breadcrumb` | `items: BreadcrumbItem[]` | 面包屑 |
1810
1966
  | Tabs | `Tabs` | `items: TabItem[]`, `activeKey`, `onChange`, `type` | 标签页 |
1811
- | Dropdown | `Dropdown` | `items: DropdownItem[]`, `trigger`, `placement` | 下拉菜单 |
1967
+ | Dropdown | `Dropdown` | `trigger`, `items: DropdownItem[]`, `open` | 下拉菜单 |
1812
1968
  | Pagination | `Pagination` | `total`, `page`, `pageSize`, `onChange` | 分页 |
1813
1969
  | Steps | `Steps` | `items: StepItem[]`, `current`, `direction`, `size` | 步骤条 |
1814
1970
  | Accordion | `Accordion` | `items: AccordionItem[]`, `multiple`, `defaultActive` | 手风琴 |
@@ -1818,7 +1974,7 @@ props 变化 ──────────────────────
1818
1974
  | 组件 | 导入名 | 关键 Props | 说明 |
1819
1975
  |-----|--------|-----------|------|
1820
1976
  | Chart | `Chart` | `type: ChartType`, `data`, `options`, `title`, `area` | SVG 图表(line/bar/pie)|
1821
- | DatePicker | `DatePicker` | `mode: DatePickerMode`, `value`, `onChange`, `placeholder` | 日期选择器(date/datetime/time/range)|
1977
+ | DatePicker | `DatePicker` | `mode: DatePickerMode`, `value`, `onChange`, `placeholder`, `disabled` | 日期选择器(date/datetime/time/range)|
1822
1978
  | Editor | `Editor` | `value`, `onChange`, `toolbar`, `placeholder`, `disabled` | 富文本编辑器,零依赖 |
1823
1979
 
1824
1980
  ### 布局
@@ -2038,7 +2194,7 @@ const LoginPage = (_init, ctx) => {
2038
2194
  },
2039
2195
  onSubmit: async (values) => {
2040
2196
  $.submitting = true
2041
- await api.post('/login', values)
2197
+ await ctx.api?.post('/login', values) // api 客户端由中间件注入 ctx.api
2042
2198
  $.submitting = false
2043
2199
  },
2044
2200
  onError: (errors) => { $.errors = errors },
@@ -2068,12 +2224,13 @@ const UserList = (_init, ctx) => {
2068
2224
  { id: 2, name: '李四', email: 'li@example.com', role: '编辑' },
2069
2225
  ]
2070
2226
 
2071
- const filtered = users.filter(u =>
2072
- !$.keyword || u.name.includes($.keyword) || u.email.includes($.keyword)
2073
- )
2227
+ return (props) => {
2228
+ // 派生数据必须在 render 内计算(每次 render 读最新 $.keyword
2229
+ const filtered = users.filter(u =>
2230
+ !$.keyword || u.name.includes($.keyword) || u.email.includes($.keyword)
2231
+ )
2074
2232
 
2075
- return (props) =>
2076
- h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
2233
+ return h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
2077
2234
  h('div', { class: 'wf-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
2078
2235
  h(SearchInput, { placeholder: '搜索用户...', value: $.keyword, onSearch: (v: string) => { $.keyword = v } }),
2079
2236
  h(Button, { variant: 'primary' }, '新建用户'),
@@ -2093,6 +2250,7 @@ const UserList = (_init, ctx) => {
2093
2250
  }),
2094
2251
  h(Pagination, { total: filtered.length, page: 1, pageSize: 10, onChange: (p: number) => {} }),
2095
2252
  )
2253
+ }
2096
2254
  }
2097
2255
  ```
2098
2256
 
@@ -35,7 +35,5 @@ export { lockScroll, unlockScroll } from './scroll-lock.ts';
35
35
  export { trapFocus } from './focus-trap.ts';
36
36
  export { computeFixedPos } from './popup.ts';
37
37
  export type { FixedPos, Placement } from './popup.ts';
38
- export { confirm } from './confirm.ts';
39
- export type { ConfirmOptions, ConfirmState } from './confirm.ts';
40
38
  export { zhCN } from './locale/zh_CN.ts';
41
39
  export { enUS } from './locale/en_US.ts';
@@ -625,6 +625,25 @@ function createApp() {
625
625
  let _dirtyBatch = /* @__PURE__ */ new Set();
626
626
  let _dirtyScheduled = false;
627
627
  const _mediaRegistry = /* @__PURE__ */ new Map();
628
+ const _popupTrackers = /* @__PURE__ */ new Map();
629
+ let _popupListenersReady = false;
630
+ let _popupRaf = 0;
631
+ function schedulePopupRecompute() {
632
+ if (_popupRaf) return;
633
+ _popupRaf = requestAnimationFrame(() => {
634
+ _popupRaf = 0;
635
+ const ids = [];
636
+ for (const [id, t] of _popupTrackers) {
637
+ if (!t.isOpen()) continue;
638
+ const el = t.getEl();
639
+ if (!el) continue;
640
+ const p = t.compute(el.getBoundingClientRect());
641
+ Object.assign(t.pos, p);
642
+ ids.push(id);
643
+ }
644
+ if (ids.length > 0) ctx.ui.render(ids);
645
+ });
646
+ }
628
647
  function renderByIds(ids) {
629
648
  if (_rendering) return;
630
649
  _rendering = true;
@@ -805,6 +824,43 @@ function createApp() {
805
824
  _mediaRegistry.set(key, { mql: null, handler: null });
806
825
  }
807
826
  },
827
+ /**
828
+ * 弹层位置跟踪:滚动/resize 时自动重算 fixed 坐标
829
+ *
830
+ * 用法(mount 阶段):
831
+ * const pos = ctx.ui.usePopupPosition({
832
+ * el: () => inputEl, // ref 保存的锚定元素
833
+ * isOpen: () => show, // 弹层是否显示
834
+ * compute: (r) => ({ top: r.bottom + 4, left: r.left }),
835
+ * })
836
+ *
837
+ * pos 是稳定对象,render 闭包直接读取 top/left;
838
+ * 滚动/resize 时自动重算并定向刷新;打开弹层瞬间调用 pos.refresh()。
839
+ */
840
+ usePopupPosition: function(options) {
841
+ const selfId = getSelfId(this);
842
+ const pos = { top: 0, left: 0, refresh: () => {
843
+ } };
844
+ if (!selfId) return pos;
845
+ const tracker = {
846
+ pos,
847
+ getEl: options.el,
848
+ isOpen: options.isOpen,
849
+ compute: options.compute
850
+ };
851
+ _popupTrackers.set(selfId, tracker);
852
+ if (!_popupListenersReady) {
853
+ _popupListenersReady = true;
854
+ window.addEventListener("scroll", schedulePopupRecompute, { capture: true, passive: true });
855
+ window.addEventListener("resize", schedulePopupRecompute);
856
+ }
857
+ pos.refresh = () => {
858
+ const el2 = tracker.getEl();
859
+ if (!el2) return;
860
+ Object.assign(pos, tracker.compute(el2.getBoundingClientRect()));
861
+ };
862
+ return pos;
863
+ },
808
864
  /** 注册组件实例的自定义 ID(用于跨组件精准刷新) */
809
865
  selfId: function(name) {
810
866
  if (typeof name !== "string" || !name) {
@@ -837,6 +893,12 @@ function createApp() {
837
893
  if (container) container.innerHTML = "";
838
894
  container = null;
839
895
  ctx = {};
896
+ if (_popupListenersReady) {
897
+ window.removeEventListener("scroll", schedulePopupRecompute, { capture: true });
898
+ window.removeEventListener("resize", schedulePopupRecompute);
899
+ _popupListenersReady = false;
900
+ _popupTrackers.clear();
901
+ }
840
902
  }
841
903
  };
842
904
  return app;
@@ -1298,6 +1360,7 @@ var zhCN = {
1298
1360
  Switch: { ariaLabel: "\u5207\u6362" },
1299
1361
  Breadcrumb: { ariaLabel: "\u9762\u5305\u5C51" },
1300
1362
  Modal: { ariaLabel: "\u5F39\u7A97" },
1363
+ Confirm: { confirmText: "\u786E\u5B9A", cancelText: "\u53D6\u6D88" },
1301
1364
  Drawer: { ariaLabel: "\u4FA7\u8FB9\u9762\u677F" },
1302
1365
  DatePicker: {
1303
1366
  w0: "\u65E5",
@@ -1332,6 +1395,7 @@ var enUS = {
1332
1395
  Switch: { ariaLabel: "Toggle" },
1333
1396
  Breadcrumb: { ariaLabel: "Breadcrumb" },
1334
1397
  Modal: { ariaLabel: "Dialog" },
1398
+ Confirm: { confirmText: "OK", cancelText: "Cancel" },
1335
1399
  Drawer: { ariaLabel: "Panel" },
1336
1400
  DatePicker: {
1337
1401
  w0: "Su",
@@ -1476,8 +1540,7 @@ function trapFocus(container) {
1476
1540
  }
1477
1541
 
1478
1542
  // src/client/popup.ts
1479
- function computeFixedPos(el, placement = "bottom", gap = 6, center = true) {
1480
- const rect = el.getBoundingClientRect();
1543
+ function computeFixedPosRect(rect, placement = "bottom", gap = 6, center = true) {
1481
1544
  switch (placement) {
1482
1545
  case "bottom":
1483
1546
  return {
@@ -1501,77 +1564,8 @@ function computeFixedPos(el, placement = "bottom", gap = 6, center = true) {
1501
1564
  };
1502
1565
  }
1503
1566
  }
1504
-
1505
- // src/client/confirm.ts
1506
- function createConfirmModal(message, options) {
1507
- return new Promise((resolve) => {
1508
- const {
1509
- title = "\u786E\u8BA4\u64CD\u4F5C",
1510
- confirmText = "\u786E\u5B9A",
1511
- cancelText = "\u53D6\u6D88",
1512
- variant = "primary"
1513
- } = options;
1514
- const overlay = document.createElement("div");
1515
- overlay.className = "wf-modal";
1516
- overlay.style.cssText = "position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center";
1517
- const bg = document.createElement("div");
1518
- bg.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:-1";
1519
- const box = document.createElement("div");
1520
- box.style.cssText = `background:var(--wf-color-bg,#fff);border-radius:var(--wf-radius-md,8px);box-shadow:var(--wf-shadow-lg,0 4px 24px rgba(0,0,0,0.12));min-width:360px;max-width:90vw;z-index:1`;
1521
- if (title) {
1522
- const header = document.createElement("div");
1523
- header.style.cssText = "display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--wf-color-border,#e5e7eb);font-family:var(--wf-font-sans);font-size:var(--wf-font-size-lg);font-weight:var(--wf-font-weight-semibold);color:var(--wf-color-text);line-height:1.4";
1524
- header.textContent = title;
1525
- box.appendChild(header);
1526
- }
1527
- const body = document.createElement("div");
1528
- body.style.cssText = "padding:20px;font-family:var(--wf-font-sans);font-size:var(--wf-font-size-sm);color:var(--wf-color-text);line-height:var(--wf-line-height-normal)";
1529
- body.textContent = message;
1530
- box.appendChild(body);
1531
- const footer = document.createElement("div");
1532
- footer.style.cssText = "display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--wf-color-border,#e5e7eb)";
1533
- const cancelBtn = document.createElement("button");
1534
- cancelBtn.className = "wf-btn wf-btn--secondary wf-btn--md";
1535
- cancelBtn.textContent = cancelText;
1536
- const confirmBtn = document.createElement("button");
1537
- confirmBtn.className = `wf-btn wf-btn--${variant} wf-btn--md`;
1538
- confirmBtn.textContent = confirmText;
1539
- footer.appendChild(cancelBtn);
1540
- footer.appendChild(confirmBtn);
1541
- box.appendChild(footer);
1542
- overlay.appendChild(bg);
1543
- overlay.appendChild(box);
1544
- document.body.appendChild(overlay);
1545
- lockScroll();
1546
- const close = (result) => {
1547
- unlockScroll();
1548
- overlay.remove();
1549
- resolve(result);
1550
- };
1551
- const onKeyDown = (e) => {
1552
- if (e.key === "Escape") close(false);
1553
- };
1554
- document.addEventListener("keydown", onKeyDown);
1555
- cancelBtn.onclick = () => {
1556
- document.removeEventListener("keydown", onKeyDown);
1557
- close(false);
1558
- };
1559
- confirmBtn.onclick = () => {
1560
- document.removeEventListener("keydown", onKeyDown);
1561
- close(true);
1562
- };
1563
- bg.onclick = () => {
1564
- document.removeEventListener("keydown", onKeyDown);
1565
- close(false);
1566
- };
1567
- });
1568
- }
1569
- function confirm() {
1570
- return (ctx) => {
1571
- ;
1572
- ctx.confirm = (message, options) => createConfirmModal(message, options ?? {});
1573
- return ctx;
1574
- };
1567
+ function computeFixedPos(el, placement = "bottom", gap = 6, center = true) {
1568
+ return computeFixedPosRect(el.getBoundingClientRect(), placement, gap, center);
1575
1569
  }
1576
1570
  export {
1577
1571
  ApiError,
@@ -1582,7 +1576,6 @@ export {
1582
1576
  api,
1583
1577
  auth,
1584
1578
  computeFixedPos,
1585
- confirm,
1586
1579
  createApp,
1587
1580
  createPortal,
1588
1581
  enUS,
@@ -27,6 +27,10 @@ export declare const enUS: {
27
27
  Modal: {
28
28
  ariaLabel: string;
29
29
  };
30
+ Confirm: {
31
+ confirmText: string;
32
+ cancelText: string;
33
+ };
30
34
  Drawer: {
31
35
  ariaLabel: string;
32
36
  };
@@ -27,6 +27,10 @@ export declare const zhCN: {
27
27
  Modal: {
28
28
  ariaLabel: string;
29
29
  };
30
+ Confirm: {
31
+ confirmText: string;
32
+ cancelText: string;
33
+ };
30
34
  Drawer: {
31
35
  ariaLabel: string;
32
36
  };