weifuwu 0.63.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -2956
- package/dist/ai/client.d.ts +1 -1
- package/dist/ai/sse.d.ts +1 -1
- package/dist/ai/types.d.ts +2 -2
- package/dist/client/ai.d.ts +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/types.d.ts +108 -0
- package/dist/client/use-chat.d.ts +1 -1
- package/dist/components/AiChat/AiChat.d.ts +2 -0
- package/dist/components/ContextMenu/ContextMenu.d.ts +1 -1
- package/dist/components/Dropdown/Dropdown.d.ts +4 -1
- package/dist/components/HoverCard/HoverCard.d.ts +7 -1
- package/dist/components/Popover/Popover.d.ts +3 -0
- package/dist/components/Tooltip/Tooltip.d.ts +3 -0
- package/dist/components/index.js +13 -13
- package/dist/components/style.css +164 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/layout/weifuwu-layout.css +22 -0
- package/docs/components.md +314 -0
- package/docs/data.md +240 -0
- package/docs/environment.md +27 -0
- package/docs/examples.md +127 -0
- package/docs/frontend-middleware.md +435 -0
- package/docs/frontend.md +697 -0
- package/docs/layout.md +203 -0
- package/docs/mobile.md +109 -0
- package/docs/realtime.md +291 -0
- package/docs/saas.md +246 -0
- package/docs/server.md +356 -0
- package/docs/styling.md +130 -0
- package/package.json +4 -3
package/docs/layout.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# 布局系统(weifuwu/layout)
|
|
2
|
+
|
|
3
|
+
> 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
|
|
4
|
+
|
|
5
|
+
纯 CSS 布局原语 + 工具类 + 141 个主题 Token。不绑定任何 JS 框架。
|
|
6
|
+
|
|
7
|
+
> **学习路径与命名规范**:见 [`design/style-guide.md`](../design/style-guide.md)——统一语法 `wf-<域>-<名>`、三档学习(组件 → 10 核心原语 → 完整速查)、场景速查、变量定制。
|
|
8
|
+
|
|
9
|
+
> **全栈 weifuwu 项目**:`weifuwu/components/style.css` 已包含布局系统,一条 import 就够了,无需单独引用本页。
|
|
10
|
+
> 本页仅适用于**非 weifuwu 项目**或**只需 CSS 布局**的场景。
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<link rel="stylesheet" href="/node_modules/weifuwu/layout">
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
或在 weifuwu 服务端通过 `ctx.ui.css` 直接引用包名(`ctx.ui.css` 自动解析 exports map):
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// 方案 A:组件 + 布局全部搞定(推荐)
|
|
20
|
+
app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css'))
|
|
21
|
+
|
|
22
|
+
// 方案 B:只用布局
|
|
23
|
+
app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
也支持相对路径:`ctx.ui.css('./src/style.css')`。
|
|
27
|
+
|
|
28
|
+
## 70 个布局原语
|
|
29
|
+
|
|
30
|
+
| 类别 | 原语 | 效果 |
|
|
31
|
+
|------|------|------|
|
|
32
|
+
| **排列** | `wf-stack` `wf-stack@sm/md/lg` | 纵向 flex + gap(断点变体→横向) |
|
|
33
|
+
| | `wf-stack-reverse` `@sm/md/lg` | 纵向反向 |
|
|
34
|
+
| | `wf-row` `wf-row@sm/md/lg` | 横向 flex + wrap + gap |
|
|
35
|
+
| | `wf-row-reverse` `@sm/md/lg` | 横向反向 |
|
|
36
|
+
| | `wf-nowrap` | flex-wrap: nowrap |
|
|
37
|
+
| | `wf-cluster` | 换行居中簇 |
|
|
38
|
+
| **分布** | `wf-split` | justify-content: space-between |
|
|
39
|
+
| | `wf-center` | 双轴居中 |
|
|
40
|
+
| | `wf-right` | justify-content: flex-end |
|
|
41
|
+
| | `wf-around` | space-around |
|
|
42
|
+
| | `wf-evenly` | space-evenly |
|
|
43
|
+
| **对齐** | `wf-top` | align-items: flex-start |
|
|
44
|
+
| | `wf-bottom` | align-items: flex-end |
|
|
45
|
+
| | `wf-stretch` | align-items: stretch |
|
|
46
|
+
| **弹性** | `wf-fill` | flex: 1 + min-width: 0 |
|
|
47
|
+
| | `wf-fixed` | flex: none |
|
|
48
|
+
| | `wf-auto` | flex: auto |
|
|
49
|
+
| | `wf-shrink` | min-width/height: 0 |
|
|
50
|
+
| **Z轴** | `wf-cover` | position: fixed + inset: 0 |
|
|
51
|
+
| | `wf-pop` | position: absolute |
|
|
52
|
+
| | `wf-anchor` | position: relative |
|
|
53
|
+
| | `wf-layer` | position: relative + z-index |
|
|
54
|
+
| | `wf-sticky` | position: sticky |
|
|
55
|
+
| | `wf-popup` | 浮层基类:宽度视口 clamp(`min(var(--wf-popup-max, 480px), calc(100vw - 32px))`) |
|
|
56
|
+
| **安全区** | `wf-safe-bottom` / `wf-safe-top` | iOS 刘海屏/Home 条:`padding: env(safe-area-inset-*)` |
|
|
57
|
+
| **容器** | `wf-surface` | 基础面(border-radius + shadow + bg) |
|
|
58
|
+
| | `wf-grid` | display: grid + --wf-cols |
|
|
59
|
+
| | `wf-container` | max-width + margin: auto |
|
|
60
|
+
| | `wf-scroll` | overflow: auto |
|
|
61
|
+
| | `wf-clip` | overflow: hidden |
|
|
62
|
+
| **显隐** | `wf-hidden` `wf-hidden@sm/md/lg` | display: none |
|
|
63
|
+
| | `wf-block` `wf-block@sm/md/lg` | display: block |
|
|
64
|
+
| | `wf-inline` | display: inline |
|
|
65
|
+
| | `wf-inline-block` | display: inline-block |
|
|
66
|
+
| | `wf-contents` | display: contents |
|
|
67
|
+
| **间距** | `wf-p-*` / `wf-px-*` / `wf-py-*`(xs~2xl) | padding:全/水平/垂直,引用 `--wf-space-*` |
|
|
68
|
+
| | `wf-mt-*` / `wf-mb-*` / `wf-my-*`(xs~2xl) | margin:top/bottom/垂直 |
|
|
69
|
+
| | `wf-mx-auto` / `wf-my-auto` | margin: auto 居中 |
|
|
70
|
+
| | `wf-gap-*`(xs~2xl) | 为 flex/grid 原语设置 `--wf-gap` |
|
|
71
|
+
| **尺寸** | `wf-w-full` / `wf-h-full` / `wf-w-auto` | 宽/高 100%、auto |
|
|
72
|
+
| **边框** | `wf-border` / `wf-border-t/b/l/r` | 1px 边框(`--wf-border-width` + `--wf-color-border`) |
|
|
73
|
+
| **面工具** | `wf-bg-secondary/tertiary/brand/success/warning/error/info` | 语义背景色(`--wf-color-*-bg`) |
|
|
74
|
+
| | `wf-pill` | 胶囊圆角(999px,状态徽章/标签/色块) |
|
|
75
|
+
| | `wf-rounded-sm` `wf-rounded` `wf-rounded-md` `wf-rounded-lg` | 圆角工具(`--wf-radius-*`) |
|
|
76
|
+
| **气泡** | `wf-bubble` / `wf-bubble--own` / `wf-bubble--ai` | 聊天气泡(pre-wrap + 折行内建) |
|
|
77
|
+
| **打印** | `wf-print-hidden` / `wf-print-block` | 导出 PDF 时隐藏工具区 / 恢复块级 |
|
|
78
|
+
| **行高** | `wf-leading-tight` `wf-leading-base` `wf-leading-relaxed` | line-height(`--wf-line-height-*`) |
|
|
79
|
+
| **指针** | `wf-pointer` / `wf-not-allowed` | cursor: pointer / not-allowed |
|
|
80
|
+
| **内容排版** | `wf-prose` | 富文本正文(文章/博客/文档,一个类包 h2/p/ul/blockquote/pre…) |
|
|
81
|
+
| **外壳** | `wf-app-shell` | 应用外壳:侧边栏 + 主区 grid(`--wf-sidebar-width`) |
|
|
82
|
+
| | `wf-sidebar` `wf-sidebar-header` `wf-sidebar-body` `wf-sidebar-footer` | 侧边栏:品牌区/导航区/底部用户区,sticky 全高 |
|
|
83
|
+
| | `wf-nav` `wf-nav-group` `wf-nav-item` `wf-nav-icon` | 导航:分组标题 + 链接项(`--active` 激活态) |
|
|
84
|
+
| | `wf-main` | 主内容区(padding + min-width: 0) |
|
|
85
|
+
| | `wf-text-*` 排版工具 | 见下文「排版工具」 |
|
|
86
|
+
|
|
87
|
+
### 排版工具(`wf-text-*`)
|
|
88
|
+
|
|
89
|
+
| 工具 | 效果 |
|
|
90
|
+
|------|------|
|
|
91
|
+
| `wf-text-left/center/right` | text-align |
|
|
92
|
+
| `wf-text-xs…5xl` | 字号(`--wf-font-size-*`) |
|
|
93
|
+
| `wf-text-secondary/tertiary/disabled/brand` | 中性色阶 |
|
|
94
|
+
| `wf-text-success/warning/error/info` | 语义色文本(`--wf-color-*`) |
|
|
95
|
+
| `wf-text-medium/semibold/bold` | 字重 |
|
|
96
|
+
| `wf-tracking-normal/wide/wider` | letter-spacing |
|
|
97
|
+
| `wf-uppercase/lowercase/capitalize` | text-transform |
|
|
98
|
+
| `wf-pre-wrap` | white-space: pre-wrap + word-break(聊天气泡/代码) |
|
|
99
|
+
| `wf-break-word` | overflow-wrap + word-break |
|
|
100
|
+
| `wf-text-nowrap` | white-space: nowrap |
|
|
101
|
+
| `wf-truncate` | 单行省略(ellipsis) |
|
|
102
|
+
| `wf-line-clamp-2/3` | 多行截断 |
|
|
103
|
+
|
|
104
|
+
## 141 个主题 Token
|
|
105
|
+
|
|
106
|
+
**双层结构**:原始层(Primitive,色值只定义一次,品牌/暗色调校改这里)+ 语义层(Semantic,组件消费)。
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
/* ── 原始层 — 品牌/中性色值 + 暗色值,主题定制改这一层 ── */
|
|
110
|
+
--wf-brand-500 / --wf-brand-600 / --wf-brand-50 /* 品牌主色/悬停/浅底 */
|
|
111
|
+
--wf-slate-900…50 / --wf-white /* 中性阶 */
|
|
112
|
+
--wf-dark-* /* 暗色值(暗色模式经间接层引用,零硬编码) */
|
|
113
|
+
|
|
114
|
+
/* ── 语义层 — 组件消费,暗色/主题切换覆盖这里 ── */
|
|
115
|
+
/* 品牌色 */
|
|
116
|
+
--wf-color-primary / --wf-color-primary-hover / --wf-color-primary-bg
|
|
117
|
+
|
|
118
|
+
/* 语义色 */
|
|
119
|
+
--wf-color-success / --wf-color-success-bg
|
|
120
|
+
--wf-color-warning / --wf-color-warning-bg
|
|
121
|
+
--wf-color-error / --wf-color-error-bg
|
|
122
|
+
--wf-color-info / --wf-color-info-bg
|
|
123
|
+
|
|
124
|
+
/* 语义文字色(P2):浅底可读 700 级,文字用 -text、填充用 500 级 */
|
|
125
|
+
--wf-color-primary-text / --wf-color-success-text / --wf-color-warning-text / --wf-color-error-text / --wf-color-info-text
|
|
126
|
+
--wf-color-on-brand /* 实心品牌/语义底上的文字与图标 */
|
|
127
|
+
--wf-overlay /* 浮层遮罩(Modal/Drawer),暗色自动加深 */
|
|
128
|
+
|
|
129
|
+
/* 文字色 */
|
|
130
|
+
--wf-color-text / --wf-color-text-secondary / --wf-color-text-tertiary / --wf-color-text-disabled
|
|
131
|
+
|
|
132
|
+
/* 背景色 */
|
|
133
|
+
--wf-color-bg / --wf-color-bg-secondary / --wf-color-bg-tertiary
|
|
134
|
+
|
|
135
|
+
/* 边框色 */
|
|
136
|
+
--wf-color-border / --wf-color-border-light / --wf-color-border-dark
|
|
137
|
+
|
|
138
|
+
/* 字体 */
|
|
139
|
+
--wf-font-sans / --wf-font-mono
|
|
140
|
+
|
|
141
|
+
/* 字号: xs sm base lg xl 2xl 3xl 4xl 5xl display */
|
|
142
|
+
--wf-font-size-*
|
|
143
|
+
|
|
144
|
+
/* 字重: normal medium semibold bold */
|
|
145
|
+
--wf-font-weight-*
|
|
146
|
+
|
|
147
|
+
/* 行高: tight normal relaxed */
|
|
148
|
+
--wf-line-height-*
|
|
149
|
+
|
|
150
|
+
/* 字距: normal wide wider */
|
|
151
|
+
--wf-letter-spacing-*
|
|
152
|
+
|
|
153
|
+
/* 间距: xs sm md lg xl 2xl */
|
|
154
|
+
--wf-space-*
|
|
155
|
+
|
|
156
|
+
/* 间隔: xs sm md lg xl 2xl */
|
|
157
|
+
--wf-gap-*
|
|
158
|
+
|
|
159
|
+
/* 圆角: sm md lg xl */
|
|
160
|
+
--wf-radius-*
|
|
161
|
+
|
|
162
|
+
/* 阴影: sm md lg */
|
|
163
|
+
--wf-shadow-*
|
|
164
|
+
|
|
165
|
+
/* 动效(P0):时长阶梯/缓动曲线/位移量,全站动效统一引用 */
|
|
166
|
+
--wf-dur-fast / --wf-dur-base / --wf-dur-slow
|
|
167
|
+
--wf-ease-out / --wf-ease-in / --wf-ease-snap
|
|
168
|
+
--wf-motion-sm / --wf-motion-md / --wf-motion-lg
|
|
169
|
+
|
|
170
|
+
/* 表头/分组标题(P5):CJK 感知,默认 none/0,英文可覆盖 */
|
|
171
|
+
--wf-heading-case / --wf-heading-tracking
|
|
172
|
+
|
|
173
|
+
/* 数字(P5):tabular-nums 防宽度抖动(wf-nums 工具类) */
|
|
174
|
+
--wf-nums
|
|
175
|
+
|
|
176
|
+
/* 其他 */
|
|
177
|
+
--wf-border-width / --wf-focus-ring
|
|
178
|
+
--wf-transition-duration / --wf-transition-timing
|
|
179
|
+
--wf-accent-color / --wf-caret-color
|
|
180
|
+
--wf-opacity-disabled / --wf-opacity-overlay
|
|
181
|
+
--wf-pop-z / --wf-cover-z
|
|
182
|
+
|
|
183
|
+
/* 应用外壳 */
|
|
184
|
+
--wf-sidebar-width
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 暗色模式
|
|
188
|
+
|
|
189
|
+
两种激活方式(显式 `data-theme` 优先级更高):
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// 1. 手动切换
|
|
193
|
+
// document.documentElement.setAttribute('data-theme', 'dark')
|
|
194
|
+
// document.documentElement.setAttribute('data-theme', 'light') // 强制亮色
|
|
195
|
+
|
|
196
|
+
// 2. 自动:系统暗色偏好(无需任何代码)
|
|
197
|
+
// 系统为暗色时自动生效;加 data-theme="light" 可强制亮色
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
暗色值定义在原始层 `--wf-dark-*`(只写一次),`_dark.css` 两段仅做语义映射——改暗色调校只动原始层,无硬编码色值。
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
package/docs/mobile.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# 移动端开发指南(weifuwu)
|
|
2
|
+
|
|
3
|
+
> 移动端友好由框架构造保证:**用对原语 + 守 audit 规则**,不靠每个组件"记得"。
|
|
4
|
+
> 落地依据:`design/mobile-support-plan.md`(P0/P1 已完成,P2 进行中)。
|
|
5
|
+
|
|
6
|
+
## 一、断点体系(布局原语)
|
|
7
|
+
|
|
8
|
+
移动优先,640/768/1024 三个断点(与 `useBreakpoint` 的 mobile/tablet/desktop 对齐):
|
|
9
|
+
|
|
10
|
+
| 断点 | 类前缀 | 语义 |
|
|
11
|
+
|------|--------|------|
|
|
12
|
+
| ≥640px | `@sm` | 小屏以上 |
|
|
13
|
+
| ≥768px | `@md` | 平板以上 |
|
|
14
|
+
| ≥1024px | `@lg` | 桌面 |
|
|
15
|
+
|
|
16
|
+
```html
|
|
17
|
+
<div class="wf-stack wf-stack@md">…</div> <!-- 移动纵向堆叠,md 起横向 -->
|
|
18
|
+
<div class="wf-hidden@sm">…</div> <!-- 移动端隐藏 -->
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- 组件级移动适配用 `@media (max-width: 639px)`(bottom-sheet/全宽抽屉)或 `(max-width: 767px)`(双栏堆叠)
|
|
22
|
+
- 触屏判定用 `@media (pointer: coarse)`(44px 命中区),**不要**用 width 猜触屏
|
|
23
|
+
|
|
24
|
+
## 二、命中区纪律(44px,Apple HIG / WCAG 2.5.8)
|
|
25
|
+
|
|
26
|
+
- **button/input/select 由全局 coarse 规则自动覆盖**(`_base.css`)
|
|
27
|
+
- **非 button 交互元素**(div/label/span 带 onClick)必须进 coarse 44px 清单——style-audit 规则强制,新组件漏了测试红
|
|
28
|
+
- **小组件**(圆点/星/勾选框)视觉尺寸不变,用 `::after` 扩展命中区(参考 Carousel dot):
|
|
29
|
+
|
|
30
|
+
```css
|
|
31
|
+
@media (pointer: coarse) {
|
|
32
|
+
.wf-carousel-dot {
|
|
33
|
+
position: relative;
|
|
34
|
+
}
|
|
35
|
+
.wf-carousel-dot::after {
|
|
36
|
+
content: '';
|
|
37
|
+
position: absolute;
|
|
38
|
+
inset: -14px;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- 小按钮(显式 `min-height` 覆盖了全局)需在组件 CSS 补 coarse 44px(参考 Pagination/Transfer/Calendar nav)
|
|
44
|
+
|
|
45
|
+
## 三、弹层(usePopup 组合器)
|
|
46
|
+
|
|
47
|
+
**弹层组件必须用 `ctx.ui.usePopup`**——移动端友好由构造保证:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const popup = ctx.ui.usePopup({
|
|
51
|
+
trigger: 'hover', // 触屏自动降级 tap(内部 matchMedia '(hover: hover)')
|
|
52
|
+
placement: () => latestPos, // 支持 getter 动态读 props
|
|
53
|
+
el: () => wrapEl, // 锚定元素 ref
|
|
54
|
+
isOpen: () => $.open,
|
|
55
|
+
setOpen: (v) => { $.open = v; ctx.ui.render() },
|
|
56
|
+
open: controlled ? () => latestOpen : undefined, // 受控桥(mount 期决定模式)
|
|
57
|
+
onOpenChange: (v) => latestOnOpenChange?.(v),
|
|
58
|
+
width: 320, // 自动 clamp 视口(≤100vw-32px)
|
|
59
|
+
disabled: () => disabled,
|
|
60
|
+
openDelay: () => delay, // hover 延迟(HoverCard)
|
|
61
|
+
})
|
|
62
|
+
// popup.wrapProps — 触发 + Escape + focus,spread 到包装元素
|
|
63
|
+
// popup.portal(content, portalKey) — 定位 + clamp + portal(挂载 #__wf_portal)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**内置行为**:hover 桌面 / tap 触屏 / Escape(document 级,portal 内也可关)/ 外部点击 / 视口 clamp / 宽度 clamp。
|
|
67
|
+
|
|
68
|
+
**边界**:Modal/Drawer 全屏对话框不进 usePopup(focus-trap/scroll-lock 各自实现)。
|
|
69
|
+
|
|
70
|
+
## 四、手势原语
|
|
71
|
+
|
|
72
|
+
| 原语 | 用法 | 场景 |
|
|
73
|
+
|------|------|------|
|
|
74
|
+
| `ctx.ui.useLongPress({ onLongPress, duration })` | spread 到目标 | ContextMenu 触屏长按(已内置双通道) |
|
|
75
|
+
| `ctx.ui.useVisualViewport()` | 返回 `{ height, offsetTop, keyboardOpen }` 响应式 | fixed 底部栏被键盘遮挡(AiChat `raiseOnKeyboard`) |
|
|
76
|
+
| `ctx.ui.useBreakpoint()` / `useMedia()` | 断点回调 | JS 侧响应式 |
|
|
77
|
+
| `ctx.ui.useHoverCapable()` | boolean | 自定义 hover/tap 双模式 |
|
|
78
|
+
|
|
79
|
+
## 五、safe-area(刘海屏/Home 条)
|
|
80
|
+
|
|
81
|
+
```html
|
|
82
|
+
<div class="wf-safe-bottom">…</div> <!-- padding-bottom: env(safe-area-inset-bottom) -->
|
|
83
|
+
<div class="wf-safe-top">…</div>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
底部抽屉/Modal bottom-sheet 已内置(P2)。
|
|
87
|
+
|
|
88
|
+
## 六、防横向溢出(375px 验收基线)
|
|
89
|
+
|
|
90
|
+
- 弹层宽度:usePopup 自动 `max-width: min(…, calc(100vw - 32px))`;手动浮层加 `.wf-popup` 基类
|
|
91
|
+
- 宽内容(表格/双栏):容器 `overflow-x: auto`(Table/Resizable 已内置)
|
|
92
|
+
- 网格:`minmax(min(100%, 420px), 1fr)`(防 minmax 固定值撑破窄屏)
|
|
93
|
+
- 验收:375×667 视口 `document.documentElement.scrollWidth <= innerWidth`
|
|
94
|
+
|
|
95
|
+
## 七、验证清单
|
|
96
|
+
|
|
97
|
+
1. 375px 视口无横向溢出
|
|
98
|
+
2. 交互元素 tap 可达(44px 命中区)
|
|
99
|
+
3. hover 组件 tap 可开、点外部可关、Escape 可关
|
|
100
|
+
4. ContextMenu 长按触发
|
|
101
|
+
5. 键盘弹起不遮挡输入(fixed 底部栏)
|
|
102
|
+
6. 桌面视口行为不变(hover 仍 hover)
|
|
103
|
+
7. style-audit 18 规则全绿 + `npm test` ≤15s
|
|
104
|
+
|
|
105
|
+
## 八、已知裁剪/例外(诚实声明)
|
|
106
|
+
|
|
107
|
+
- Rate 星 / Tree 勾选 / Slider 手柄:视觉小标记,命中区依赖行级容器(不做元素级 44px,避免破坏视觉)
|
|
108
|
+
- ⌘K 类快捷键在移动端无键盘语义(Command 需显式触发按钮)
|
|
109
|
+
- `raiseOnKeyboard` 默认 false:内联 chat 靠原生聚焦滚动;全屏 chat 布局才抬升
|
package/docs/realtime.md
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# 实时与渲染 — scheduler / ui / graphql / WebSocket(weifuwu)
|
|
2
|
+
|
|
3
|
+
> 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
|
|
4
|
+
|
|
5
|
+
## scheduler — 计划任务(即时/延时/cron)
|
|
6
|
+
|
|
7
|
+
> 依赖 `queue`(触发后入队执行)。三类任务:即时(queue.add 已有)、延时(`ctx.schedule`)、定时(`ctx.cron`)。
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { queue, scheduler } from 'weifuwu'
|
|
11
|
+
|
|
12
|
+
const q = queue()
|
|
13
|
+
app.use(q)
|
|
14
|
+
app.use(scheduler({ queue: q })) // 依赖 ctx.queue(触发后入队)
|
|
15
|
+
|
|
16
|
+
// 延时任务(单次):delayMs 或指定时间
|
|
17
|
+
await ctx.schedule('email.send', { to, body }, { delayMs: 30_000 })
|
|
18
|
+
await ctx.schedule('report.build', {}, { when: new Date('2026-09-01T00:00:00Z') })
|
|
19
|
+
|
|
20
|
+
// cron 定时任务(重复):每分钟触发 → 入队执行
|
|
21
|
+
ctx.cron('* * * * *', 'heartbeat.check', { scope: 'health' })
|
|
22
|
+
// 改需求 = 重新注册(同 name 覆盖更新,旧定义不残留)
|
|
23
|
+
ctx.cron('*/5 * * * *', 'heartbeat.check', { scope: 'health' })
|
|
24
|
+
// 停用 = cancel(删定义 + 清理 pending 触发点)
|
|
25
|
+
await ctx.cancelCron('heartbeat.check')
|
|
26
|
+
|
|
27
|
+
// 执行端:与 queue 完全一致
|
|
28
|
+
const worker = ctx.queue.worker('email.send', async (job) => { ... })
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **延时**:ZSET(score=触发时间戳)+ 守护循环(独立连接)→ 到期 `ZREM` 原子抢占(多实例不重复)→ `queue.add`
|
|
32
|
+
- **多应用隔离**:`scheduler({ prefix })`——ZSET/HASH 应用级共享,多应用共用 redis 时必须各自 prefix(同应用多实例共享 prefix = 协作消费)
|
|
33
|
+
- **cron**:HASH 注册表(**field = name,同 name 重新注册 = 覆盖更新**,改表达式不残留旧定义)+ 滚动生成触发点(`ZADD NX` 幂等)→ 复用延时链路;`nextRunAt` 原子推进
|
|
34
|
+
- **取消**:`ctx.cancelCron(name)` 删定义 + 清理 pending 触发点(停用 cron 必须 cancel——定义无 TTL 会累积)
|
|
35
|
+
- **崩溃恢复**:未消费触发点留在 ZSET,重启后补扫立即触发(at-least-once,幂等由业务保证)
|
|
36
|
+
- **cron 表达式**:5 字段(分 时 日 月 周),支持 `*`/步进/列表/范围;时区 = 服务器本地;非法表达式注册即抛错
|
|
37
|
+
- **裁剪**:❌ cron 秒/年/别名(@daily)/特殊字符(L/W/#)、时区配置、单次任务取消(v2)、分布式锁(原子命令抢占替代)
|
|
38
|
+
- **文档红线**:cron 定义持久化在 HASH——进程重启后守护循环恢复即继续触发(无需重新注册);**停用必须 `cancelCron`**(定义无 TTL,不取消会永久触发)
|
|
39
|
+
|
|
40
|
+
## ui — SSR 渲染 + JS/CSS 编译
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { ui } from 'weifuwu'
|
|
44
|
+
|
|
45
|
+
app.use(ui())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| ctx 注入 | 签名 | 说明 |
|
|
49
|
+
|----------|------|------|
|
|
50
|
+
| `ctx.ui.html` | `` (strings, ...values) => Response `` | HTML 模板 (转义防 XSS) |
|
|
51
|
+
| `ctx.ui.html.unsafe(str)` | `(string) => string` | 插入原始 HTML |
|
|
52
|
+
| `ctx.ui.js(entryPath)` | `(string) => Promise<Response>` | esbuild 编译 TSX → JS bundle |
|
|
53
|
+
| `ctx.ui.css(entryPath)` | `(string) => Promise<Response>` | 读取 CSS 文件 → CSS Response(如安装 postcss + @tailwindcss/postcss 则自动编译) |
|
|
54
|
+
| `ctx.ui.ssr(Comp, props?, { data })` | `(Component, props, opts?) => Promise<string>` | 服务端渲染组件 → HTML 片段(async 工厂自动 await;HtmlSafe 内联不二次转义) |
|
|
55
|
+
| `ctx.ui.ssrData(data)` | `(Map) => string` | 序列化 SSR 数据 → `<script>window.__DATA__=...</script>`(`<` 转义防 XSS) |
|
|
56
|
+
|
|
57
|
+
### ctx.ui.html — HTML 模板
|
|
58
|
+
|
|
59
|
+
模板插值自动转义(`& < > "` → 实体),防 XSS:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
app.get('/page', (req, ctx) => ctx.ui.html`
|
|
63
|
+
<h1>${title}</h1> <!-- 自动转义 -->
|
|
64
|
+
<div>${ctx.ui.html.unsafe(richHtml)}</div> <!-- 不转义 -->
|
|
65
|
+
`)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### ctx.ui.js — 编译 TSX → JS
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
app.get('/app.js', (req, ctx) => ctx.ui.js('./src/main.tsx')) // 相对路径
|
|
72
|
+
app.get('/app.js', (req, ctx) => ctx.ui.js('weifuwu/client')) // 或包名
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
使用 esbuild 编译:
|
|
76
|
+
- `bundle: true`, `format: 'esm'`, `platform: 'browser'`
|
|
77
|
+
- `jsx: 'automatic'`, `jsxImportSource: 'weifuwu/client'`
|
|
78
|
+
- 带 mtime 缓存验证(开发时编辑文件后自动失效)
|
|
79
|
+
|
|
80
|
+
### ctx.ui.css — CSS 编译
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
app.get('/style.css', (req, ctx) => ctx.ui.css('./src/style.css')) // 相对路径
|
|
84
|
+
app.get('/style.css', (req, ctx) => ctx.ui.css('weifuwu/components/style.css')) // 或包名
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- 无编译工具时直接返回原始 CSS
|
|
88
|
+
- 检测到已安装 `postcss` + `@tailwindcss/postcss` 时自动编译 Tailwind CSS
|
|
89
|
+
- 支持包名(`weifuwu/layout/style.css`, `weifuwu/components/style.css`)或文件路径
|
|
90
|
+
- 带 mtime 缓存验证(开发时编辑文件后自动失效)
|
|
91
|
+
|
|
92
|
+
### ctx.ui.ssr — SSR 渲染组件 → HTML
|
|
93
|
+
|
|
94
|
+
将组件(含 async 工厂组件)在服务端渲染为完整 HTML 片段,数据经 `ctx.data` 预取并序列化进 `window.__DATA__`(客户端 hydration 时同步命中,不重跑请求):
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const BlogPage = asyncComponent(async (ctx) => {
|
|
98
|
+
const post = await ctx.data.get(`/api/posts/${ctx.params.slug}`, fetchPost)
|
|
99
|
+
return (_init, ctx) => () =>
|
|
100
|
+
h('article', {},
|
|
101
|
+
h('h1', {}, post.title),
|
|
102
|
+
h('div', { innerHTML: post.body }),
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
app.get('/blog/:slug', async (req, ctx) => {
|
|
107
|
+
const data = new Map()
|
|
108
|
+
const html = await ctx.ui.ssr(BlogPage, {}, { data }) // HtmlSafe:模板内联不二次转义
|
|
109
|
+
return ctx.ui.html`
|
|
110
|
+
<!DOCTYPE html>
|
|
111
|
+
<html><body>
|
|
112
|
+
<div id="root">${html}</div>
|
|
113
|
+
${ctx.ui.ssrData(data)}
|
|
114
|
+
<script src="/static/app.js"></script>
|
|
115
|
+
</body></html>
|
|
116
|
+
`
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- 事件处理器/ref 剥离,文本自动转义(XSS),`class`/`style` 对象序列化,`innerHTML` 原样输出
|
|
121
|
+
- Fragment/Portal 子节点就地内联
|
|
122
|
+
- `ctx.ui.ssrData(data)` 输出 `<script>window.__DATA__=...</script>`(JSON `<` 转义防 XSS)
|
|
123
|
+
- 服务端 ctx shim:`$`(dirty no-op)、`ctx.data` 预取去重、`selfId` 请求级隔离
|
|
124
|
+
|
|
125
|
+
### Hydration — 客户端收养服务端 HTML
|
|
126
|
+
|
|
127
|
+
服务端 HTML + `window.__DATA__`(ctx.data 种子)到达客户端后,`mount(..., { hydrate: true })` **收养现有 DOM**(不重建、不闪跳),只接线事件/ref/$:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { createApp } from 'weifuwu/client'
|
|
131
|
+
|
|
132
|
+
createApp()
|
|
133
|
+
.mount('#root', BlogPage, { hydrate: true }) // 容器已有服务端 HTML
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- **游标收养**:元素/文本按位置匹配现有 DOM;tag 不匹配 → 局部替换;文本不一致 → 就地修正;服务端多余节点 → 收尾清理
|
|
137
|
+
- **async 工厂 hydration**:工厂 `ctx.data.get` 从 `__DATA__` 同步命中(不重跑请求)→ 渲染与服务端一致 → 收养
|
|
138
|
+
- hydration 后 `$`/dirty/事件全量可用(与纯 SPA 无差别)
|
|
139
|
+
- 诚实裁剪:Portal 内容就地收养(不移动到 `#__wf_portal`);渲染期非确定性(Date/random)会导致 mismatch(dev 警告)
|
|
140
|
+
|
|
141
|
+
### uiSsr — 路由级 SSR(声明即渲染)
|
|
142
|
+
|
|
143
|
+
共享路由定义,前后端同一份声明——后端匹配即自动 SSR,无需手写 handler/模板/序列化:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
// routes.tsx —— 前后端共用
|
|
147
|
+
import type { RouteDef } from 'weifuwu/client'
|
|
148
|
+
import { BlogPage } from './pages/BlogPage.tsx'
|
|
149
|
+
|
|
150
|
+
export const routes: RouteDef[] = [
|
|
151
|
+
{ path: '/blog/:slug', component: BlogPage, title: '博客' },
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
// server.ts —— 一行中间件:GET 匹配 → 注入 ctx.route.params → await 组件工厂 → 完整 HTML + __DATA__ + bundle
|
|
155
|
+
import { uiSsr } from 'weifuwu'
|
|
156
|
+
app.use(uiSsr({ routes, bundle: '/static/blog.js' }))
|
|
157
|
+
|
|
158
|
+
// blog-hydrate.ts —— 客户端:同一份 routes,router() 注入 ctx.route.params(两端同源)
|
|
159
|
+
createApp()
|
|
160
|
+
.use(router({ routes }))
|
|
161
|
+
.mount('#root', routes[0].component, { hydrate: true })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- 组件工厂读 `ctx.route.params`(`/blog/:slug` → `ctx.route.params.slug`)——后端 uiSsr / 前端 router **同源注入**
|
|
165
|
+
- 未匹配 → next()(交给 API/静态/404);非 GET → next()
|
|
166
|
+
- 可自定义 `title` / `template`
|
|
167
|
+
|
|
168
|
+
### weifuwu/dev — 服务端直接跑 .tsx
|
|
169
|
+
|
|
170
|
+
Node 原生 TS 只剥离类型(不支持 JSX)。`weifuwu/dev` 注册 esbuild loader,服务端直接跑 `.tsx`(零构建):
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"scripts": {
|
|
175
|
+
"dev": "node --import weifuwu/dev server.ts",
|
|
176
|
+
"start": "node --import weifuwu/dev server.ts"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- 前后端同一 JSX 运行时(`jsxImportSource: weifuwu/client`)→ 两端 VNode 一致 → hydration 可靠
|
|
182
|
+
- 与 `ctx.ui.js` 前端动态编译同一理念:无构建、无产物、改代码即生效
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## graphql — GraphQL 端点
|
|
187
|
+
|
|
188
|
+
> **SDL + resolvers 绑定为自研实现**(`makeExecutableSchema`,56 行替代 @graphql-tools/schema)——支持根类型与嵌套类型字段 resolver、默认属性查找。
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import type { GraphQLHandler } from 'weifuwu'
|
|
192
|
+
|
|
193
|
+
const handler: GraphQLHandler = async (req, ctx) => ({
|
|
194
|
+
schema: `
|
|
195
|
+
type Query {
|
|
196
|
+
hello: String
|
|
197
|
+
users: [User]
|
|
198
|
+
}
|
|
199
|
+
type User { id: ID, name: String }
|
|
200
|
+
`,
|
|
201
|
+
resolvers: {
|
|
202
|
+
Query: {
|
|
203
|
+
hello: () => 'world',
|
|
204
|
+
users: () => [{ id: 1, name: 'Alice' }],
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
rootValue: {},
|
|
208
|
+
context: (req, ctx) => ({ user: ctx.user }),
|
|
209
|
+
graphiql: true,
|
|
210
|
+
maxDepth: 10,
|
|
211
|
+
timeout: 30000,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// 挂载到 /
|
|
215
|
+
app.graphql(handler)
|
|
216
|
+
|
|
217
|
+
// 或挂载到自定义路径
|
|
218
|
+
app.graphql('/graphql', handler)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
| 选项 | 类型 | 默认值 | 说明 |
|
|
222
|
+
|------|------|--------|------|
|
|
223
|
+
| `schema` | `string \| GraphQLSchema` | — | SDL 字符串或 Schema 对象 |
|
|
224
|
+
| `resolvers` | `any` | — | 解析器(schema 为字符串时必填)|
|
|
225
|
+
| `rootValue` | `any` | — | 根值 |
|
|
226
|
+
| `context` | `(req, ctx) => object` | — | 上下文工厂 |
|
|
227
|
+
| `graphiql` | `boolean` | — | 启用 GraphiQL IDE |
|
|
228
|
+
| `maxDepth` | `number` | `10` | 查询深度限制(0=关闭)|
|
|
229
|
+
| `timeout` | `number` | `30000` | 执行超时(ms,0=关闭)|
|
|
230
|
+
|
|
231
|
+
GET 请求支持 query 参数查询;POST 支持 JSON body。启用 `graphiql: true` 时,GET 无 `?query=` 参数返回 GraphiQL IDE 页面。
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## WebSocket
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
app.ws('/chat/:room', {
|
|
239
|
+
open(ws, ctx) {
|
|
240
|
+
ws.send(`欢迎加入 ${ctx.params.room} 房间`)
|
|
241
|
+
ctx.hub?.join(ctx.params.room, ws)
|
|
242
|
+
},
|
|
243
|
+
message(ws, ctx, data) {
|
|
244
|
+
// data: string | Buffer
|
|
245
|
+
ctx.hub?.send(ctx.params.room, `用户: ${data}`)
|
|
246
|
+
},
|
|
247
|
+
close(ws, ctx) {
|
|
248
|
+
ctx.hub?.leave(ws)
|
|
249
|
+
},
|
|
250
|
+
error(ws, ctx, error) {
|
|
251
|
+
console.error('WS error:', error)
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| 回调 | 参数 | 说明 |
|
|
257
|
+
|------|------|------|
|
|
258
|
+
| `open(ws, ctx)` | `WebSocket`, `Context` | 连接建立 |
|
|
259
|
+
| `message(ws, ctx, data)` | `WebSocket`, `Context`, `string \| Buffer` | 收到消息 |
|
|
260
|
+
| `close(ws, ctx)` | `WebSocket`, `Context` | 连接关闭 |
|
|
261
|
+
| `error(ws, ctx, error)` | `WebSocket`, `Context`, `Error` | 错误 |
|
|
262
|
+
|
|
263
|
+
### Hub — WebSocket 房间
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// 注入 hub → ctx.hub
|
|
267
|
+
app.ws('/chat/:room', {
|
|
268
|
+
open(ws, ctx) { ctx.hub.join(ctx.params.room, ws) },
|
|
269
|
+
message(ws, ctx, data) { ctx.hub.send(ctx.params.room, String(data)) },
|
|
270
|
+
close(ws, ctx) { ctx.hub.leave(ws) },
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
// 自定义 Hub(Redis 后端)
|
|
274
|
+
import type { Hub } from 'weifuwu'
|
|
275
|
+
const redisHub: Hub = { ... }
|
|
276
|
+
app.wsHub(redisHub)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
| Hub 方法 | 说明 |
|
|
280
|
+
|----------|------|
|
|
281
|
+
| `join(key, ws)` | WebSocket 加入房间 |
|
|
282
|
+
| `leave(ws)` | WebSocket 离开所有房间 |
|
|
283
|
+
| `send(key, message)` | 向房间广播消息 |
|
|
284
|
+
| `close()` | 关闭 Hub |
|
|
285
|
+
|
|
286
|
+
WebSocket 原生 `ws.send()` 发送,`ws.on('message', cb)` WebSocket 接收。
|
|
287
|
+
|
|
288
|
+
> **实时应用推荐用 `messager()`**(SaaS 地基模块):协议内置(`connected/subscribe/ping`)+ 持久化 + 跨进程广播 + 点对点,不必自写 Hub/协议——见[消息系统章节](saas.md)。
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|