diff --git a/.ua/index.json b/.ua/index.json index 4447c41..ad907b5 100644 --- a/.ua/index.json +++ b/.ua/index.json @@ -1,6 +1,6 @@ { "$schema": "ui-arch-registry-index/0.1", - "generatedAt": "2026-10-26T02:22:42.729Z", + "generatedAt": "2026-10-26T02:24:35.622Z", "repoRoot": ".", "packages": [ { @@ -179,7 +179,8 @@ "image(props):图片,缩略图点击 / 回车开遮罩大图预览(Esc 或点击遮罩关闭),width 控制缩略宽", "timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点", "segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用", - "descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列" + "descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列", + "statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值" ], "keyTypes": [ "ButtonProps", @@ -221,7 +222,8 @@ "TimelineProps", "SegmentedProps", "DescriptionItem", - "DescriptionsProps" + "DescriptionsProps", + "StatisticProps" ] }, "usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')", @@ -1711,6 +1713,54 @@ "窄屏自动降为单列,无需在调用方写媒体查询", "纯展示组件,无状态无回调:值变了由调用方重新渲染或传函数式 value" ] + }, + { + "id": "statistic", + "title": "统计数值 Statistic", + "demo": "sec-statistic", + "props": [ + { + "n": "title", + "t": "string", + "d": "指标名(小字灰标题);缺省不渲染" + }, + { + "n": "value", + "t": "Signal", + "r": 1, + "d": "数值信号:变化时从当前显示值滚动到新值,首跑即入场动画" + }, + { + "n": "precision", + "t": "number", + "d": "小数位,千分位分隔自动带;缺省 0", + "df": "0" + }, + { + "n": "prefix", + "t": "string", + "d": "数字前缀(如 ¥);缺省不渲染" + }, + { + "n": "suffix", + "t": "string", + "d": "数字后缀(如 元 / %);缺省不渲染" + }, + { + "n": "trend", + "t": "'up' | 'down'", + "d": "环比方向:up 红 / down 绿(A 股涨红跌绿口径);缺省不渲染" + } + ], + "example": "const gmv = signal(128000); statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' })", + "steps": [ + "value 必须传信号:组件用 effect 监听,值变了自动滚动过渡,不要在外部手动改文本", + "首跑也会从 0 滚到目标值(入场动画);不想要就初始化信号为终值后在挂载后再改", + "precision 控制小数位,千分位由 toLocaleString 自动加,不要自己拼格式化", + "trend 只表达方向与颜色(涨红跌绿),数值本身与环比幅度仍由调用方组织", + "多条指标并排时用 descriptions 或 flex 容器排列,本组件不自带网格", + "用户开启「减弱动效」时组件直接落终值,无需调用方判断" + ] } ] }, diff --git a/AGENTS.md b/AGENTS.md index 9c8a880..2333b39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ - `packages/core/table-core`(@ui-arch/table-core):Headless 表格逻辑内核 - `packages/core/frame`(@ui-arch/frame):signal + hyperscript 微框架 -- `packages/skin/ui`(@ui-arch/ui):37 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 +- `packages/skin/ui`(@ui-arch/ui):38 个 DOM 组件(表单 / 反馈 / 浮层 / 导航 / 表格),样式只消费令牌 - `packages/design/tokens`(@ui-arch/tokens):设计令牌 - `packages/tools/cli`(@ui-arch/cli):`ua` 命令行 + MCP 注册表服务(private) - `apps/demo`(@ui-arch/demo):交互演示,产物 apps/demo/dist/demo.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c7fe7be..4b4b893 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,7 @@ ui-arch-mvp/ │ │ ├── table-core/ # 表格状态内核 │ │ └── frame/ # 信号 + hyperscript 微框架 │ ├── skin/ # 皮肤层(DOM 组件,可发布) -│ │ └── ui/ # 37 个组件 + ui.css +│ │ └── ui/ # 38 个组件 + ui.css │ ├── design/ # 资产层(可发布) │ │ └── tokens/ # --ua-* 设计令牌 │ └── tools/ # 工程层(private,不发布) diff --git a/README.md b/README.md index 4f8378d..4a91ef7 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ npm run demo # 打开演示:apps/demo/dist/demo.html npm run docs # 生成人类文档站 apps/docs/index.html + Agent 全量文档 llms-full.txt ``` -## 组件(共 37 个) +## 组件(共 38 个) -accordion · alert · avatar · badge · breadcrumb · button · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · menu · modal · pagination · popover · progress · radioGroup · rate · segmented · select · skeleton · slider · spinner · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip +accordion · alert · avatar · badge · breadcrumb · button · card · checkbox · dataTable · descriptions · divider · drawer · empty · form · image · inputNumber · menu · modal · pagination · popover · progress · radioGroup · rate · segmented · select · skeleton · slider · spinner · statistic · stepper · switchButton · tabs · tag · textField · textarea · timeline · toast · tooltip -分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / form / formItem)、反馈(alert / toast / spinner / progress / skeleton / badge / empty)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper)、数据(dataTable / descriptions / tag / avatar / card)、其他(divider / timeline)。 +分组:表单(button / textField / inputNumber / textarea / checkbox / switchButton / select / radioGroup / slider / rate / segmented / form / formItem)、反馈(alert / toast / spinner / progress / skeleton / badge / empty / statistic)、浮层(tooltip / popover / modal / drawer / menu)、导航(tabs / breadcrumb / pagination / accordion / stepper)、数据(dataTable / descriptions / tag / avatar / card)、其他(divider / timeline)。 ## 目录 @@ -25,7 +25,7 @@ accordion · alert · avatar · badge · breadcrumb · button · card · checkbo |---|---| | `packages/core/frame` | @ui-arch/frame:signal / computed / effect + hyperscript(无虚拟 DOM) | | `packages/core/table-core` | @ui-arch/table-core:headless 表格(filter → sort → paginate 派生管线) | -| `packages/skin/ui` | @ui-arch/ui:37 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | +| `packages/skin/ui` | @ui-arch/ui:38 个 DOM 组件 + ui.css(只消费 `--ua-*` 令牌) | | `packages/design/tokens` | @ui-arch/tokens:设计令牌(暗色只换值不换名) | | `packages/tools/cli` | @ui-arch/cli:`ua` 命令行与 stdio MCP server | | `apps/demo` | 交互演示(esbuild 打包成单文件) | diff --git a/apps/demo/main.ts b/apps/demo/main.ts index 49187b3..cc9d84e 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -39,6 +39,7 @@ import { slider, spinner, stepper, + statistic, switchButton, tabs, tag, @@ -154,6 +155,10 @@ const menuPick = signal(''); // —— 禁用态演示:开关信号同时绑定 switch 的 checked 与 checkbox 的 disabled —— const locked = signal(true); +// —— 统计数值:两个指标共用同一组按钮刷新,演示滚动过渡 —— +const gmv = signal(128000); +const orders = signal(2048); + // —— 分段控制器:两个实例共用同一信号,演示信号驱动同步与整组禁用 —— const range = signal('week'); @@ -449,6 +454,25 @@ const content = h( button({ label: '导出', variant: 'ghost' }), ), ), + section( + 'sec-statistic', + '统计数值', + 'statistic:大号等宽数字 + 前后缀 + 环比趋势;value 变化时 400ms 缓出滚动(减弱动效偏好下直落终值)。', + h( + 'div', + { class: 'row' }, + statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' }), + statistic({ title: '订单数', value: orders, suffix: '单', trend: 'down' }), + ), + h( + 'div', + { class: 'row' }, + button({ label: '刷新数据', variant: 'ghost', onClick: () => { + gmv(gmv() + Math.round(Math.random() * 20000 - 8000)); // 随机涨跌,演示双向滚动 + orders(orders() + Math.round(Math.random() * 200 - 80)); + } }), + ), + ), section( 'sec-descriptions', '描述列表', diff --git a/llms-full.txt b/llms-full.txt index 55ce016..dfa3328 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @ui-arch 组件库 Agent 文档(无图形版) -> 自动生成于 2026-10-26T02:22:42.729Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 +> 自动生成于 2026-10-26T02:24:35.622Z,数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。 > 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。 ## 快速上手 @@ -977,6 +977,34 @@ descriptions({ title: '订单信息', column: 2, bordered: true, items: [{ label 现场演示:apps/docs/demo.html#sec-descriptions +### statistic — 统计数值 Statistic + +参数: + +- title:string —— 指标名(小字灰标题);缺省不渲染 +- value(必填):Signal —— 数值信号:变化时从当前显示值滚动到新值,首跑即入场动画 +- precision(默认 0):number —— 小数位,千分位分隔自动带;缺省 0 +- prefix:string —— 数字前缀(如 ¥);缺省不渲染 +- suffix:string —— 数字后缀(如 元 / %);缺省不渲染 +- trend:'up' | 'down' —— 环比方向:up 红 / down 绿(A 股涨红跌绿口径);缺省不渲染 + +示例: + +``` +const gmv = signal(128000); statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' }) +``` + +手把手: + +1. value 必须传信号:组件用 effect 监听,值变了自动滚动过渡,不要在外部手动改文本 +2. 首跑也会从 0 滚到目标值(入场动画);不想要就初始化信号为终值后在挂载后再改 +3. precision 控制小数位,千分位由 toLocaleString 自动加,不要自己拼格式化 +4. trend 只表达方向与颜色(涨红跌绿),数值本身与环比幅度仍由调用方组织 +5. 多条指标并排时用 descriptions 或 flex 容器排列,本组件不自带网格 +6. 用户开启「减弱动效」时组件直接落终值,无需调用方判断 + +现场演示:apps/docs/demo.html#sec-statistic + API 速览: - button(props):按钮,variant = primary | ghost | danger,disabled 支持布尔或信号禁用 @@ -1017,6 +1045,7 @@ API 速览: - timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点 - segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用 - descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列 +- statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值 一行用法: diff --git a/llms.txt b/llms.txt index af73065..2748cd5 100644 --- a/llms.txt +++ b/llms.txt @@ -85,6 +85,7 @@ - timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点 - segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用 - descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列 + - statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值 - 用法: `const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')` - 约定: 皮肤不持有业务状态,一律委托 headless 内核或调用方信号 - 约定: 表单类组件统一约定:*Props.value/checked/active 为 Signal 双向绑定,onChange/onInput 为回调 diff --git a/packages/skin/ui/src/index.ts b/packages/skin/ui/src/index.ts index 7d1ea15..b97c867 100644 --- a/packages/skin/ui/src/index.ts +++ b/packages/skin/ui/src/index.ts @@ -31,6 +31,7 @@ export { rate } from './rate.js'; export { empty } from './empty.js'; export { divider } from './divider.js'; export { descriptions } from './descriptions.js'; +export { statistic } from './statistic.js'; export { timeline } from './timeline.js'; export { modal } from './modal.js'; export { drawer } from './drawer.js'; diff --git a/packages/skin/ui/src/statistic.ts b/packages/skin/ui/src/statistic.ts new file mode 100644 index 0000000..2343c53 --- /dev/null +++ b/packages/skin/ui/src/statistic.ts @@ -0,0 +1,41 @@ +import { effect, h } from '@ui-arch/frame'; +import type { StatisticProps } from './types.js'; + +/*** 统计数值:标题 + 大号数字 + 前后缀 + 环比趋势,数值变化时 rAF 滚动过渡(400ms,easeOutCubic) ***/ +export function statistic(props: StatisticProps): HTMLElement { + // —— 状态集中声明 —— + const precision = props.precision ?? 0; // 小数位 + const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; // 减弱动效偏好 + const num = h('span', { class: 'ua-stat-num' }, '0'); // 数字节点:逐帧改文本,不重建 DOM + let shown = 0; // 当前显示中的值(动画中间态) + let raf = 0; // 进行中的动画帧句柄,重复调用时先取消 + + /*** 格式化:千分位 + 固定小数位 ***/ + const fmt = (v: number): string => v.toLocaleString('zh-CN', { minimumFractionDigits: precision, maximumFractionDigits: precision }); + + /*** 从当前显示值滚到目标值;偏好减弱动效时直接落终值 ***/ + const roll = (to: number): void => { + cancelAnimationFrame(raf); // 新目标打断上一段动画,避免数值打架 + if (reduce) { shown = to; num.textContent = fmt(to); return; } + const from = shown; + const start = performance.now(); + const step = (t: number): void => { + const k = Math.min((t - start) / 400, 1); // 归一化进度 0→1 + shown = from + (to - from) * (1 - (1 - k) ** 3); // easeOutCubic 缓出 + num.textContent = fmt(shown); + if (k < 1) raf = requestAnimationFrame(step); + else shown = to; // 收尾对齐目标值,避免浮点残差 + }; + raf = requestAnimationFrame(step); + }; + + const row = h('div', { class: 'ua-stat-value' }, + props.prefix ? h('span', { class: 'ua-stat-affix' }, props.prefix) : null, + num, + props.suffix ? h('span', { class: 'ua-stat-affix' }, props.suffix) : null, + props.trend ? h('span', { class: `ua-stat-trend is-${props.trend}` }, props.trend === 'up' ? '▲' : '▼') : null); + const view = h('div', { class: 'ua-stat' }, props.title ? h('div', { class: 'ua-stat-title' }, props.title) : null, row); + + effect(() => roll(props.value())); // 信号变化即滚动到新值(首跑做入场动画) + return view; +} diff --git a/packages/skin/ui/src/types.ts b/packages/skin/ui/src/types.ts index 29a473a..e6f0a08 100644 --- a/packages/skin/ui/src/types.ts +++ b/packages/skin/ui/src/types.ts @@ -388,4 +388,14 @@ export interface DescriptionsProps { title?: string; // 分组标题;缺省不渲染 } +/*** 统计数值入参 ***/ +export interface StatisticProps { + title?: string; // 指标名;缺省不渲染标题行 + value: Signal; // 数值信号:变化时滚动过渡到新值(首跑即入场动画) + precision?: number; // 小数位;缺省 0 + prefix?: string; // 数字前缀(如 ¥);缺省不渲染 + suffix?: string; // 数字后缀(如 元 / %);缺省不渲染 + trend?: 'up' | 'down'; // 环比方向:up 红 / down 绿(A 股口径);缺省不渲染 +} + export type { ColumnDef, RowKey }; // 再导出常用内核类型,方便调用方 diff --git a/packages/skin/ui/ua.manifest.json b/packages/skin/ui/ua.manifest.json index 1b2ea8c..4b06162 100644 --- a/packages/skin/ui/ua.manifest.json +++ b/packages/skin/ui/ua.manifest.json @@ -50,7 +50,8 @@ "image(props):图片,缩略图点击 / 回车开遮罩大图预览(Esc 或点击遮罩关闭),width 控制缩略宽", "timeline(props):时间线,items 按序渲染纵向事件流(标题 / 时间 / 正文 + 语义色节点),mode 控制轴线左右,pending 追加进行中节点", "segmented(props):分段控制器,胶囊分段 + 滑动滑块,value 信号双向绑定,←→ 环形导航,整组或单项可禁用", - "descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列" + "descriptions(props):描述列表,items(label/value/span) 铺进 column 列网格,bordered 切表格形态,窄屏自动单列", + "statistic(props):统计数值,标题 + 大号数字 + 前后缀 + 环比趋势,value 变化时 rAF 滚动过渡(400ms easeOutCubic),减弱动效偏好下直落终值" ], "keyTypes": [ "ButtonProps", @@ -92,7 +93,8 @@ "TimelineProps", "SegmentedProps", "DescriptionItem", - "DescriptionsProps" + "DescriptionsProps", + "StatisticProps" ] }, "usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')", @@ -1582,6 +1584,54 @@ "窄屏自动降为单列,无需在调用方写媒体查询", "纯展示组件,无状态无回调:值变了由调用方重新渲染或传函数式 value" ] + }, + { + "id": "statistic", + "title": "统计数值 Statistic", + "demo": "sec-statistic", + "props": [ + { + "n": "title", + "t": "string", + "d": "指标名(小字灰标题);缺省不渲染" + }, + { + "n": "value", + "t": "Signal", + "r": 1, + "d": "数值信号:变化时从当前显示值滚动到新值,首跑即入场动画" + }, + { + "n": "precision", + "t": "number", + "d": "小数位,千分位分隔自动带;缺省 0", + "df": "0" + }, + { + "n": "prefix", + "t": "string", + "d": "数字前缀(如 ¥);缺省不渲染" + }, + { + "n": "suffix", + "t": "string", + "d": "数字后缀(如 元 / %);缺省不渲染" + }, + { + "n": "trend", + "t": "'up' | 'down'", + "d": "环比方向:up 红 / down 绿(A 股涨红跌绿口径);缺省不渲染" + } + ], + "example": "const gmv = signal(128000); statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' })", + "steps": [ + "value 必须传信号:组件用 effect 监听,值变了自动滚动过渡,不要在外部手动改文本", + "首跑也会从 0 滚到目标值(入场动画);不想要就初始化信号为终值后在挂载后再改", + "precision 控制小数位,千分位由 toLocaleString 自动加,不要自己拼格式化", + "trend 只表达方向与颜色(涨红跌绿),数值本身与环比幅度仍由调用方组织", + "多条指标并排时用 descriptions 或 flex 容器排列,本组件不自带网格", + "用户开启「减弱动效」时组件直接落终值,无需调用方判断" + ] } ] } diff --git a/packages/skin/ui/ui.css b/packages/skin/ui/ui.css index 0cb354a..ba19c67 100644 --- a/packages/skin/ui/ui.css +++ b/packages/skin/ui/ui.css @@ -1880,3 +1880,45 @@ button.ua-step-dot:hover { grid-template-columns: minmax(0, 1fr); } } + +/* —— 统计数值:大号等宽数字 + 前后缀 + 环比趋势,涨红跌绿 —— */ +.ua-stat { + display: flex; + flex-direction: column; + gap: 2px; +} +.ua-stat-title { + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-sm); +} +.ua-stat-value { + display: inline-flex; + align-items: baseline; + gap: 3px; +} +.ua-stat-num { + color: var(--ua-color-text); + font-size: 26px; + font-weight: 600; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} +.ua-stat-affix { + color: var(--ua-color-text-muted); + font-size: var(--ua-font-size-sm); +} +.ua-stat-trend { + font-size: var(--ua-font-size-sm); + margin-left: 2px; +} +.ua-stat-trend.is-up { + color: var(--ua-color-danger); +} +.ua-stat-trend.is-down { + color: var(--ua-color-success); +} +@media (prefers-reduced-motion: reduce) { + .ua-stat-num { + transition: none !important; + } +}