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 +192 -34
- package/dist/client/index.d.ts +0 -2
- package/dist/client/index.js +67 -74
- package/dist/client/locale/en_US.d.ts +4 -0
- package/dist/client/locale/zh_CN.d.ts +4 -0
- package/dist/client/popup.d.ts +12 -0
- package/dist/client/types.d.ts +23 -0
- package/dist/client/vnode.d.ts +3 -0
- package/dist/components/Confirm/Confirm.d.ts +37 -0
- package/dist/components/Input/Input.d.ts +1 -0
- package/dist/components/Skeleton/Skeleton.d.ts +1 -0
- package/dist/components/Toast/Toast.d.ts +10 -0
- package/dist/components/index.d.ts +4 -2
- package/dist/components/index.js +444 -46
- package/package.json +1 -1
- package/dist/client/confirm.d.ts +0 -26
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/
|
|
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
|
-
|
|
1352
|
-
|
|
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
|
|
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
|
-
-
|
|
1542
|
-
-
|
|
1543
|
-
-
|
|
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 {
|
|
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
|
-
| `
|
|
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
|
-
|
|
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位"
|
|
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
|
|
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` | `
|
|
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`, `
|
|
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`, `
|
|
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[]`, `
|
|
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
|
|
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
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|