weifuwu 0.58.0 → 0.59.1

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
@@ -1,6 +1,6 @@
1
1
  # weifuwu
2
2
 
3
- **全栈框架 — 后端 HTTP 路由 + 前端 VDOM 框架 + CSS 布局系统**
3
+ **一个包的全栈框架** — 后端 HTTP + 前端 VDOM + 组件库 + CSS 设计系统。全自研、零配置、消灭样板。
4
4
 
5
5
  ```bash
6
6
  npm install weifuwu
@@ -17,21 +17,46 @@ npm install weifuwu
17
17
 
18
18
  ## 设计理念
19
19
 
20
+ ### 一句话
21
+
22
+ **weifuwu = 一个包的全栈框架:全自研、零配置、消灭样板。** 下面三条核心哲学与十条技术原则都是这句话的展开——我们不做缝合框架,每一层都自研且可预测。
23
+
24
+ ### 核心哲学
25
+
26
+ **① 一个包,全栈一体。** 后端、前端、组件、样式装在一个 npm 包里,零配置、零构建、纯 link 可用:服务端 `--import weifuwu/dev` 直接跑 `.tsx`(Node loader + esbuild 同步编译);浏览器 CDN import map 直接跑;CSS 一条 link 即得完整设计系统。
27
+
28
+ **② 全自研,诚实裁剪。** VDOM、PG v3 / RESP2 协议、GraphQL schema、OpenAI 兼容流式协议——全部自研而非包装他人。动机不是炫技而是**确定性**:自研客户端输出确定、行为可预测、错误模型统一。配套纪律是诚实裁剪:**不支持的能力明确抛 `ProtocolError('unsupported')`,绝不静默降级或"尽量支持"**(已裁剪清单见 `docs/db-clients-plan.md`)。
29
+
30
+ **③ 消灭样板。** 框架的每一层都在消灭一类样板代码:
31
+
32
+ | 样板 | 消灭方式 |
33
+ |---|---|
34
+ | 构建样板 | 动态编译(`ctx.ui.js` / `weifuwu/dev`),改代码即刷即用,零构建步骤 |
35
+ | 样式样板 | 语义原语 + 变量定制,零自定义 CSS 文件(`--wf-brand-500` 改一层值全站跟随) |
36
+ | 数据样板 | `ctx.data.get` 一个 API 覆盖 SSR 预取 / hydration 命中 / SPA fetch,写数据像写同步代码 |
37
+ | 协议样板 | 自研 PG/Redis 客户端消灭双重编码、parseRow 样板、`'EX'` 参数顺序陷阱 |
38
+
39
+ ### 技术原则(哲学的展开)
40
+
20
41
  **零运行时依赖** — 前端无 npm 运行时依赖(自研 VDOM,不引入 Virtual DOM 库、rxjs、immer 等)。后端仅依赖 `esbuild`(TSX→JS 编译)+ `graphql` + `ws`(语言/协议本身)——**数据库客户端(PostgreSQL/Redis 协议)、GraphQL schema 工具全部自研**。esbuild 作为运行时依赖随 `npm install weifuwu` 自动安装,`ctx.ui.js()` 开箱即用。
21
42
 
22
- **两阶段组件模型** — 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook
43
+ **两阶段组件模型** — 组件 = `(initProps, ctx) => (props) => VNode`。外层函数只执行一次(mount),内层函数每次状态/props 变化时执行(render)。无 class、无 `this`、无 Hook——**位置即语义**:外层天生只跑一次,没有 hooks 规则、没有依赖数组、没有闭包陷阱(详解见[核心概念](#核心概念))。
23
44
 
24
- **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch。也支持手动 `ctx.ui.render()` 精确控制渲染时机。无需手动调用 `useState`/`useEffect`。
45
+ **Proxy 驱动渲染** — `ctx.ui.$()` 返回深度 Proxy,`$.x = val` 自动触发当前组件的 VDOM patch;也支持手动 `ctx.ui.render()` 精确控制渲染时机。**组件库手动优先、业务层自动优先**——同一框架内按角色选模式(详见[组件库](#组件库-weifuwucomponents))。
25
46
 
26
- **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` 等),Handler/组件从 `ctx` 读取。
47
+ **中间件注入一切** — 后端和前端共用同一理念:中间件向 `ctx` 注入能力(`ctx.sql` / `ctx.redis` / `ctx.api` / `ctx.auth` / `ctx.i18n` / `ctx.limit` / `ctx.email` / `ctx.queue` / `ctx.ai` 等),Handler/组件从 `ctx` 读取。
27
48
 
