bingbingyihao-focus-ui
| Install | |
|---|---|
npm install bingbingyihao-focus-ui |
|
| Latest Version: | 0.1.0 |
| Vue: | 3 |
| License: | Apache-2.0 |
| Last Updated: | Sep 29, 2026 |
| Links: | Gitee · npm |
Focus UI
聚焦 5 个高频交互组件的 Vue 3 组件库,内置 黑白灰极简 / 暖色系 / 冷色系 三套主题。
不做大而全的组件库。绝大多数业务页面反复用到的其实只有这几个: Dialog(对话框)、Confirm(确认框)、Message(消息提示)、Select(下拉)、DatePicker(日期选择) —— 而它们恰恰是原生控件最不好用、各平台差异最大、最影响"整体质感"的五个。 所以这里只把它们做透:每个都能独立拿走用,且共享同一套设计令牌。
┌─ 组件 ────────────────────────────────────────────────────────────┐
│ FxSelect 单选 / 多选 / 搜索 / 分组 / 色点 / 清除 / 键盘 │
│ FxDatePicker 单选 / 区间 / 日-月-年三级视图 / 快捷预设 / 格式化 │
│ FxDialog 遮罩 / Esc / 焦点陷阱 / 锁滚动 / 命令式 openDialog │
│ FxConfirm 命令式 confirm() → Promise<boolean>,支持异步校验 │
│ message 命令式 message.success / error / warning / loading │
│ FxButton 统一按钮(弹窗 / 提示内部与业务页共用) │
└─ 主题 ────────────────────────────────────────────────────────────┘
data-theme="mono" | "warm" | "cool" → 只换令牌,组件代码零改动
快速开始
npm install
npm run dev # http://127.0.0.1:5175/ 演示页(顶栏可切三套主题)
在别的工程里使用
npm install bingbingyihao-focus-ui
装进来的只有组件本体与三份样式(src/ui/ + src/styles/),演示页与测试脚本不在包内。
// main.js —— 样式顺序不能反:基线里会用主题令牌,必须在其之后解析
import 'bingbingyihao-focus-ui/styles/tokens.css' // 结构令牌:字体 / 间距 / 动效 / 层级
import 'bingbingyihao-focus-ui/styles/themes.css' // 三套主题令牌
import 'bingbingyihao-focus-ui/styles/base.css' // reset + 原生控件归一化
import FocusUI from 'bingbingyihao-focus-ui'
createApp(App).use(FocusUI) // 方式一:全量注册(FxSelect / FxDialog ...)
// 方式二:按需引入(推荐)
import { FxSelect, confirm, message, setTheme } from 'bingbingyihao-focus-ui'
本包以源码形式发布(直接暴露
.vue文件),因此需要打包器能编译node_modules里的 单文件组件 —— Vite / webpack + vue-loader / Nuxt 都可以。包只依赖 Vue 3.4+, 且vue声明在peerDependencies,不会把第二份 Vue 装进你的工程。主题令牌挂在
:root上,所以必须引入themes.css才能拿到颜色; 不想全量引base.css也行,但它负责把原生select / input / textarea归一化, 少了它页面里会出现"自绘组件很精致、旁边原生控件很土"的割裂感。
三套主题
import { setTheme } from 'bingbingyihao-focus-ui'
setTheme('warm') // mono | warm | cool
// 等价于:
document.documentElement.dataset.theme = 'warm'
三套主题的差异不只是色相 —— 圆角、阴影、边框强度、字重都在变。 只换颜色的话,三套主题看起来会像同一个东西换了皮:
黑白灰极简 mono |
暖色系 warm |
冷色系 cool(默认) |
|
|---|---|---|---|
| 主色 | #18181b 近黑 |
#c2410c 橙 |
#2563eb 蓝 |
| 页面底色 | #f5f5f6 |
#fdf8f2 暖白 |
#f4f7fc 冷白 |
| 圆角 md | 6px 小硬 |
12px 大柔 |
10px 中 |
| 阴影 | 几乎不投影,靠"线"分层 | 柔和暖阴影 | 清晰冷阴影 |
| 边框 | 对比更强(#b9b9c0) |
压得很淡 | 中性 |
| 强调字重 | 700 |
600 |
600 |
| 危险色 | 仍保留红(语义不能丢) | 砖红 | 正红 |
两个配套能力:
- 可以局部生效。令牌挂在
[data-theme]选择器上,所以任意元素都能套一套风格 —— 演示页的「主题与令牌」区块就是并排渲染了三套风格,不用来回切换。 ⚠️ 唯一的例外是 Teleport 到body的浮层(下拉面板 / 日历 / 弹窗),它们只跟随html上的主题。 - 令牌表读的是真实值。演示页的令牌对照表是用游离探针元素
getComputedStyle读出来的, 改了themes.css表格会跟着变,不存在"文档写了但代码没改"的偏差。
令牌清单
组件样式只引用 --fx-*,因此换主题不需要动任何组件代码。
| 分类 | 令牌 |
|---|---|
| 表面层级 | --fx-color-bg → --fx-color-surface → -2 → -3 |
| 描边 | --fx-color-border、--fx-color-border-strong |
| 文本 | --fx-color-text、-2、-3 |
| 强调色 | --fx-color-primary(-hover/-active/-weak/-contrast)、danger、success、warning、info |
| 遮罩 | --fx-color-mask |
| 形状 | --fx-radius-xs/sm/md/lg/full、--fx-shadow-1/2/3、--fx-ring |
| 质感 | --fx-border-width、--fx-font-weight-strong |
| 结构 | --fx-font-*、--fx-space-*、--fx-control-*、--fx-dur-*、--fx-ease、--fx-z-* |
三套主题的令牌集合由 npm run test:theme 强制保持一致:少写一个令牌就会回退到上一套主题的值,
表现为"切了主题但某个角落没变",这类问题在页面上极难靠肉眼发现。
组件
FxSelect 下拉选择
替代原生 <select>(展开面板由浏览器渲染,样式完全不可控,各平台外观差异最大)。
<FxSelect v-model="value" :options="options" clearable placeholder="请选择" />
<FxSelect v-model="list" multiple searchable :options="grouped" :max-tag-count="3" block />
<script setup>
const options = [
{ value: 'a', label: '选项 A' },
{ value: 'b', label: '选项 B', hint: '推荐', color: '#2563eb' },
{ value: 'c', label: '禁用项', disabled: true },
]
// 分组写法可与扁平写法混用
const grouped = [{ label: '流程控制', options: [{ value: 'x', label: '条件分支' }] }]
</script>
| 属性 / 事件 | 类型 | 说明 |
|---|---|---|
v-model |
any | any[] |
当前值,多选时为数组 |
options |
Array |
支持扁平与分组两种写法混用 |
multiple |
boolean |
多选:选中后不关闭面板,标签超出 maxTagCount 折叠为 +N |
searchable |
boolean |
面板内搜索框;分组标题会跟着过滤结果一起消失 |
clearable / disabled / loading |
boolean |
清除按钮 / 禁用 / 载入中 |
size |
'sm' | 'md' | 'lg' |
尺寸 |
block |
boolean |
占满父容器宽度 |
max-tag-count |
number |
多选时最多展示几个标签(默认 2) |
empty-text |
string |
无匹配项时的文案 |
align |
'start' | 'end' |
浮层对齐方式 |
@change / @clear |
(value) => void / () => void |
值变化 / 点击清空 |
键盘:↑ ↓ Home End Enter Esc Tab;多选时 Backspace 删掉最后一个标签。
FxDatePicker 日期选择
替代原生 input[type=date](无法主题化,且完全不支持区间)。
<!-- 默认产出 Date 对象,方便参与计算 -->
<FxDatePicker v-model="day" clearable />
<!-- 直接产出字符串,方便提交给接口 -->
<FxDatePicker v-model="day" value-format="YYYY-MM-DD" />
<!-- 区间 + 快捷预设 -->
<FxDatePicker v-model="range" type="range" :shortcuts="shortcuts" :max="new Date()" />
| 属性 / 事件 | 类型 | 说明 |
|---|---|---|
v-model |
Date | [Date, Date] | string | number |
区间模式为数组 |
type |
'date' | 'range' |
单选 / 区间 |
value-format |
'date' | 'timestamp' | pattern |
值形态:Date 对象(默认)/ 毫秒时间戳 / 按模式串格式化 |
format |
string |
展示格式,缺省取全局配置(YYYY-MM-DD) |
shortcuts |
Array |
[{ label, value: Date | [Date,Date] | () => ... }] |
min / max |
Date | string | number |
可选范围,超出部分置灰 |
disabled-date |
(date) => boolean |
自定义不可选规则 |
week-starts-on |
number |
一周从周几开始,1 = 周一(默认) |
range-separator |
string |
区间连接符,默认 " ~ " |
clearable / disabled / size / block |
— | 同 FxSelect |
@change / @clear |
(value) => void / () => void |
值变化 / 点击清空 |
面板支持 日 → 月 → 年 三级视图(点标题逐级上升):选去年某天不用点十几次箭头。
区间模式的中间态(已点第一下、还没点第二下)只在面板内部存在,不会向外抛出 [Date, null] 这种半成品。
FxDialog 对话框
<FxDialog v-model:open="open" title="编辑资料" :width="460">
<FxSelect v-model="role" :options="roles" block />
<template #footer>
<FxButton @click="open = false">取消</FxButton>
<FxButton variant="primary" :loading="saving" @click="save">保存</FxButton>
</template>
</FxDialog>
| 属性 / 事件 | 说明 |
|---|---|
v-model:open |
受控开合;不传 open 即为非受控,组件自己管状态 |
title |
标题;也可用 #header 插槽完全自定义 |
width |
数字按 px,也可传任意 CSS 宽度;默认取全局配置(420) |
placement |
center 居中(默认)/ top 顶部对齐(长表单更合适) |
close-on-mask / close-on-esc |
默认均为 true |
show-close |
是否显示右上角关闭按钮 |
lock-scroll |
打开时锁 body 滚动(含滚动条宽度补偿),默认 true |
#default / #header / #footer |
主体 / 头部 / 底部 |
@open @opened @close @closed |
开合前后事件;closed 在关闭动画结束后触发 |
defineExpose |
close() |
模态该有的默认行为都做了:遮罩、Esc、点击遮罩关闭、焦点陷阱(Tab 不会跑到弹窗外的元素上)、
关闭后焦点归还触发按钮、锁滚动、打开即聚焦第一个可操作元素。
命令式:
const dialog = openDialog({
title: '说明',
content: '字符串 / VNode / 渲染函数都支持',
footer: () => h(FxButton, { onClick: () => dialog.close() }, () => '知道了'),
})
FxConfirm 确认框
import { confirm, message } from 'bingbingyihao-focus-ui'
if (await confirm({ title: '确认删除?', danger: true, confirmText: '删除' })) {
await api.remove(id)
message.success('已删除')
}
// 需要异步校验时:返回 false 就不关闭,期间确定按钮 loading
await confirm({
title: '提交审批?',
onConfirm: async () => await checkSomething(),
})
| 参数 / 属性 | 说明 |
|---|---|
confirm(options) |
返回 Promise<boolean>:确定 → true;取消 / Esc / 遮罩 → false |
options.onConfirm |
返回 false 或 Promise<false> 表示"校验没过",弹窗保持打开 |
danger / icon |
危险操作(按钮与图标变红)/ question | warning | danger | success |
message / description |
主文案 / 次要说明 |
confirm-text / cancel-text |
按钮文案 |
v-model:open |
组件式用法;@confirm / @cancel 事件 |
语义约定:取消一定关闭;确定在非受控用法下自动关闭,受控 / 命令式时交给调用方 (因为命令式需要等异步校验结束才关,乐观关闭会让 loading 来不及显示)。
message 消息提示
import { message } from 'bingbingyihao-focus-ui'
message.success('保存成功')
message.error('请求失败,请稍后重试', { title: '出错了' })
message.warning('这条不会自动消失', { duration: 0 })
const handle = message.loading('正在上传…')
await upload()
handle.close()
| API / 选项 | 说明 |
|---|---|
message(content, options) |
基础调用(等价 message.info),返回 { id, close } |
.success .error .warning .info |
四种语义类型 |
.loading |
载入中,默认不自动关闭,用返回句柄手动 close() |
options.title |
标题行 |
options.duration |
自动关闭时间(ms),0 常驻,默认 3000 |
options.closable / options.max |
关闭按钮 / 同屏最大条数(默认 5,超出移除最早的) |
message.close(id) / .closeAll() |
关闭某条 / 全部 |
细节:鼠标悬停会暂停倒计时且不重置 —— 划过时不会让刚出现的长文案立刻消失。
FxButton 按钮
| 属性 | 说明 |
|---|---|
variant |
primary | default | ghost | text | danger |
size |
sm | md | lg |
loading / disabled |
载入态(带 spinner)/ 禁用 |
block |
占满父容器宽度 |
全局配置
import { setConfig } from 'bingbingyihao-focus-ui'
setConfig({
dialog: { width: 480 },
message: { duration: 2000, max: 3, placement: 'top-center' }, // top-right(默认) | top-center | bottom-right | bottom-center
datePicker: { weekStartsOn: 0, format: 'YYYY/MM/DD' },
})
优先级:组件 props > 全局配置 > 代码内兜底常量。
目录结构
ui-framework/
├─ index.html / vite.config.js
├─ eslint.config.js # ESLint 扁平配置(vue3-recommended + 与 Prettier 分工)
├─ .prettierrc.cjs / .editorconfig / .gitattributes
├─ scripts/
│ ├─ unit-test.mjs # 纯 Node 单测:日期工具 + 选项工具(67 项断言)
│ ├─ theme-test.mjs # 解析 themes.css:令牌一致性 + 对比度 + 三套必须真的不同(36 项)
│ ├─ smoke.mjs # 真实浏览器端到端冒烟(78 项断言,含 pageerror / console.error 检查)
│ ├─ format.mjs # ★ 一键 lint + format(跨平台,Mac / Linux / CI 用这个)
│ └─ format.ps1 # ★ 同上,Windows PowerShell 版(纯 ASCII,见文件内注释)
└─ src/
├─ main.js / App.vue # 演示壳:顶栏主题切换 + 侧栏锚点 + 内容区
├─ styles/
│ ├─ tokens.css # 结构令牌(字体 / 间距 / 控件高度 / 动效 / 层级)
│ ├─ themes.css # ★ 三套主题令牌(改这里就能加第 4 套)
│ └─ base.css # reset + 原生控件归一化 + 表单触发器基类 .fx-field
├─ ui/
│ ├─ index.js # 统一出口 + Vue 插件 + setTheme / dateUtils / selectUtils
│ ├─ config.js # 全局配置
│ ├─ composables/
│ │ ├─ useZIndex.js # 浮层层级:全局单调递增
│ │ ├─ useScrollLock.js # 滚动锁:计数式,支持多个弹窗叠加
│ │ ├─ usePopupPosition.js # 浮层定位:fixed + 空间不足自动翻转 + 先定位后显示
│ │ ├─ useFocusTrap.js # 焦点陷阱 + 焦点归还
│ │ └─ useClickOutside.js # 点击外部关闭(mousedown 捕获阶段)
│ └─ components/
│ ├─ base/FxButton.vue
│ ├─ select/{FxSelect.vue, options.js} # options.js 为纯函数(可单测)
│ ├─ date-picker/{FxDatePicker.vue, CalendarPanel.vue, dateUtils.js}
│ ├─ dialog/{FxDialog.vue, imperative.js} # imperative.js = openDialog + confirm
│ └─ message/{message.js, MessageHost.vue, MessageItem.vue}
└─ views/ # 演示页(与组件库本体无关,复用时可整目录删掉)
├─ DemoView.vue / demo.css
└─ sections/{Theme,Select,DatePicker,Dialog,Message}Section.vue
质量保障
npm run fix # 一键:eslint --fix + prettier --write
npm run verify # 一键:lint + 格式 + 单测/主题测试,全绿才退出 0
npm run lint # ESLint 修复(vue3-recommended)
npm run lint:check # ESLint 只检查不修改
npm run format # Prettier(无分号 / 单引号 / 100 列 / ES5 尾逗号)
npm run format:check # Prettier 只检查不修改
npm run test # = test:unit + test:theme,纯 Node,不需要浏览器
npm run build # 生产构建
npm run dev # 另开终端
npm run smoke # 真实浏览器端到端(复用本机 Edge / Chrome)
提交前跑一条就够:
npm run verify # 任意平台
powershell -ExecutionPolicy Bypass -File scripts/format.ps1 check # Windows
ESLint 与 Prettier 的分工:格式一律归 Prettier,ESLint 只管"会不会出错"
(未使用变量、重复的 prop 键、改 props、组件名多词……)。两者冲突的规则由
@vue/eslint-config-prettier/skip-formatting 关闭,它必须放在配置数组最后,
否则会被后面的 recommended 覆盖回去。
为什么要 .gitattributes:本机 git 若开了 core.autocrlf=true,工作区文件会变成
CRLF,而 .prettierrc.cjs 是 endOfLine: 'lf' —— format:check 会对每一个文件报错,
format 再改回 LF,下次检出又变 CRLF,来回抖。行尾不该由"谁的机器怎么配的 git"决定,
统一写进仓库:* text=auto eol=lf。
test:unit:日期计算是这类组件最容易出错的地方 —— 月份网格少一天、1/31 + 1 月溢出到 3 月、2025-02-30被静默滚成 3 月 2 日…… 这些在界面上只表现为"某个日期莫名点不了",靠肉眼几乎发现不了。test:theme:直接解析themes.css文本。三套主题的令牌集合必须完全一致;正文 / 次级文字 / 主色上的文字需满足对比度下限(4.5:1、4.5:1、3:1);主色与圆角必须两两不同。smoke:真浏览器里跑完整链路,并断言全程无pageerror与console.error。
发布(维护者)
包以源码形式发布,因此没有构建步骤,发布前跑一遍校验即可:
npm config get registry # 必须是 https://registry.npmjs.org/,本地若用镜像要先切回来
npm login # 需要 2FA 或 granular access token
npm run verify # lint + 格式 + 单测 / 主题测试(prepublishOnly 也会自动跑)
npm pack --dry-run # 逐条确认打包清单:只应有 src/ui、src/styles、README、LICENSE
npm publish # 包名非 scoped,且 publishConfig.access 已设为 public
git tag v0.1.0 && git push origin v0.1.0
两个注意点:
files字段是白名单,src/views、scripts、index.html都不会进包; 若新增「使用者需要但不属于src/ui/src/styles」的文件,记得同步加进files。- 版本号一旦发布成功就不可复用,改动后必须
npm version patch | minor | major再发。
设计取舍
为什么只用 CSS 变量做主题,不上 CSS-in-JS。 三套主题 = 三份令牌,加一套主题就是复制一段
:root 区块;而组件代码一行不用改,也不需要额外的运行时依赖。
为什么自绘 Select / DatePicker 而不是包一层原生控件。 原生控件的展开面板不在我们的样式
作用域内(下拉、日历、window.confirm 都是),包一层的结果永远是"框好看、面板丑"。
既然要统一风格,就得自己画。
为什么不引 dayjs / date-fns。 这个组件只需要「取当月网格 + 格式化 + 加减 + 比较」四件事,
为它引入一个日期库,等于把库的体积与升级成本绑在一个无关紧要的依赖上。
dateUtils.js 是纯函数,反而更好测。
为什么浮层层级全局递增,而不是按组件类型分层。 「后打开的必须盖住先打开的」是唯一成立的规则。 按类型分层(比如 dialog 永远高于 select)会导致「从下拉面板里点出一个对话框, 结果对话框被自己的下拉压住」。
为什么 Esc 监听挂在 document 的冒泡阶段。 挂在弹窗元素上时,焦点一旦跑到弹窗外的元素,
Esc 就会"失灵";用捕获阶段则会出现"一次 Esc 把下拉和弹窗一起关掉"。
冒泡 + 检查 event.defaultPrevented,才能做到「先关内层、再关外层」。
代码风格参考 workflow-orchestrator:
无分号 / 单引号 / 100 列 / ES5 尾逗号 + .editorconfig,注释一律写"为什么这么设计"而不是复述代码。
后续可扩展
- Tooltip / Popover:
usePopupPosition已经是通用的,补一层触发逻辑即可; - 表格与分页:目前刻意不做,等有真实需求再进;
- 暗色模式:令牌已按「表面层级」分层,补一份
[data-theme='dark']即可; - 表单校验联动:
FxConfirm的onConfirm返回false保持打开的机制可以直接复用。
Related Packages
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.0 | ^3.4.0 | Apache-2.0 |