28
- **自研数据层** — `ctx.sql`(PG v3 协议)与 `ctx.redis`(RESP2 协议)为**自研客户端**:确定性输出、行为可预测、统一错误模型。jsonb 自动解码、TTL 安全 API、schema 写前校验——高频痛点(双重编码/parseRow 样板/`'EX'` 参数顺序)从根上消除。
49
+ **async 工厂组件** — `async (ctx) => (initProps, ctx) => (props) => VNode`:工厂层声明数据(`await ctx.data.get`)、mount 初始化状态(`$`)、render 输出视图。异步只在工厂边界,mount/render 保持同步;数据经闭包注入,写数据像写同步代码。三条纪律见[核心概念 · async 组件](#核心概念)。
50
+
51
+ **SPA/SSR/Hydration 统一透明** — 同一份路由定义(`routes`)一个组件三场景自动适配:后端 `uiSsr({ routes })` 匹配即自动 SSR(完整 HTML + `__DATA__`),客户端 `router({ routes })` + `RouteView` + `mount(..., { hydrate: true })` 按 URL 同源匹配并收养服务端 HTML(不重建、无闪跳)。`ctx.data.get` 一个 API:SSR 预取 / hydration 命中(不重复请求)/ SPA 触发 fetch。服务端直接用 `.tsx`(`weifuwu/dev` Node loader),前后端同一 JSX 运行时。
29
52
 
30
- **SSR + 动态编译** 后端 `ctx.ui.js()` esbuild 实时编译 TSX,开发时改代码即刷即用,零构建步骤。
53
+ **AI 是一等公民** — 自研 OpenAI 兼容协议(`docs/ai-contract.md`)+ 零依赖流式客户端 + agent 工具循环 + HITL 人工审批。`ctx.ai` 一个入口:`chat()` / `stream()` / `agent()` / `approve()`,不用 ai-sdk。
31
54
 
32
- **async 工厂组件**`async (ctx) => (initProps, ctx) => (props) => VNode`:工厂层声明数据(`await ctx.data.get`)、mount 初始化状态(`$`)、render 输出视图。异步只在工厂边界,mount/render 保持同步;数据经闭包注入,写数据像写同步代码。
55
+ **SaaS 地基随包内置**rateLimit(限流)/ email(邮件)/ userSystem(用户认证)/ queue(可靠队列)以中间件形态随包提供,`app.use(...)` 一行接入(详见文末[SaaS 地基模块](#saas-地基模块ratelimit--email--usersystem--queue))。
33
56
 
34
- **SPA/SSR/Hydration 统一透明**同一份路由定义(`routes`)一个组件形态三场景自动适配:后端 `uiSsr({ routes })` 匹配即自动 SSR(完整 HTML + `__DATA__`),客户端 `router({ routes })` + `RouteView` + `mount(..., { hydrate: true })` 按 URL 同源匹配并收养服务端 HTML(不重建、无闪跳)。`ctx.data.get` 一个 API:SSR 预取 / hydration 命中(不重复请求)/ SPA 触发 fetch。服务端直接用 `.tsx`(`weifuwu/dev` Node loader),前后端同一 JSX 运行时。
57
+ **零自定义 CSS 设计系统** 一个 CSS 文件 = 双层 Token + 布局原语 + 工具类 + 组件样式。业务页面不写 style.css:组件 + `wf-*` 原语写业务,品牌/组件定制改变量(`--wf-brand-500` / `--wf-btn-radius`),暗色自动(详见[布局系统](#布局系统-weifuwulayout))。
58
+
59
+ **自研数据层** — `ctx.sql`(PG v3 协议)与 `ctx.redis`(RESP2 协议)为**自研客户端**:确定性输出、行为可预测、统一错误模型。jsonb 自动解码、TTL 安全 API、schema 写前校验——高频痛点(双重编码/parseRow 样板/`'EX'` 参数顺序)从根上消除。
35
60
 
36
61
  ---
37
62
 
@@ -211,8 +236,8 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
211
236
  | 资源 | CDN 地址 | 说明 |
212
237
  |------|---------|------|
213
238
  | `weifuwu/client` | `https://unpkg.com/weifuwu@latest/dist/client/index.js` | 客户端核心(createApp, h, 路由, 状态管理等) |
214
- | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/index.js` | 43 个 UI 组件(Button, Card, Table, Modal 等) |
215
- | 组件样式 | `https://unpkg.com/weifuwu@latest/dist/components/style.css` | 组件 CSS + 91 个主题 Token + 35 个布局原语 |
239
+ | `weifuwu/components` | `https://unpkg.com/weifuwu@latest/dist/components/index.js` | 46 个 UI 组件(Button, Card, Table, Modal 等) |
240
+ | 组件样式 | `https://unpkg.com/weifuwu@latest/dist/components/style.css` | 组件 CSS + 115 个主题 Token + 67 个布局原语 |
216
241
  | 独立布局系统 | `https://unpkg.com/weifuwu@latest/dist/layout/weifuwu-layout.css` | 仅 CSS 布局,不依赖 JS |
217
242
 
218
243
 
@@ -243,15 +268,15 @@ createApp().use(router({ routes })).mount('#root', RouteView, { hydrate: true })
243
268
  | Router 方法 | **app.graphql()** | GraphQL 端点(支持 GraphiQL),Router 实例方法(无需单独 import) | Router |
244
269
  | `weifuwu/client` | **createApp** | 应用引导 + VDOM 渲染引擎 | — |
245
270
  | `weifuwu/client` | **router / RouteView** | 前端路由(history/hash 模式) | createApp |
246
- | `weifuwu/client` | **asyncComponent** | async 工厂组件(形态 C):工厂层声明数据,mount/render 同步 | — |
271
+ | `weifuwu/client` | **asyncComponent** | async 工厂组件:工厂层声明数据,mount/render 同步(三条纪律见[核心概念](#核心概念)) | — |
247
272
  | `weifuwu/client` | **ctx.data** | 数据管道:SSR 预取 / hydration 命中 / SPA fetch(`ctx.data.get`) | createApp |
248
273
  | `weifuwu/client` | **api / auth / ws** | HTTP 客户端 / 认证 / WebSocket 中间件 | createApp |
249
274
  | `weifuwu/client` | **i18n** | 国际化中间件(运行时切换语言) | createApp |
250
275
  | `weifuwu/client` | **ErrorBoundary** | 错误边界组件 | createApp |
251
276
  | `weifuwu/client` | **lockScroll/trapFocus** | 滚动锁定 / 焦点陷阱工具 | — |
252
277
  | `weifuwu/client` | **popup** | 弹层 fixed 定位工具(`computeFixedPos` / `computeFixedPosRect`) | — |
253
- | `weifuwu/components` | **43 个组件** | Button/Table/Modal/Confirm/Toast/... + `confirm()` / `toast()` 命令式中间件 | weifuwu/client |
254
- | `weifuwu/layout` | **CSS 布局** | 35 个布局原语 + 91 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
278
+ | `weifuwu/components` | **46 个组件** | Button/Table/Modal/Confirm/Toast/... + `confirm()` / `toast()` 命令式中间件 | weifuwu/client |
279
+ | `weifuwu/layout` | **CSS 布局** | 67 个布局原语 + 115 个主题 Token(也支持 `weifuwu/layout/style.css`) | — |
255
280
 
256
281
  ---
257
282
 
@@ -298,6 +323,49 @@ const Counter = (_init, ctx) => {
298
323
  | 读取 | handler 读取 ctx | 组件读取 ctx |
299
324
  | 渲染 | 返回 Response | `ctx.ui.render()` / `ctx.ui.dirty()` / `$.x = val` 触发局部 VDOM patch |
300
325
 
326
+ ### async 组件(三条纪律)
327
+
328
+ `asyncComponent` 让"拿数据渲染页面"像写同步代码——三层结构:**工厂**(async,只执行一次,声明数据)→ **mount**(初始化 `$`)→ **render**(输出视图)。异步只在工厂边界:
329
+
330
+ ```tsx
331
+ const UserProfile = asyncComponent(async (ctx) => {
332
+ const user = await ctx.data.get(`/api/user/${ctx.params.id}`) // ① 工厂层:声明数据
333
+ return (_init, ctx) => {
334
+ const $ = ctx.ui.$()
335
+ $.liked = false // ② mount:客户端状态
336
+ return (props) =>
337
+ h('div', {},
338
+ h('p', {}, user.name), // 服务端状态(闭包,SSR 进 HTML)
339
+ h('button', { onClick: () => $.liked = !$.liked }, $.liked ? '❤️' : '🤍'))
340
+ }
341
+ })
342
+ ```
343
+
344
+ **三条纪律**(不遵守就是隐性 bug):
345
+
346
+ | 纪律 | 反例 | 正确 |
347
+ |---|---|---|
348
+ | ① 数据 key 必须含维度 | `ctx.data.get('/api/user')`——`/users/1 → /users/2` 导航命中旧缓存 | `ctx.data.get(\`/api/user/${ctx.params.id}\`)` |
349
+ | ② 会变的数据放 `$` | `const count = data.count`——点击永不更新 | `$.count = data.count`(初始值 seed 自服务端数据) |
350
+ | ③ 初始状态必须确定性 | `$.w = window.innerWidth`——SSR/hydration mismatch | 用服务端数据 seed,交互后再测 |
351
+
352
+ **常见坑**:
353
+ - 工厂**拿不到 props**——数据维度从 `ctx.params` / `ctx.data` 取
354
+ - 闭包数据是页面加载时的**快照**——路由参数变化靠工厂重跑刷新(key 变 → 缓存 miss → 重新取数);工厂缓存绑定页面上下文,路由导航/登录登出时自动失效
355
+ - **个性化数据不进 `ctx.data`**——SSR 会把工厂取数结果序列化给所有客户端,会话/用户相关数据留在 `$` + fetch
356
+
357
+ ### 渲染策略:SPA 还是 SSR?
358
+
359
+ 组件与路由的写法**完全一样**,差异只有入口两行:
360
+
361
+ | | SPA | SSR + Hydration |
362
+ |---|---|---|
363
+ | 适用 | 应用页(后台、工具、Dashboard) | 内容页(博客、营销,需要 SEO/首屏) |
364
+ | 后端 | HTML 外壳 | `uiSsr({ routes })` 一行(自动完整 HTML + `__DATA__`) |
365
+ | 客户端 | `mount('#root', RouteView)` | `mount('#root', RouteView, { hydrate: true })` |
366
+
367
+ **怎么选**:默认 SPA;需要 SEO 或首屏即内容时用 SSR。两种模式可混合——一个 app 内 `uiSsr` 匹配共享路由,未匹配 `next()` 走普通 handler。
368
+
301
369
  ### Closeable 接口
302
370
 
303
371
  所有有状态模块(postgres、redis)实现 `close(): Promise<void>`,serve 关闭时自动调用。
@@ -1651,7 +1719,7 @@ const UserProfile: Component = (initProps, ctx) => {
1651
1719
  }
1652
1720
  ```
1653
1721
 
1654
- ### asyncComponent 工厂(形态 C)— 同步式数据声明
1722
+ ### asyncComponent 工厂(async 组件)— 同步式数据声明
1655
1723
 
1656
1724
  `async (ctx) => (initProps, ctx) => (props) => VNode` — 工厂层(async,只执行一次并缓存)声明数据/加载代码,mount/render 保持同步。数据经闭包注入组件,渲染无 loading 分支:
1657
1725
 
@@ -2148,11 +2216,11 @@ import type { RouterOptions } from 'weifuwu/client'
2148
2216
 
2149
2217
  # 组件库 (`weifuwu/components`)
2150
2218
 
2151
- 43 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
2219
+ 46 个 HTML 原语组件。每个是 `(_init, ctx) => (props) => VNode`(两阶段组件,与前端框架同一模型),引用 `--wf-*` CSS 变量做主题。另含 `confirm()` / `toast()` 命令式中间件。
2152
2220
 
2153
2221
  ```ts
2154
2222
  import { Button, Input, Table, Modal, Toast } from 'weifuwu/components'
2155
- import 'weifuwu/components/style.css' // 包含 Token + 35 布局原语 + 组件样式,一次性引入
2223
+ import 'weifuwu/components/style.css' // 包含 Token + 67 布局原语 + 组件样式,一次性引入
2156
2224
  ```
2157
2225
 
2158
2226
  ### 使用示例
@@ -2381,7 +2449,9 @@ props 变化 ──────────────────────
2381
2449
 
2382
2450
  # 布局系统 (`weifuwu/layout`)
2383
2451
 
2384
- 纯 CSS 布局原语 + 91 个主题 Token。不绑定任何 JS 框架。
2452
+ 纯 CSS 布局原语 + 工具类 + 115 个主题 Token。不绑定任何 JS 框架。
2453
+
2454
+ > **学习路径与命名规范**:见 [`docs/style-guide.md`](./docs/style-guide.md)——统一语法 `wf-<域>-<名>`、三档学习(组件 → 10 核心原语 → 完整速查)、场景速查、变量定制。
2385
2455
 
2386
2456
  > **全栈 weifuwu 项目**:`weifuwu/components/style.css` 已包含布局系统,一条 import 就够了,无需单独引用本页。
2387
2457
  > 本页仅适用于**非 weifuwu 项目**或**只需 CSS 布局**的场景。
@@ -2401,9 +2471,8 @@ app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
2401
2471
  ```
2402
2472
 
2403
2473
  也支持相对路径:`ctx.ui.css('./src/style.css')`。
2404
- ```
2405
2474
 
2406
- ## 35 个布局原语
2475
+ ## 67 个布局原语
2407
2476
 
2408
2477
  | 类别 | 原语 | 效果 |
2409
2478
  |------|------|------|
@@ -2440,13 +2509,56 @@ app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
2440
2509
  | | `wf-inline` | display: inline |
2441
2510
  | | `wf-inline-block` | display: inline-block |
2442
2511
  | | `wf-contents` | display: contents |
2443
-
2444
- ## 91 个主题 Token
2512
+ | **间距** | `wf-p-*` / `wf-px-*` / `wf-py-*`(xs~2xl) | padding:全/水平/垂直,引用 `--wf-space-*` |
2513
+ | | `wf-mt-*` / `wf-mb-*` / `wf-my-*`(xs~2xl) | margin:top/bottom/垂直 |
2514
+ | | `wf-mx-auto` / `wf-my-auto` | margin: auto 居中 |
2515
+ | | `wf-gap-*`(xs~2xl) | 为 flex/grid 原语设置 `--wf-gap` |
2516
+ | **尺寸** | `wf-w-full` / `wf-h-full` / `wf-w-auto` | 宽/高 100%、auto |
2517
+ | **边框** | `wf-border` / `wf-border-t/b/l/r` | 1px 边框(`--wf-border-width` + `--wf-color-border`) |
2518
+ | **面工具** | `wf-bg-secondary/tertiary/brand/success/warning/error/info` | 语义背景色(`--wf-color-*-bg`) |
2519
+ | | `wf-pill` | 胶囊圆角(999px,状态徽章/标签/色块) |
2520
+ | | `wf-rounded-sm` `wf-rounded` `wf-rounded-md` `wf-rounded-lg` | 圆角工具(`--wf-radius-*`) |
2521
+ | **气泡** | `wf-bubble` / `wf-bubble--own` / `wf-bubble--ai` | 聊天气泡(pre-wrap + 折行内建) |
2522
+ | **打印** | `wf-print-hidden` / `wf-print-block` | 导出 PDF 时隐藏工具区 / 恢复块级 |
2523
+ | **行高** | `wf-leading-tight` `wf-leading-base` `wf-leading-relaxed` | line-height(`--wf-line-height-*`) |
2524
+ | **指针** | `wf-pointer` / `wf-not-allowed` | cursor: pointer / not-allowed |
2525
+ | **内容排版** | `wf-prose` | 富文本正文(文章/博客/文档,一个类包 h2/p/ul/blockquote/pre…) |
2526
+ | **外壳** | `wf-app-shell` | 应用外壳:侧边栏 + 主区 grid(`--wf-sidebar-width`) |
2527
+ | | `wf-sidebar` `wf-sidebar-header` `wf-sidebar-body` `wf-sidebar-footer` | 侧边栏:品牌区/导航区/底部用户区,sticky 全高 |
2528
+ | | `wf-nav` `wf-nav-group` `wf-nav-item` `wf-nav-icon` | 导航:分组标题 + 链接项(`--active` 激活态) |
2529
+ | | `wf-main` | 主内容区(padding + min-width: 0) |
2530
+ | | `wf-text-*` 排版工具 | 见下文「排版工具」 |
2531
+
2532
+ ### 排版工具(`wf-text-*`)
2533
+
2534
+ | 工具 | 效果 |
2535
+ |------|------|
2536
+ | `wf-text-left/center/right` | text-align |
2537
+ | `wf-text-xs…5xl` | 字号(`--wf-font-size-*`) |
2538
+ | `wf-text-secondary/tertiary/disabled/brand` | 中性色阶 |
2539
+ | `wf-text-success/warning/error/info` | 语义色文本(`--wf-color-*`) |
2540
+ | `wf-text-medium/semibold/bold` | 字重 |
2541
+ | `wf-tracking-normal/wide/wider` | letter-spacing |
2542
+ | `wf-uppercase/lowercase/capitalize` | text-transform |
2543
+ | `wf-pre-wrap` | white-space: pre-wrap + word-break(聊天气泡/代码) |
2544
+ | `wf-break-word` | overflow-wrap + word-break |
2545
+ | `wf-text-nowrap` | white-space: nowrap |
2546
+ | `wf-truncate` | 单行省略(ellipsis) |
2547
+ | `wf-line-clamp-2/3` | 多行截断 |
2548
+
2549
+ ## 115 个主题 Token
2550
+
2551
+ **双层结构**:原始层(Primitive,色值只定义一次,品牌/暗色调校改这里)+ 语义层(Semantic,组件消费)。
2445
2552
 
2446
2553
  ```css
2554
+ /* ── 原始层 — 品牌/中性色值 + 暗色值,主题定制改这一层 ── */
2555
+ --wf-brand-500 / --wf-brand-600 / --wf-brand-50 /* 品牌主色/悬停/浅底 */
2556
+ --wf-slate-900…50 / --wf-white /* 中性阶 */
2557
+ --wf-dark-* /* 暗色值(暗色模式经间接层引用,零硬编码) */
2558
+
2559
+ /* ── 语义层 — 组件消费,暗色/主题切换覆盖这里 ── */
2447
2560
  /* 品牌色 */
2448
2561
  --wf-color-primary / --wf-color-primary-hover / --wf-color-primary-bg
2449
- --wf-color-secondary / --wf-color-secondary-bg
2450
2562
 
2451
2563
  /* 语义色 */
2452
2564
  --wf-color-success / --wf-color-success-bg
@@ -2496,6 +2608,9 @@ app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
2496
2608
  --wf-accent-color / --wf-caret-color
2497
2609
  --wf-opacity-disabled / --wf-opacity-overlay
2498
2610
  --wf-pop-z / --wf-cover-z
2611
+
2612
+ /* 应用外壳 */
2613
+ --wf-sidebar-width
2499
2614
  ```
2500
2615
 
2501
2616
  ### 暗色模式
@@ -2511,10 +2626,64 @@ app.get('/layout.css', (req, ctx) => ctx.ui.css('weifuwu/layout'))
2511
2626
  // 系统为暗色时自动生效;加 data-theme="light" 可强制亮色
2512
2627
  ```
2513
2628
 
2629
+ 暗色值定义在原始层 `--wf-dark-*`(只写一次),`_dark.css` 两段仅做语义映射——改暗色调校只动原始层,无硬编码色值。
2630
+
2514
2631
  ---
2515
2632
 
2516
2633
  # 样式定制指南
2517
2634
 
2635
+ ## 组件定制钩子(零覆盖 CSS)
2636
+
2637
+ 关键组件视觉全部变量化——定制只需设一个变量(默认值 = 现有 token):
2638
+
2639
+ ```html
2640
+ <style>
2641
+ :root {
2642
+ --wf-brand-500: #7c3aed; /* 品牌换色:改原始层一个值,全站跟随 */
2643
+ --wf-dark-brand-500: #a78bfa; /* 暗色品牌(可选) */
2644
+ --wf-modal-width: 640px; /* 组件定制:设一个变量 */
2645
+ --wf-btn-radius: 999px;
2646
+ --wf-field-height: 44px;
2647
+ --wf-card-shadow: 0 8px 24px rgba(0,0,0,.12);
2648
+ }
2649
+ </style>
2650
+ ```
2651
+
2652
+ 钩子清单:`--wf-btn-*` `--wf-card-*` `--wf-field-*` `--wf-modal-*` `--wf-drawer-width` `--wf-toast-*` `--wf-alert-radius` `--wf-badge-radius` `--wf-tag-radius` `--wf-switch-radius` `--wf-popover-*` `--wf-tooltip-radius` `--wf-dropdown-min-width` `--wf-datepicker-*`。
2653
+
2654
+ **覆盖优先级(@layer)**:`@layer tokens, base, layout, utilities, components`——你写的未分层 CSS 天然最高优先级;也可用 `@layer utilities` 精准覆盖我们。
2655
+
2656
+ ## 零自定义 CSS 模式(推荐)
2657
+
2658
+ 一个项目只需要引用**一个 CSS 文件**(`weifuwu/components/style.css`,内含 Token + 布局原语 + 组件样式),
2659
+ 业务代码全部由组件 + `wf-*` 原语承担,**不需要再写 `style.css`**:
2660
+
2661
+ ```tsx
2662
+ // 组件 → 页面功能块;wf-* 原语 → 块之间的空间关系;--wf-* → 业务色值
2663
+ <PageHeader title="仪表盘" sub="欢迎回来">
2664
+ <Button variant="primary">+ 新建</Button>
2665
+ </PageHeader>
2666
+ <div class="wf-row wf-gap-lg">
2667
+ <StatCard label="总用户" value="1,234" trend="up" trendLabel="12%" />
2668
+ </div>
2669
+ ```
2670
+
2671
+ 主题定制(品牌色/圆角/字体)不需要独立文件——**内联在 HTML 的 `<style>` 里即可**:
2672
+
2673
+ ```html
2674
+ <style>
2675
+ :root {
2676
+ --wf-color-primary: #6366f1;
2677
+ --wf-radius: 8px;
2678
+ }
2679
+ </style>
2680
+ ```
2681
+
2682
+ 完整的零样式示例:`apps/weifuwu-demo`(一个页面 = 组件 + 原语,无 style.css)。
2683
+
2684
+ 诚实例外(合理场景,仍可内联 `<style>` 解决):打印/PDF 导出规则、第三方库宿主样式、
2685
+ 业务特有的一次性视觉(如色板选择器交互)。
2686
+
2518
2687
  ## 全局主题变量
2519
2688
 
2520
2689
  所有组件引用 `--wf-*` CSS 变量。在根元素覆盖即可定制主题:
@@ -3,5 +3,7 @@ export interface AvatarProps {
3
3
  name?: string;
4
4
  src?: string;
5
5
  size?: 'sm' | 'md' | 'lg';
6
+ /** 指定背景色(覆盖按名字哈希的颜色),如按类型着色:user=蓝 / ai=紫 */
7
+ color?: string;
6
8
  }
7
9
  export declare const Avatar: Component<AvatarProps>;
@@ -3,6 +3,10 @@ export interface CardProps {
3
3
  variant?: 'default' | 'outlined';
4
4
  padding?: 'sm' | 'md' | 'lg';
5
5
  clickable?: boolean;
6
+ /** hover 抬升(阴影 + 上移),适合可点击/可悬停的卡片 */
7
+ hover?: boolean;
8
+ /** 选中态(边框高亮 + 品牌浅底),适合选择卡片 */
9
+ active?: boolean;
6
10
  onClick?: () => void;
7
11
  children?: any;
8
12
  }
@@ -9,6 +9,8 @@ export interface InputProps {
9
9
  disabled?: boolean;
10
10
  error?: string;
11
11
  hint?: string;
12
+ /** 边框变体:borderless 用于可编辑标题/内联编辑(hover/focus 才显边框) */
13
+ variant?: 'default' | 'borderless';
12
14
  onInput?: (e: Event) => void;
13
15
  onChange?: (e: Event) => void;
14
16
  }
@@ -0,0 +1,22 @@
1
+ import type { Component } from '../../client/vnode.ts';
2
+ export interface SegmentedOption {
3
+ value: string;
4
+ label: string;
5
+ disabled?: boolean;
6
+ }
7
+ export interface SegmentedControlProps {
8
+ /** 选项列表(label 可为字符串或任意 VNode) */
9
+ options: SegmentedOption[];
10
+ /** 当前选中值 */
11
+ value?: string;
12
+ onChange?: (value: string) => void;
13
+ size?: 'sm' | 'md';
14
+ /** 撑满父容器宽度(选项等分) */
15
+ block?: boolean;
16
+ ariaLabel?: string;
17
+ }
18
+ /**
19
+ * 分段控件 — 单选切换(模式切换 / 状态筛选 / 模板选择)
20
+ * 语义:toggle group(aria-pressed),键盘 focus-visible 可见
21
+ */
22
+ export declare const SegmentedControl: Component<SegmentedControlProps>;
@@ -5,5 +5,7 @@ export interface StatCardProps {
5
5
  trend?: 'up' | 'down';
6
6
  trendLabel?: string;
7
7
  icon?: string;
8
+ /** 点击跳转/交互(悬停抬升 + role=button) */
9
+ onClick?: () => void;
8
10
  }
9
11
  export declare const StatCard: Component<StatCardProps>;
@@ -8,6 +8,10 @@ export interface TextareaProps {
8
8
  error?: string;
9
9
  hint?: string;
10
10
  rows?: number;
11
+ /** 最大字符数(同时限制输入) */
12
+ maxLength?: number;
13
+ /** 显示字数统计(右下角;配合受控 value 实时更新) */
14
+ showCount?: boolean;
11
15
  onInput?: (e: Event) => void;
12
16
  }
13
17
  export declare const Textarea: Component<TextareaProps>;
@@ -59,6 +59,8 @@ export { Slider } from './Slider/Slider.ts';
59
59
  export type { SliderProps } from './Slider/Slider.ts';
60
60
  export { SearchInput } from './SearchInput/SearchInput.ts';
61
61
  export type { SearchInputProps } from './SearchInput/SearchInput.ts';
62
+ export { SegmentedControl } from './SegmentedControl/SegmentedControl.ts';
63
+ export type { SegmentedControlProps, SegmentedOption } from './SegmentedControl/SegmentedControl.ts';
62
64
  export { ProgressBar } from './ProgressBar/ProgressBar.ts';
63
65
  export type { ProgressBarProps } from './ProgressBar/ProgressBar.ts';
64
66
  export { Accordion } from './Accordion/Accordion.ts';
@@ -52,9 +52,9 @@ var Button = (_init, ctx) => (props) => {
52
52
 
53
53
  // src/components/Input/Input.ts
54
54
  var Input = (_init) => (props) => {
55
- const { label, name, type = "text", value, placeholder, required, disabled, error, hint, onInput, onChange } = props;
55
+ const { label, name, type = "text", value, placeholder, required, disabled, error, hint, variant = "default", onInput, onChange } = props;
56
56
  const inputEl = h("input", {
57
- class: "wf-input",
57
+ class: `wf-input${variant === "borderless" ? " wf-input--borderless" : ""}`,
58
58
  name: name || void 0,
59
59
  type,
60
60
  value: value ?? "",
@@ -79,7 +79,7 @@ var Input = (_init) => (props) => {
79
79
 
80
80
  // src/components/Textarea/Textarea.ts
81
81
  var Textarea = (_init, _ctx) => (props) => {
82
- const { label, value, placeholder, required, disabled, error, hint, rows = 3, onInput } = props;
82
+ const { label, value, placeholder, required, disabled, error, hint, rows = 3, maxLength, showCount, onInput } = props;
83
83
  const textareaEl = h("textarea", {
84
84
  class: "wf-textarea",
85
85
  value: value ?? "",
@@ -87,6 +87,7 @@ var Textarea = (_init, _ctx) => (props) => {
87
87
  required: required || void 0,
88
88
  disabled: disabled || void 0,
89
89
  rows,
90
+ maxLength,
90
91
  onInput
91
92
  });
92
93
  const children = [];
@@ -96,6 +97,14 @@ var Textarea = (_init, _ctx) => (props) => {
96
97
  children.push(h("label", { class: "wf-textarea-label" }, labelContent));
97
98
  }
98
99
  children.push(textareaEl);
100
+ if (showCount) {
101
+ const len = (value ?? "").length;
102
+ const over = maxLength != null && len > maxLength;
103
+ children.push(h("div", {
104
+ class: `wf-textarea-count${over ? " wf-textarea-count--over" : ""}`,
105
+ "aria-live": "polite"
106
+ }, maxLength != null ? `${len}/${maxLength}` : String(len)));
107
+ }
99
108
  if (error) children.push(h("div", { class: "wf-textarea-err" }, error));
100
109
  if (hint && !error) children.push(h("div", { class: "wf-textarea-hint" }, hint));
101
110
  return h("div", { class: `wf-textarea-wrap${error ? " wf-textarea--err" : ""}` }, children);
@@ -1059,12 +1068,14 @@ function getPageRange(current, total) {
1059
1068
 
1060
1069
  // src/components/Card/Card.ts
1061
1070
  var Card = (_init, _ctx) => (props) => {
1062
- const { variant = "default", padding = "md", clickable, onClick, children } = props;
1071
+ const { variant = "default", padding = "md", clickable, hover, active, onClick, children } = props;
1063
1072
  const cls = [
1064
1073
  "wf-card",
1065
1074
  `wf-card--${variant}`,
1066
1075
  `wf-card--pad-${padding}`,
1067
- clickable && "wf-card--clickable"
1076
+ clickable && "wf-card--clickable",
1077
+ hover && "wf-card--hover",
1078
+ active && "wf-card--active"
1068
1079
  ].filter(Boolean).join(" ");
1069
1080
  return h("div", { class: cls, onClick, role: clickable ? "button" : void 0, tabindex: clickable ? 0 : void 0 }, children);
1070
1081
  };
@@ -1086,7 +1097,7 @@ function hashColor(name) {
1086
1097
  return COLORS[Math.abs(hash) % COLORS.length];
1087
1098
  }
1088
1099
  var Avatar = (_init, _ctx) => (props) => {
1089
- const { name = "", src, size = "md" } = props;
1100
+ const { name = "", src, size = "md", color } = props;
1090
1101
  const initial = name.trim()[0]?.toUpperCase() ?? "?";
1091
1102
  if (src) {
1092
1103
  return h("img", {
@@ -1097,7 +1108,7 @@ var Avatar = (_init, _ctx) => (props) => {
1097
1108
  }
1098
1109
  return h("div", {
1099
1110
  class: `wf-avatar wf-avatar--${size}`,
1100
- style: { background: hashColor(name) }
1111
+ style: { background: color ?? hashColor(name) }
1101
1112
  }, initial);
1102
1113
  };
1103
1114
 
@@ -1113,7 +1124,7 @@ var Tag = (_init, _ctx) => (props) => {
1113
1124
 
1114
1125
  // src/components/StatCard/StatCard.ts
1115
1126
  var StatCard = (_init, _ctx) => (props) => {
1116
- const { label, value, trend, trendLabel, icon } = props;
1127
+ const { label, value, trend, trendLabel, icon, onClick } = props;
1117
1128
  const children = [];
1118
1129
  if (icon) children.push(h("div", { class: "wf-stat-icon" }, icon));
1119
1130
  children.push(h("div", { class: "wf-stat-value" }, String(value)));
@@ -1126,7 +1137,12 @@ var StatCard = (_init, _ctx) => (props) => {
1126
1137
  trendLabel ? h("span", { class: "wf-stat-trend-label" }, trendLabel) : null
1127
1138
  ].filter(Boolean)));
1128
1139
  }
1129
- return h("div", { class: "wf-stat" }, children);
1140
+ return h("div", {
1141
+ class: `wf-stat${onClick ? " wf-stat--clickable" : ""}`,
1142
+ onClick,
1143
+ role: onClick ? "button" : void 0,
1144
+ tabindex: onClick ? 0 : void 0
1145
+ }, children);
1130
1146
  };
1131
1147
 
1132
1148
  // src/components/Steps/Steps.ts
@@ -1279,6 +1295,27 @@ var SearchInput = (_init, _ctx) => (props) => {
1279
1295
  ].filter(Boolean));
1280
1296
  };
1281
1297
 
1298
+ // src/components/SegmentedControl/SegmentedControl.ts
1299
+ var SegmentedControl = (_init, _ctx) => (props) => {
1300
+ const { options, value, onChange, size = "md", block, ariaLabel } = props;
1301
+ const cls = [
1302
+ "wf-segmented",
1303
+ size === "sm" && "wf-segmented--sm",
1304
+ block && "wf-segmented--block"
1305
+ ].filter(Boolean).join(" ");
1306
+ return h(
1307
+ "div",
1308
+ { class: cls, role: "group", "aria-label": ariaLabel },
1309
+ options.map((opt) => h("button", {
1310
+ type: "button",
1311
+ class: `wf-segmented-option${opt.value === value ? " wf-segmented-option--active" : ""}`,
1312
+ "aria-pressed": opt.value === value ? "true" : "false",
1313
+ disabled: opt.disabled || void 0,
1314
+ onClick: opt.disabled ? void 0 : () => onChange?.(opt.value)
1315
+ }, opt.label))
1316
+ );
1317
+ };
1318
+
1282
1319
  // src/components/ProgressBar/ProgressBar.ts
1283
1320
  var ProgressBar = (_init, ctx) => (props) => {
1284
1321
  const { value = 0, max = 100, label, showValue } = props;
@@ -3200,6 +3237,7 @@ export {
3200
3237
  ProgressBar,
3201
3238
  RadioGroup,
3202
3239
  SearchInput,
3240
+ SegmentedControl,
3203
3241
  Select,
3204
3242
  Skeleton,
3205
3243
  Slider,