Compare commits

..

80 Commits
dev ... dev

Author SHA1 Message Date
申君健 05cb7930a2
fix: saas-design write the wrong icon name (#4295) 2026-08-05 11:24:02 +08:00
申君健 1544764b98
fix(common): 为子组件注入design添加兜底变量 (#4293) 2026-08-05 10:58:24 +08:00
vitlot-Hongchujie fdb2129f86
fix(tabs): 修复 showMoreTabs 模式下 overflow 计算滞后导致下拉菜单未及时更新(#4279) (#4272)
* fix(tabs): fix showMoreTabs overflow calculation lag caused by CSS transition

The nav element has CSS transition: transform 0.3s, which causes
getBoundingClientRect() to return pre-transition positions when
calcMorePanes runs in requestAnimationFrame (before the transition
has advanced).

Fix: use tab-relative-to-nav position delta (transition-invariant
since both share the same transform coordinate space) plus navOffset
to compute the final expected positions, bypassing the transition
intermediate state entirely.

Also replace nextTick with requestAnimationFrame in deferred
calcMorePanes calls to ensure all cascading DOM updates from
scrollToActiveTab -> updated() chains have settled.

* fix(tab-nav): trigger calcMorePanes after scrollPrev/scrollNext arrow click

* test(tabs): 修正 custom-more-icon 溢出下拉数量断言

showMoreTabs 溢出计算修正后按真实布局计算,容器 400px 减去
更多按钮占位后仅 5 个 tab 完整可见,下拉应为 Tab 6/7/8 共 3 项,
原断言 2 是基于旧实现 mount 早期量宽了 navScroll 的错误结果。
2026-08-05 09:19:25 +08:00
Georgyhongbo f83a76b7c3
docs(date-picker): 收窄 align demo 输入框,清晰展示左中右对齐效果 (#4287)
* docs(date-picker): 收窄 align demo 输入框,清晰展示左中右对齐效果

* docs(date-picker): 同步 align demo 宽度收窄到 composition-api 示例

reviewer 指出 PR #4287 漏改了 align-composition-api.vue,
将输入框宽度一并收窄到 240px,与 align.vue 保持一致。
2026-08-04 20:30:54 +08:00
liukun 1447be3d86
fix:多端表格切换到移动端时显示空白 (#4284) 2026-08-04 20:30:42 +08:00
Georgyhongbo b48ae0972c
fix(clickoutside): 修复拖选文本时误触发外部点击关闭的问题 (#4288)
* fix(clickoutside): 缓存 mousedown 的 composedPath,修复组件内拖选到外部松开时误触发外部点击关闭

composedPath() 在事件派发完成后会返回空数组,而 clickoutside 把 mousedown
事件存入 startClick,等到 mouseup 处理时才调用其 composedPath(),此时路径
已丢失,导致 mousedown 在组件内部的保护判断失效。修复为在 mousedown 派发
时立即缓存完整路径,mouseup 时使用缓存路径。

* test(color-picker): 增加 hex 输入框拖选文本时面板不应误关闭的回归测试
2026-08-04 20:13:15 +08:00
Georgyhongbo 45977b2eee
fix(color-select-panel): sync hex input to slider and color display on blur/enter (#4267)
When users type a hex color value directly in the text input,
the slider and color display should update accordingly.
Previously only slider->text direction worked, not text->slider.

- Add onInputBlur to sync state.input to color object on blur
- Add @keyup.enter to blur the input, triggering the sync
- Guard against empty input and linear-gradient mode
2026-08-04 20:11:46 +08:00
Georgyhongbo 1c4497c259
fix(demos): multiple-limit demo no longer shares v-model with custom-icon demo (#4286)
场景4「多选个数限制」与场景5「自定义图标+自定义样式」误用同一个
v-model,导致在场景5中选中超过限制数量的选项后,场景4也显示超限
选项,突破了 multiple-limit=2 的限制。

修复:为场景5分配独立 model(base-select 用 value5,select 用 value6),
两个场景互不影响。覆盖 base-select / select 的 pc 与 mobile-first
示例(含 composition-api 变体)。
2026-08-04 20:03:20 +08:00
Georgyhongbo bb894e68e1
fix(input): 为密码可见性切换按钮补上 mousedown.prevent,修复连续点击时误选中页面文本 (#4291) 2026-08-04 19:35:24 +08:00
申君健 4be2d53c13
fix(slider-button-group): lost ref in pc.vue (#4292) 2026-08-04 19:24:14 +08:00
申君健 46ab75ae0b
fix: svg function supports a bottom padding style across multiple devices. (#4289)
* fix: 消除一行css 警告

* fix: 多端下增加底托的类名
2026-08-04 09:32:10 +08:00
申君健 b9004f00c5
fix: 恢复指南菜单以及构建open模式,不带业务组件 (#4283) 2026-07-31 17:13:57 +08:00
gimmyhehe 5e0ae7cc04
fix(theme): update theme package version to 3.31.1 and improve CSS class selectors in reset.less (#4274) 2026-07-30 09:34:11 +08:00
申君健 560b6f79be
docs: add 3.31.0's changelog (#4275)
* fix: add 3.31.0's changelog

* fix: 升级playground版本号
2026-07-30 09:32:54 +08:00
申君健 64b9fc6f83
fix: compatible with the click behavior into saas-design (#4273)
* fix: 兼容AUI的折叠面板的点击行为

* fix: 重新更新collapse-item的点击事件

* fix: remove wrong code from alert
2026-07-29 15:10:16 +08:00
申君健 61e36078c7
fix: 兼容AUI的折叠面板的点击行为 (#4271) 2026-07-29 15:01:13 +08:00
zzcr 52eb2bd06e chore: update docs version 2026-07-27 19:13:29 +08:00
liukun 4be1cdfc6f
fix(vue-renderless/date-panel): 修复多时区场景下选中日期偏移一天的问题 (#4266)
预补偿 val - localOffset 后混用本地 getFullYear/getMonth 与 UTC 的 getUTCDate+1,导致所有时区偏移一天。

本地与 UTC 混用还会使西半球跨年/跨月边界错月错年。

改为统一读取 getUTCFullYear/getUTCMonth/getUTCDate,与预补偿语义一致,修正多时区及跨边界日期偏移。
2026-07-27 17:26:57 +08:00
zzcr 49f644b22b fix: 修复color-picker组件发包失败问题 2026-07-27 16:55:33 +08:00
申君健 b51b58bcc8
fix: sync theme-saas's tailwind classname of rounded-* (#4264)
* feat: 更新theme-saas配置文件

Match-id-3ee0e938f05e6e92cc8fd12b1902a2107e5b54b7

* fix: 更新rounded-form 之前4个类名

* fix: 补充rounded-form 后的2个类名

* fix: 优化脚本

---------

Co-authored-by: OpenTiny <opentiny@sina.com>
2026-07-27 14:42:18 +08:00
申君健 e5e921b3ab
fix: 修改E2E 测试位置的描述 (#4260) 2026-07-24 15:09:42 +08:00
liukun 162e1644e7
fix:穿梭框组件表头不支持固定 (#4257) 2026-07-24 15:09:16 +08:00
liukun 1b515ab8df
feat:富文本超链接支持跳转前拦截 (#4258) 2026-07-24 15:04:00 +08:00
James 9bb547140b
feat(switch): [switch] add display-only attribute (#4259)
* feat(switch): [switch] add display-only attribute

* fix(switch): modify the e2e-test
2026-07-24 15:03:34 +08:00
liukun 2d296622df
fix:窗口宽度变化横向滚动错位 (#4251) 2026-07-23 20:25:50 +08:00
申君健 c6783e2b7c
fix: fix e2e errors of dialog-select and modal (#4256)
* fix: fix dialog-select  e2e error

* fix: 修改model-event e2e测试

* fix: 还原文件
2026-07-23 14:41:28 +08:00
liukun c94102a989
fix:custom插槽filter模式显示错位 (#4252) 2026-07-23 14:30:58 +08:00
申君健 1fccd5c414
chore: update to version 3.31.0 (#4255) 2026-07-23 14:27:08 +08:00
wuyiping a360a6f35b
feat(slider-button): add slieder-button e2e (#4254)
* feat: add slieder-button e2e

* feat(slider-button): adapt theme style

* feat: add loading-open.svg to theme-saas
2026-07-23 14:26:25 +08:00
申君健 2a5150709d
fix(file-upload): 增强accept属性的适配算法 (#4253)
* fix(file-upload):  增强accept属性的适配算法

* fix: 修改检视意见
2026-07-22 16:52:53 +08:00
Yujing613 0dd1091377
fix(numeric): 移除numeric增减按钮z-index,解决表格内层级冲突问题 (#4244) 2026-07-22 14:15:00 +08:00
James fa23962cda
feat(alert): [alert] add small and medium sizes (#4248)
* feat(alert): [alert] add small and medium sizes

* fix: modify the review comments
2026-07-22 09:45:23 +08:00
James b86d7f9fd9
fix(drawer): add drag and drop icon(mobile-first) (#4249)
* fix(drawer): add drag and drop icon(mobile-first)

* fix: add demo(mobile-first)

* fix: modify the review comments
2026-07-22 09:44:49 +08:00
ajaxzheng 8b0022cdb5
feat: add SliderButton component (#4250)
* feat: add SliderButton component

* fix(sort-desc): update import paths for SVG components and refactor export to use arrow function

* chore: 修复检视意见

* chore:修复检视意见
2026-07-22 09:43:58 +08:00
James c1aa6fff59
fix(nav-menu): modify the misalignment issue of dropdown content (#4171)
* fix(nav-menu): modify the misalignment issue of dropdown content

* fix(nav-menu): modify problem

* fix: modify the review comments
2026-07-22 09:14:45 +08:00
James 9e383a27f3
feat(flowchart): example and documentation for adding Flowchart component (#4240)
* feat(flowchart): example and documentation for adding Flowchart component

* fix: modify the review comments
2026-07-22 09:11:51 +08:00
cfljue 839146d1dc
fix(vue-renderless/fluent-editor): Fix lineheight formatting to use 'line-height' (#4212)
Fix the issue where the line height setting for the rich text editor does not take effect
2026-07-21 14:21:42 +08:00
xuxiao1797 a0b3392278
fix(vue-renderless/file-upload): [file-upload] accept 支持 video/*、audio/* 通配符 (fixes #4237) (#4239)
isAcceptType 此前仅对 image/* 走扩展名白名单校验,video/*、audio/* 会落入
正则分支,其中的通配符 * 被当作正则量词,导致 mp4、mp3 等合法文件被误拦截。
改为统一的 MIME 通配符映射,复用 FILE_TYPE.VIDEO / FILE_TYPE.AUDIO 白名单。

补充 accept 通配符单元测试,用 BeforeUploadOptions 替代 as any。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:26:43 +08:00
James 72bcdfe803
fix: modify the problem of lazy load root node not triggering in tree menu component vue3 (#4242) 2026-07-21 10:17:11 +08:00
James 5ad55a68bc
fix(grid): modify saas theme (#4246)
* fix(grid): modify saas theme

* fix(grid): add pager variable
2026-07-20 14:18:21 +08:00
James 668b5fb425
feat(tag): [tag] add round attribute (#4247)
* feat(tag): [tag] add round attribute

* fix: supplementary type
2026-07-20 14:17:53 +08:00
James 18f9848f32
feat: the image component supports URL encoding SVG (#4241) 2026-07-07 09:41:10 +08:00
申君健 18c77f6d38
fix: base-select在清除时,不隐藏收起按钮,以及鼠标悬浮时增加zindex值 (#4243) 2026-07-07 09:39:46 +08:00
申君健 9e75faa786
fix(grid): 表格列筛选的属性位置错误 (#4238) 2026-07-06 15:44:03 +08:00
Malusnow 46d9cdd138
fix(vue-renderless/dialog-select):[dialog-select] fix the unsynchronized multi-selection checkboxes when opening for the first time. (#4201) 2026-06-27 16:02:00 +08:00
wuyiping 51d1c276fb
fix(grid): solve the error issue of clearSelected method (#4217) 2026-06-24 09:15:11 +08:00
Yujing613 04e52d2c1a
feat(drawer): add destroyOnClose to drawer (#4153)
* feat(drawer): add destroyOnClose to drawer

* test: add destroy-on-close test cases
docs: update drawer destroy-on-close description

* test(drawer): 完善 destroy-on-close 测试用例

* docs(drawer): update destroy-on-close stable version
2026-06-23 16:18:03 +08:00
wuyiping 569870bf84
feat(form-item): solve the problem that the size attribute of the button in the form component does not take effect (#4206) 2026-06-23 16:16:28 +08:00
wuyiping c8290d13a7
fix(vue-renderless/nav-menu): [nav-menu] 修复页面刷新时菜单偶发隐藏问题 (fixes #4167) (#4228)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:15:33 +08:00
wuyiping 36dfabb86d
feat: fix the issue where the menu cannot be collapsed due to the simultaneous activation of the attributes "menu-collapsible" and "width-adapt" (#4229) 2026-06-23 16:14:59 +08:00
wuyiping ecc0b8163e
feat(nav-menu): fix the issue where before-skip returns false, but the component still updates its active state (#4227) 2026-06-23 16:14:06 +08:00
wuyiping 5ec1ed9a3d
feat: solve the problem of panel data not being cleared during half-select (#4234) 2026-06-23 16:13:07 +08:00
申君健 ff4061250a
fix: 添加grid column 组件的type="operation" 和 operationConfig的文档, 删除无用缓存代码 (#4231)
* fix: 添加grid column 组件的type="operation" 和 operationConfig的文档, 删除无用缓存代码

* fix: 错字
2026-06-23 10:19:09 +08:00
Kagol 54e365bfba
fix(space): remove $createElement reference to fix Vue 3 console warning (#4222)
In Vue 3, $createElement is not defined on the component instance.
Accessing it triggers a Vue warning: Property "$createElement" was
accessed during render but is not defined on instance.

The h function is already imported from @opentiny/vue-common, which
provides a cross-version compatible render function through the adapter
layer. Simply use h directly instead of falling back from $createElement.

Fixes #4209

Co-authored-by: kagol <kagol@opentiny.com>
2026-06-23 10:18:36 +08:00
申君健 62494474c1
fix: 修复属性拼写错误 (#4235) 2026-06-23 10:17:25 +08:00
James 5cae71eb52
fix(grid): update attribute spelling errors (#4236) 2026-06-23 10:16:59 +08:00
申君健 2b00c06ecb
fix: the table height remains unchanged when modifying maxheight (#4216) 2026-06-10 17:17:23 +08:00
申君健 aa6289945a
fix: add getStateSelection api for old code (#4214) 2026-06-10 16:32:42 +08:00
申君健 06bd798ed7
fix(grid): fix the issue where table rows occasionally become out of order after dragging (#4219) 2026-06-10 16:31:55 +08:00
dong 723299ef6a
fix(vue-renderless/tab-nav): delay initial mounted to nextTick (#4220)
* fix(vue-renderless/tab-nav): delay initial mounted to nextTick

Ensure scrollToActiveTab runs after DOM/layout stabilizes on first mount.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vue-renderless/tab-nav): defer mounted init via nextTick in renderless

Use single-layer mounted and call it from onMounted so scroll runs after DOM layout without currying.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:31:43 +08:00
申君健 968e8c5806
fix: fix webkitGetAsEntry() return null (#4226) 2026-06-10 16:30:48 +08:00
gimmyhehe a7c510e2c4
fix: enhance components prop dynamic change (#4225)
* fix: enhance components prop dynamic change

* fix: Sync data changes to state and update timeline

Add call to api.timelineflowData on data change.
2026-06-09 17:10:05 +08:00
gimmyhehe 7a4400d44f
fix(grid): grid event add dynamic support (#4224)
* fix(grid): grid event add dynamic support

* fix(grid): ensure correct merging of event listeners in grid component

* fix(grid): grid event add dynamic support
2026-06-09 10:14:34 +08:00
gimmyhehe ef0251e1f7
fix(form): tooltip and prop support dynamic change (#4221)
* fix(form): tooltip and prop support dynamic change

* fix(form): update tooltip when validate

* fix(form-item): clear validation on prop change to ensure proper state management

* fix(form): watch required and rules change

* fix: Optimize initialValue handling for array types
2026-06-09 10:13:50 +08:00
James ee19b484a8
feat(modal): [modal] added the before-close attribute (#4204)
* feat(modal): [modal] added the before-close attribute

* fix: modify the comments
2026-05-26 19:09:19 +08:00
wuyiping d953756931
fix: supplement development document specifications (#4218)
* fix: supplement development document specifications

* fix: change skill name

* fix: change skill name

* fix: add design.skill.md
2026-05-26 17:35:30 +08:00
申君健 c17736357c
fix: 正在补充harness 文档 (#4213) 2026-05-21 14:13:40 +08:00
申君健 48c18a7adc
fix(form-item): more appropriate modification that aligns with the form-item's intent to influence the width of subordinate elements (#4208) 2026-05-19 15:56:08 +08:00
申君健 d323362039
fix(tooltip): more secure execution of the renderContent function (#4207) 2026-05-19 15:55:51 +08:00
申君健 fab2cac3a5
fix(numeric): add parse-input prop (#4205)
* fix(numeric): add parse-input prop

* fix:  调整一下逻辑

* fix: 删除多余文件
2026-05-19 15:55:23 +08:00
申君健 fff845b60c
fix(input): remove input mf outline, which is black border (#4196)
* fix(input): remove input mf outline, which is black border

* fix: 批量修改一些input 为outline-0 的情况
2026-05-12 16:57:43 +08:00
申君健 9f036e4be3
fix: add size prop for collapse (#4197)
* fix: add size prop for collapse

* fix: 添加版本
2026-05-12 16:27:37 +08:00
James 8ba8ff5e56
fix: modify the bugs in the drawer component and provide additional property testing examples (#4195) 2026-05-11 14:31:23 +08:00
James 61d378842a
fix(space): [space] modify the Space Components (#4184)
* fix: modify the space components

* fix: modify the e2e test

* fix: modify the comments
2026-05-11 14:30:51 +08:00
GaoNeng f75adb36bc
fix(color-picker): auto flip (#4187)
* fix(color-picker): auto flip

* fix(color-picker): e2e test
2026-05-11 14:29:24 +08:00
申君健 dbe5a12b2a
fix(form-item): 多端的form-item上,去控制tiny-cascader 的宽度为w-full, 引起tiny-cascader的所有有类名的元素的宽度都是100% 的bug (#4194) 2026-05-11 14:16:49 +08:00
liukun cfdcfe8ffb
fix:上传组件名group失效 (#4193) 2026-05-11 14:16:13 +08:00
申君健 87c536cc66
fix: fix form-item-inline's padding-top is 12px (#4202) 2026-05-11 11:49:19 +08:00
申君健 9a70c000a0
fix(reset.css): restore [class*=~'@{css-prefix}'] in reset.css (#4203) 2026-05-11 11:48:55 +08:00
申君健 f0b2180eb5
fix: 统一多端的input的禁用色 (#4199) 2026-05-07 14:25:11 +08:00
594 changed files with 14199 additions and 3839 deletions

38
.agents/skills/SKILL.md Normal file
View File

@ -0,0 +1,38 @@
---
name: tinyvue-develop-spec
description: tinyvue组件库开发规范
---
## 使用时机
在当前仓库进行组件的开发,主题开发,国际化开发,测试脚本开发时,必须遵守以下规范。
## 适用场景
- 新增 UI 组件
- 修改现有组件逻辑
- 重构组件架构
- 理解 Renderless 模式
### 重要术语
1. 跨端模板: tiny vue组件库中同一个组件可以有2种组件模板 其中`pc.vue` 面向家用PC浏览器的场景 `mobile-first.vue`是移动优先的浏览器场景可以兼容用户使用手机或PC浏览器
2. 无渲染逻辑层: tiny vue组件库遵从模板和逻辑分离的原则进行开发。模板层只负责绑定变量和函数所有的业务逻辑data 处理、状态计算等)都要放在无渲染逻辑层 `packages/renderless` 目录
## 组件库的架构
| 模块名称 | 代码目录 | 参考规范 | 说明 |
| --------------- | ----------------------------------------------------------------------------- | -------------------------- | -------------------------------------------- |
| 模板层 | `packages/vue` | `./vue.skill.md` | 每个组件的模板,必须在相应的组件目录 |
| 无渲染逻辑层 | `packages/renderless` | `./renderless.skill.md` | 每个组件的逻辑,必须在相应的组件目录 |
| 样式层 | `packages/theme` | `./theme.skill.md` | 每个组件的样式,必须在相应的组件目录 |
| 设计规范层 | `packages/design` | `./design.skill.md` | 交互规范、默认 props、图标与 renderless 扩展 |
| 适配Vue2/Vue3层 | `packages/vue-common` | `./common.skill.md` | 非必要不修改适配层代码 |
| 图标库 | `packages/vue-icon` | `./icon.skill.md` | 图标库 |
| 国际化层 | `packages/vue-locale` | `./i18n-workflow.skill.md` | 组件的国际化 |
| 公用hooks | `packages/vue-hooks` | `./hooks.skill.md` | 跨组件可以复用的hooks函数 |
| 公用指令 | `packages/vue-directive` | `./directive.skill.md` | 跨组件可以复用的指令 |
| utils | `packages/utils` | `./utils.skill.md` | 公用函数 |
| 组件测试 | `examples/sites/demos/pc/app/**/*.spec.ts``packages/vue/src/**/__tests__` | `./testing-guide.skill.md` | e2e测试和单元测试 |
| 文档开发 | `examples/sites/demos/pc/app/**` | `./vue.skill.md` | 每一个组件的 api 和 demo 的开发 |

View File

@ -0,0 +1,186 @@
# @opentiny/vue-common 适配层开发规范
## 适用场景
- 理解 TinyVue 如何同时支持 Vue 2.6 / 2.7 / Vue 3
- 编写或调试组件 `setup`、`$setup`、跨端模板选择
- 使用 `defineComponent`、`hooks`、`directive`、`svg`、`mergeClass` 等适配 API
- 排查「仅在某一 Vue 版本下复现」的问题
## 核心原则
### 1. 包定位
`packages/vue-common` 发布为 `@opentiny/vue-common`,是整个组件库的 **Vue 版本适配与运行时胶水层**
- 通过 `virtual:common/adapter/vue` 在构建时指向 `adapter/vue2` | `vue2.7` | `vue3`
- 向 renderless 提供统一的 `hooks` 对象(即对应 Vue 版本的 API
- 提供 `$setup` / `setup`,连接模板与 renderless
- 提供主题/模式解析、设计规范注入、图标 `svg` 包装等
**修改约束**:表结构变更、新 Vue 版本支持需充分评估;日常组件开发**优先改 renderless / vue 模板**,避免随意改动适配层行为。`SKILL.md` 中亦提示:非必要不修改适配层。
### 2. 目录结构
```text
packages/vue-common/src/
├── index.ts # 对外主入口:$setup、setup、$prefix、svg 等
├── adapter/
│ ├── index.ts # 导出当前 Vue 版本 adapter
│ ├── vue2/
│ ├── vue2.7/
│ └── vue3/
├── breakpoint.ts # useBreakpoint 响应式断点
├── csscls.ts # 类名序列化、去重
├── usedefer.ts # useDefer
└── generateIcon.ts # 渐变图标 id 处理
```
### 3. 组件开发中最常用的 API
| API | 用途 |
| ---------------------------------- | ----------------------------------------------------------------- |
| `$prefix` | 组件名前缀 `'Tiny'`,如 `TinyButton` |
| `$props` / `props` | 框架保留 prop`tiny_mode`、`tiny_renderless`、`tiny_template` 等 |
| `defineComponent` | 定义组件(跨版本) |
| `$setup` | 跨端父组件:根据 `tiny_mode` 选择 `pc` / `mobile-first` 模板 |
| `setup` | 子模板中连接 renderless返回模板绑定对象 |
| `hooks` | 当前 Vue 版本的 API 集合,可传给 renderless 第二参数 |
| `isVue2` / `isVue3` | 版本判断renderless 中应尽量避免,优先用 utils/vm |
| `directive` | 统一 Vue2/3 指令钩子名 |
| `svg` | 包装 `@opentiny/vue-theme` 的 svg 为图标组件 |
| `mergeClass` | mobile-first 下合并 Tailwind 类名 |
| `filterAttrs`(模板中 `a` | 过滤 `$attrs` 绑定 |
| `Teleport` / `KeepAlive` | 须从此包导入以保证兼容 |
| `useBreakpoint` / `useDefer` | 布局与渲染优化 |
| `useInstanceSlots` / `useRelation` | 已注入 `isVue2` 的 hooks 封装 |
### 4. 双层组件:$setup + 子模板 setup
**跨端组件**`pc.vue` + `mobile-first.vue`
```typescript
// packages/vue/src/alert/src/index.ts
import { $props, $prefix, $setup, defineComponent } from '@opentiny/vue-common'
import template from 'virtual-template?pc|mobile-first'
export default defineComponent({
name: $prefix + 'Alert',
props: alertProps,
setup(props, context) {
return $setup({ props, context, template })
}
})
```
`$setup` 会:
1. `resolveMode` 解析 `tiny_mode``pc` | `mobile` | `mobile-first`
2. 通过 `virtual-template` 插件加载对应模板
3. `renderComponent` 渲染子组件,并合并设计规范 `designConfig` 中的默认 props
**具体模板**`pc.vue`
```typescript
import { setup, defineComponent, props } from '@opentiny/vue-common'
import { renderless, api } from '@opentiny/vue-renderless/alert/vue'
import type { IAlertApi } from '@opentiny/vue-renderless/types/alert.type'
export default defineComponent({
props: [...props, 'type', 'size' /* 其它组件 props */],
setup(props, context) {
return setup({ props, context, renderless, api }) as unknown as IAlertApi
}
})
```
### 5. setup 与 renderless 的协作
`setup` 内部流程概要:
1. 选择 `props.tiny_renderless` 或入参 `renderless`
2. 构造 `utils`(含 `vm`、`emit`、`t`、`designConfig`、`mode`、`mergeClass` 等)
3. 调用 `render(props, hooks, utils, extendOptions)` 得到 `sdk`
4. 按 `api` 数组将 `sdk` 上的方法/状态暴露给模板(`attrs`
5. 双层组件默认 `mono: false`,将 api 同步到父组件 ref单层组件传 `mono: true`
模板中常用简写:
- `t('ui.xxx')` — 国际化
- `a($attrs, filters, include)``filterAttrs`
- `m(...)``mergeClass`
- `f` / `d` / `dp` — 过滤器与实例属性定义(见 renderless.skill.md
### 6. 模式与主题解析
- **模式** `resolveMode``tiny_mode` prop > inject `TinyMode` > 全局 config > 默认 `'pc'`
- **主题** `resolveTheme``'tiny'` | `'saas'`
- 根组件可设 `tiny_mode_root``provide('TinyMode', mode)`
函数式组件Modal、Loading、Notify依赖全局 `tiny_mode`,需在应用级配置。
### 7. 设计规范 designConfig
适配层负责注入与合并 `designConfig`,详细约定见 [design.skill.md](./design.skill.md)。
```typescript
import { provideDesignConfig } from '@opentiny/vue-common'
provideDesignConfig({
components: {
Button: { props: { round: true }, api: [], renderless: fn }
}
})
```
`setup` 会合并 `designConfig.renderless` 与组件级 props 默认值。
### 8. 图标 svg 工厂
```typescript
import { svg } from '@opentiny/vue-common'
import IconX from '@opentiny/vue-theme/svgs/icon-x.svg'
export default () => svg({ name: 'IconX', component: IconX, filledComponent: IconX })()
```
详见 [icon.skill.md](./icon.skill.md)。
### 9. 版本判断与 hooks 使用
```typescript
// ❌ renderless 中
if (process.env.VUE_VERSION === '3') { ... }
// ✅ 模板/极少数适配代码
import { isVue2, hooks } from '@opentiny/vue-common'
// ✅ renderless 接收第二参数
export const renderless = (props, { computed, reactive, watch }, utils) => { ... }
```
### 10. 禁止事项
- ❌ 组件模板中不要 `import from 'vue'`(使用 `@opentiny/vue-common``defineComponent`、`hooks`、`Teleport` 等)
- ❌ renderless 中不要依赖适配层实现细节(仅使用文档化的 `utils` / `vm` 字段)
- ❌ 不要随意修改 `adapter/` 下生命周期映射逻辑
- ❌ 不要在 `.vue` 中写复杂业务逻辑,应下沉 renderless
## 与其它规范的关系
| 模块 | 规范文件 |
| ---------- | -------------------------------------------- |
| 模板层 | [vue.skill.md](./vue.skill.md) |
| 逻辑层 | [renderless.skill.md](./renderless.skill.md) |
| 样式 | [theme.skill.md](./theme.skill.md) |
| 设计规范 | [design.skill.md](./design.skill.md) |
| 工具函数 | [utils.skill.md](./utils.skill.md) |
| 组合式逻辑 | [hooks.skill.md](./hooks.skill.md) |
| 指令 | [directive.skill.md](./directive.skill.md) |
## 参考资源
- [主入口 setup / $setup](../../packages/vue-common/src/index.ts)
- [Vue3 适配器](../../packages/vue-common/src/adapter/vue3/index.ts)
- [Alert 跨端入口](../../packages/vue/src/alert/src/index.ts)
- [Alert pc 模板 setup](../../packages/vue/src/alert/src/pc.vue)

View File

@ -0,0 +1,261 @@
# TinyVue 设计规范Design开发指南
## 适用场景
- 为 Aurora / SaaS / 企业自定义规范配置组件默认行为
- 通过 `design` 覆盖图标、默认 props、renderless 扩展逻辑
- 使用 `TinyConfigProvider` 在应用级注入交互规范
- 在 renderless 中读取 `designConfig` 实现规范差异
## 与 Theme 的区别
| 维度 | Design本规范 | Theme见 [theme.skill.md](./theme.skill.md) |
| ---------- | -------------------------------------------- | ---------------------------------------------- |
| 职责 | 交互行为、默认 props、图标、renderless 扩展 | 视觉样式、CSS 变量、Less |
| 代码位置 | `packages/design/*`、`designConfig` | `packages/theme`、`packages/theme-saas` |
| 注入方式 | `TinyConfigProvider` / `provideDesignConfig` | 引入 Less、`theme` prop、ThemeTool |
| 运行时对象 | `utils.designConfig`renderless 第三参数) | `tiny_theme`、`--tv-*` CSS 变量 |
二者可组合使用SaaS 场景常见 `tiny_theme="saas"` + `@opentiny/vue-design-saas`
## 架构概览
```text
packages/design/
├── aurora/ # Aurora 规范(@opentiny/vue-design-aurora
│ ├── index.ts # 导出 { name, version, components }
│ └── src/<Component>/index.ts
├── saas/ # SaaS 规范(@opentiny/vue-design-saas
│ └── src/<Component>/index.ts
└── smb/ # SMB 示例规范(文档 demo 用)
packages/vue/src/config-provider/ # TinyConfigProvider
packages/vue-common/src/index.ts # provideDesignConfig、getDesignConfig、setup 合并逻辑
packages/vue-saas-common/ # 预置 customDesignConfig.designConfig = designSaasConfig
```
全局配置结构(`DesignConfig`
```typescript
{
name?: string // 规范名称,如 'saas'
version?: string // 规范版本
components?: {
[ComponentName: string]: IComponentDesignConfig // 键名不含 Tiny 前缀,如 Button、Select、Alert
}
}
```
单组件配置(内部类型 `IComponentDesignConfig`,定义于 `packages/renderless/types/shared.type.ts`,未对外 export常用字段
| 字段 | 说明 |
| ------------ | ---------------------------------------------------------------------------------------- |
| `props` | 默认 props用户未传时才覆盖仅在跨端父组件 **`$setup`** 中合并到 `customDesignProps` |
| `icons` | 图标映射,如 `{ warning: iconWarning() }` |
| `state` | 规范级状态默认值renderless 内通过 `designConfig.state` 读取 |
| `renderless` | 扩展函数,在组件 renderless 执行后合并进 `sdk` |
| `api` | 扩展暴露给模板的 api 名称列表(与组件 `api` 数组合并) |
| 其它 | 组件自定义字段,如 `baseOpts`、`messageType`、`showText` 等 |
## 应用侧使用
### 1. ConfigProvider推荐
```vue
<template>
<tiny-config-provider :design="design">
<tiny-alert type="warning" />
<tiny-button>按钮</tiny-button>
</tiny-config-provider>
</template>
<script setup>
import { TinyConfigProvider, TinyAlert, TinyButton } from '@opentiny/vue'
import { iconWarningTriangle } from '@opentiny/vue-icon'
const design = {
name: 'x-design',
version: '1.0.0',
components: {
Alert: {
icons: { warning: iconWarningTriangle() },
props: { center: true },
renderless: (props, hooks, utils, api) => ({
handleClose() {
api.state.show = false
utils.emit('close')
}
})
},
Button: {
props: { round: true, resetTime: 0 }
}
}
}
</script>
```
`TinyConfigProvider` 内部调用 `provideDesignConfig(design)`,子树组件通过 `inject` 获取。
### 2. 内置规范包SaaS / Aurora
SaaS 工程使用 `@opentiny/vue-saas-common` 时已注入:
```typescript
// packages/vue-saas-common/src/index.ts
import { customDesignConfig } from '@opentiny/vue-common'
import designSaasConfig from '@opentiny/vue-design-saas'
customDesignConfig.designConfig = designSaasConfig
```
Aurora 对应 `@opentiny/vue-design-aurora`,在应用入口赋值 `customDesignConfig.designConfig`(写法同 SaaS
> **注意**`@opentiny/vue-saas-common` 启动时已写入 `customDesignConfig.designConfig`,会**优先于**子树 `TinyConfigProvider``inject` 配置。应用级临时覆盖需改 `customDesignConfig`,或勿使用 `vue-saas-common` 的全局注入。普通 `@opentiny/vue` 场景下 `customDesignConfig.designConfig` 默认为 `null`,以 `ConfigProvider``provide` 为准。
### 3. 编程式注入
须在组件 `setup` 中调用(需 Vue 上下文):
```typescript
import { provideDesignConfig } from '@opentiny/vue-common'
export default {
setup() {
provideDesignConfig({
components: {
Form: { props: { hideRequiredAsterisk: true } }
}
})
}
}
```
`TinyConfigProvider` 传入的 `design` 为响应式 ref`getDesignConfig` 会通过 `.value` 解包后读取。
## 运行时解析流程
1. `getDesignConfig()`**优先** `customDesignConfig.designConfig`,否则 `inject(design.configKey, {})`;若结果为 ref/computed 则取 `.value`
2. 按当前组件名去掉 `Tiny` 前缀(`getComponentName().replace('Tiny', '')`),取 `globalDesignConfig.components[ComponentName]`
3. **`$setup`**:合并 `designConfig.props` 为默认 props不覆盖用户已传属性
4. **`setup`**
- 将 `designConfig`、`globalDesignConfig` 放入 renderless 第三参数 `utils`
- 执行组件 `renderless` 得到 `sdk`
- 若存在 `designConfig.renderless`,将其返回值 `Object.assign``sdk`
- 若 `designConfig.api` 存在,与组件 `api` 数组合并后暴露给模板
组件名映射示例:`TinySelect` → `Select``TinyAlert` → `Alert`
## 在 renderless 中使用 designConfig
### 1. 从 utils 解构(推荐)
```typescript
export const renderless = (
props: IAlertProps,
hooks: ISharedRenderlessParamHooks,
{ designConfig, t, emit }: IAlertRenderlessParamUtils
): IAlertApi => {
// ...
}
// index.ts 纯函数
export const computedGetIcon =
({ constants, props, designConfig }: Pick<IAlertRenderlessParams, 'constants' | 'props' | 'designConfig'>) =>
() => {
const designIcon = designConfig?.icons?.[props.type]
return props.icon || designIcon || constants.ICON_MAP[props.type]
}
```
### 2. 读取 state / props 级配置
```typescript
// select/vue.ts 示例
autoHideDownIcon: (() => {
if (designConfig?.state && 'autoHideDownIcon' in designConfig.state) {
return designConfig.state.autoHideDownIcon
}
return true
})(),
designConfig // 挂到 state供模板 state.designConfig?.icons 使用
```
### 3. designConfig.renderless 扩展
规范包可覆盖或增补 api 方法,**不得**破坏原有 `api.state` 引用:
```typescript
// packages/design/saas/src/select/index.ts
// 签名:(props, hooks, utils, sdk) => Partial<api>
renderless: (props, hooks, utils, api) => {
const state = api.state
return {
toggleCheckAll: (filtered) => {
/* 规范定制逻辑 */
},
computedShowTagText: () => state.isDisabled || state.isDisplayOnly
}
}
```
适配层实际调用:`Object.assign(sdk, designConfig.renderless(props, hooks, utils, sdk))`,第三参为完整 `utils`(含 `emit`、`designConfig`、`vm` 等),第四参为组件 renderless 已返回的 `sdk`
## 新增 / 修改设计规范
### 在 packages/design 中增加组件配置
1. 在 `packages/design/saas/src/<component>/index.ts`(或 `aurora`)新增默认导出对象
2. 在对应 `index.ts``components` 中注册键名与组件名一致PascalCase`Tiny` 前缀)
3. 图标优先使用对应主题的 icon 包SaaS 用 `@opentiny/vue-icon-saas`
4. 在 renderless 中增加对 `designConfig` 的可选读取,并提供合理默认值(无 design 时行为不变)
示例SaaS Alert 仅换图标):
```typescript
// packages/design/saas/src/alert/index.ts
import { iconWarning } from '@opentiny/vue-icon-saas'
export default {
icons: {
warning: iconWarning()
}
}
```
### 在组件 renderless 中支持新 design 字段
1. 在 `index.ts` 纯函数中增加 `designConfig` 参数类型(`Pick<..., 'designConfig'>`
2. 使用可选链与 `in` 判断,避免假设 design 一定存在
3. 模板需访问时,将 `designConfig` 挂到 `state`(参考 Select
## mobile-first 与 twMerge
SaaS 多端模式依赖 `customDesignConfig.twMerge`(通常 `tailwind-merge`
```typescript
import { customDesignConfig } from '@opentiny/vue-common'
import { twMerge } from 'tailwind-merge'
customDesignConfig.twMerge = twMerge
```
`mergeClass`(模板中的 `m(...)`)会经此函数合并 Tailwind 类名。
## 禁止事项
- ❌ 不要用 design 配置颜色/尺寸等纯样式(应走 theme / CSS 变量)
- ❌ 不要在 design 的 `components` 键名中带 `Tiny` 前缀
- ❌ 不要在 `designConfig.renderless` 中替换整个 `api``state` 对象
- ❌ 不要在 renderless 中 `import` 规范包;规范由应用或 `vue-saas-common` 注入
- ❌ 不要假设 `designConfig` 一定存在,必须提供默认行为
## 参考资源
- [ConfigProvider 组件](../../packages/vue/src/config-provider/src/index.vue)
- [适配层 design 逻辑](../../packages/vue-common/src/index.ts)
- [类型定义](../../packages/renderless/types/shared.type.ts)
- [SaaS Select 规范示例](../../packages/design/saas/src/select/index.ts)
- [文档 demo](../../examples/sites/demos/pc/app/config-provider/base-composition-api.vue)
- [主题规范](./theme.skill.md)
- [适配层规范](./common.skill.md)

View File

@ -0,0 +1,162 @@
# @opentiny/vue-directive 开发规范
## 适用场景
- 模板层需要声明式 DOM 行为(点击外部关闭、无限滚动、文本高亮、自动 Tooltip 等)
- 逻辑与具体组件解耦,可在多个 `pc.vue` / `mobile-first.vue` 中复用
- 不适合放入 renderless 的、强依赖 DOM 或全局监听的交互
## 核心原则
### 1. 包定位
`packages/vue-directive` 发布为 `@opentiny/vue-directive`
- 指令实现放在 `src/<directive-name>.ts`
- 在 `index.ts` 统一导出
- 可依赖 `@opentiny/utils`、`@opentiny/vue-common`(如 `auto-tip` 使用 Tooltip 与 `hooks`
- **业务逻辑仍应优先下沉 renderless**;指令只负责 DOM 绑定与事件桥接
### 2. 内置指令一览
| 导出 | 指令名 | 说明 |
| ------------------- | ---------------------- | ----------------------------------------------------------- |
| `Clickoutside` | `v-clickoutside` | 点击元素外部触发回调,支持 `.mousedown` / `.mouseup` 修饰符 |
| `AutoTip` | `v-auto-tip` | 文本溢出时自动展示 Tooltip |
| `InfiniteScroll` | `v-infinite-scroll` | 滚动到底加载更多 |
| `HighlightQuery` | `v-highlight-query` | 高亮匹配关键字 |
| `ObserveVisibility` | `v-observe-visibility` | 元素可见性监听 |
| `RepeatClick` | `v-repeat-click` | 长按/重复点击 |
文档示例:[自定义指令](https://opentiny.design/tiny-vue/zh-CN/smb-theme/components/directives-auto-tip)
### 3. 在组件模板中注册(必须)
指令在 **vue 模板层** 注册,不在 renderless 中注册。
**Vue 3 写法**(推荐通过 `directive` 适配函数兼容 Vue 2 生命周期名):
```vue
<script lang="ts">
import { setup, directive, defineComponent } from '@opentiny/vue-common'
import { Clickoutside, AutoTip } from '@opentiny/vue-directive'
export default defineComponent({
directives: directive({ Clickoutside, AutoTip }),
setup(props, context) {
return setup({ props, context, renderless, api })
}
})
</script>
<template>
<div v-clickoutside="handleClose" v-auto-tip>...</div>
</template>
```
**仅含 Vue 2 钩子(`bind`/`inserted`/`unbind`)的指令**,应通过 `directive({ ... })` 包装。部分指令(如 `ObserveVisibility`、`HighlightQuery`)已在实现内同时声明 Vue 2 / Vue 3 钩子,可直接注册:`directives: { ObserveVisibility }`。
`directive` 辅助函数位于适配层,用于统一 Vue 2 / Vue 3 指令钩子名:
```typescript
// packages/vue-common/src/adapter/vue3/index.ts
mapping(content, 'bind', 'beforeMount')
mapping(content, 'inserted', 'mounted')
mapping(content, 'update', 'updated')
mapping(content, 'unbind', 'unmounted')
```
### 4. 指令实现规范
#### 指令钩子写法
**推荐(新指令)**:实现 `bind` / `update` / `unbind`,由 `directive()` 映射为 Vue 3 的 `beforeMount` / `updated` / `unmounted`
**特例 `RepeatClick`**:导出为函数而非对象,需手动包装:
```typescript
import { RepeatClick } from '@opentiny/vue-directive'
directives: {
repeatClick: {
bind: RepeatClick
} // 模板中使用 v-repeat-click
}
```
**特例 `HighlightQuery` / `ObserveVisibility`**:实现内已同时声明 Vue 2 / Vue 3 钩子,可直接 `directives: { HighlightQuery }`,无需再经 `directive()` 转换。
新指令推荐模板:
```typescript
import { on, isServer } from '@opentiny/utils'
export default {
bind(el, binding, vnode) {
// 初始化
},
update(el, binding, vnode) {
// 更新 binding
},
unbind(el) {
// 清理监听、移除 DOM 副作用
}
}
```
#### 全局监听
若需 `document` 级监听(如 `Clickoutside`),在模块顶层用 `isServer` 守卫,维护共享 `nodeList`,在 `unbind` 中务必移除引用,防止泄漏。
#### Shadow DOM
点击外部判断需使用 `event.composedPath()`,参考 `clickoutside.ts`
### 5. 典型用法示例
**Clickoutside**
```html
<!-- 默认:外部按下并松开才触发 -->
<div v-clickoutside="handleClose"></div>
<!-- 修饰符 -->
<div v-clickoutside.mousedown="handleClose"></div>
<div v-clickoutside.mouseup="handleClose"></div>
```
**AutoTip**
```html
<div v-auto-tip>长文本...</div>
<div v-auto-tip="{ content: '自定义', placement: 'top', effect: 'dark' }"></div>
```
绑定值为 `false` / 空时禁用。模板内文字节点需用标签包裹,且避免指令节点直接包含裸文本节点(详见 `highlight-query.ts` 注释)。
**InfiniteScroll**
```html
<div v-infinite-scroll="loadMore" :infinite-scroll-disabled="loading"></div>
```
### 6. 新增指令流程
1. 在 `packages/vue-directive/src/` 新增实现,默认导出指令对象
2. 在 `packages/vue-directive/index.ts` 具名导出
3. 在使用的 `pc.vue` / `mobile-first.vue``import` 并通过 `directives: directive({ ... })` 注册
4. 优先使用 `@opentiny/utils``on`/`off`、`throttle`、`getScrollContainer` 等
5. 若需组件方法,通过 `binding.expression``binding.value``vnode.context` 通信Vue 3 项目注意与 `setup` 返回方法的兼容方式,保持与现有指令一致)
6. 在 `examples/sites/demos` 补充演示(若对用户可见)
### 7. 禁止事项
- ❌ 不得在 renderless 的 `index.ts` 中注册或使用 `v-*` 指令
- ❌ 不得在指令中编写组件级业务状态机(应通过 binding 调用 renderless 暴露的方法)
- ❌ 不得跳过 `unbind` 清理
- ❌ 新增指令时避免直接 `import from 'vue'`DOM 工具走 `@opentiny/utils`,组件/渲染走 `@opentiny/vue-common`
## 参考资源
- [Clickoutside](../../packages/vue-directive/src/clickoutside.ts)
- [AutoTip](../../packages/vue-directive/src/auto-tip.ts)
- [select pc 模板注册示例](../../packages/vue/src/select/src/pc.vue)

View File

@ -0,0 +1,155 @@
# @opentiny/vue-hooks 开发规范
## 适用场景
- 多个组件共享的组合式逻辑Popper、弹层、父子关系、窗口尺寸等
- renderless 的 `vue.ts` 中需要 DOM/生命周期相关能力,但逻辑仍希望可测试、可复用
- 不宜放入 `@opentiny/utils` 的、需要 Vue 生命周期或响应式 API 的逻辑
## 核心原则
### 1. 包定位
`packages/vue-hooks` 发布为 `@opentiny/vue-hooks`
- **可以**依赖 `@opentiny/utils`
- **不得**直接 `import from 'vue'`——Vue API 由调用方从 `hooks` 解构后传入(柯里化或直接传参,见下文)
- renderless 与 vue-common 通过 renderless 第二参数或合并后的 `hooks` 对象实例化
### 2. 现有 Hooks 一览
| 导出 | 文件 | 用途 |
| ------------------ | --------------------- | -------------------------------------------- |
| `useEventListener` | `useEventListener.ts` | 绑定/自动清理 DOM 事件,支持 target 为 ref |
| `useWindowSize` | `useWindowSize.ts` | 窗口宽高响应式 |
| `useRect` | `useRect.ts` | 元素尺寸与位置 |
| `useTouch` | `useTouch.ts` | 触摸手势 |
| `useUserAgent` | `useUserAgent.ts` | UA / 浏览器能力判断 |
| `useInstanceSlots` | `useInstanceSlots.ts` | 插槽访问vue-common 已预绑定 `isVue2` |
| `useRelation` | `useRelation.ts` | 父子组件关系树Tabs、Form 等) |
| `useFloating` | `use-floating.ts` | 浮层定位 |
| `useLazyShow` | `use-lazy-show.ts` | 延迟展示 |
| `userPopper` | `vue-popper.ts` | Popper 弹层(注意导出名拼写为 `userPopper` |
| `usePopup` | `vue-popup.ts` | Popup 弹层管理 |
### 3. 两种调用形态
#### 形态 A柯里化多数 hooks
第一个参数为 Vue 运行时 API 集合,返回可在 renderless 中调用的函数。适用于 `useEventListener`、`useRelation`、`useRect` 等。
```typescript
// packages/vue-hooks/src/useEventListener.ts
import { on, off, isServer } from '@opentiny/utils'
export const useEventListener =
({ unref, isRef, watch, nextTick, onMounted, onUnmounted, onActivated, onDeactivated }) =>
(type, listener, options = {}) => {
if (isServer) return
// ... 实现
}
```
在 renderless 的 `vue.ts` 中使用:
```typescript
import { useEventListener } from '@opentiny/vue-hooks'
export const renderless = (props, hooks, utils) => {
const addListener = useEventListener(hooks)
addListener('scroll', onScroll, { target: scrollRef })
// ...
}
```
在 vue-common 中预绑定 Vue2 差异的示例:
```typescript
import { useRelation as createUseRelation } from '@opentiny/vue-hooks'
import hooks from './adapter'
export const useRelation = createUseRelation({ ...hooks, isVue2 })
```
#### 形态 B直接工厂`userPopper`、`usePopup`
接收合并了 Vue API 与业务上下文的对象,**不是**柯里化:
```typescript
import { userPopper } from '@opentiny/vue-hooks'
export const renderless = (
props,
{ watch, reactive, onBeforeUnmount, onDeactivated, nextTick, toRefs },
{ vm, slots, emit }
) => {
const popper = userPopper({
emit,
nextTick,
onBeforeUnmount,
onDeactivated,
props,
reactive,
vm,
slots,
toRefs,
watch
})
const state = initState({ reactive, popper })
// 返回 updatePopper、destroyPopper、doDestroy 及 popper 相关 state 字段
}
```
`usePopup` 用法见 `dialog-box/vue.ts`,返回 `{ open, close, PopupManager, ...toRefs(state) }`
### 4. useRelation 使用要点
用于「父收集子、子注册到父」的场景。典型写法(参考 `tabs-mf/vue.ts`
```typescript
import { useRelation } from '@opentiny/vue-hooks'
Object.assign(api, { useRelation: useRelation(hooks) })
api.useRelation({
relationKey: `tabs-${state.tabsId}`,
relationContainer: () => vm.$el.querySelector('[data-tag=tiny-tabs-hidden]'),
childrenKey: 'childTabs',
onChange: () => api.onRelationChange()
})
// 返回 { children, index, delivery }
```
- `relationKey` 必须在父子树中一致
- 子组件通过 inject 注册,在 `onUnmounted` 时自动 `unlink`
- 也可从 `@opentiny/vue-common` 导入已绑定 `isVue2``useRelation`
### 5. userPopper / usePopup
Popper、Dialog、Select 等浮层在 renderless `vue.ts` 中按**形态 B**调用,将 `popper` / `usePopup` 传入 `initState``initApi`,勿 `Object.assign` 到整个 `api`
内部依赖 `@opentiny/utils``PopupManager`、`PopperJS`、`on`/`off` 等,勿在 renderless 重复实现定位逻辑。
### 6. 新增 Hook 流程
1. 在 `packages/vue-hooks/src/` 新建实现文件
2. 在 `packages/vue-hooks/index.ts` 导出
3. 仅使用 `@opentiny/utils` 处理 DOM/工具Vue API 一律从参数解构
4. 在 renderless 或 vue-common 中接入并补充类型(`packages/vue-hooks/types/shared.type.ts` 若需扩展)
5. 避免与 utils 中「待改造成 hooks」的模块重复`touch`
### 7. 禁止事项
- ❌ 不得在 vue-hooks 中 `import { ref } from 'vue'`
- ❌ 不得在 hook 内写组件模板或 JSX
- ❌ 不得被 `packages/utils` 反向依赖
- ❌ renderless 的 `index.ts` 纯函数文件中不宜直接调用 vue-hooks应在 `vue.ts` 中组装)
## 参考资源
- [入口导出](../../packages/vue-hooks/index.ts)
- [useEventListener](../../packages/vue-hooks/src/useEventListener.ts)
- [useRelation](../../packages/vue-hooks/src/useRelation.ts)
- [sticky 组件中的组合使用](../../packages/renderless/src/sticky/vue.ts)
- [tabs-mf 中 useRelation](../../packages/renderless/src/tabs-mf/vue.ts)
- [select-dropdown 中 userPopper](../../packages/renderless/src/select-dropdown/vue.ts)

View File

@ -0,0 +1,484 @@
# TinyVue 国际化工作流
## 适用场景
- 为新组件添加多语言支持
- 翻译现有组件的文案
- 新增支持的语言包
- 统一术语翻译
## 核心原则
### 1. 语言文件结构
TinyVue 使用基于 key-value 的多语言系统:
```
packages/vue-locale/src/lang/
├── zh-CN.ts # 简体中文
├── en-US.ts # 英文(美国)
├── ja-JP.ts # 日文
└── ...
```
每个语言文件结构:
```typescript
// packages/vue-locale/src/lang/zh-CN.ts
export default {
ui: {
// 组件名称小写
button: {
confirm: '确定',
cancel: '取消'
},
input: {
placeholder: '请输入',
clear: '清空',
more: '更多',
detail: '详情',
close: '关闭'
}
}
}
```
### 2. 命名规范
**Key 命名规则:**
- 使用 `camelCase`
- 按组件分组:`ui.<component>.<key>`
- 保持语义清晰,避免缩写
**示例:**
```typescript
// ✅ 正确
ui.datePicker.confirm
ui.grid.selectAll
ui.input.clear
// ❌ 错误
ui.dp.ok
ui.g.sel_all
ui.input.clr
```
### 3. 翻译一致性
同一概念在不同组件中使用相同翻译:
| 中文 | 英文 | 使用场景 |
| -------- | --------- | ---------------- |
| 确定 | Confirm | 对话框、确认操作 |
| 取消 | Cancel | 对话框、取消操作 |
| 保存 | Save | 表单提交 |
| 删除 | Delete | 删除操作 |
| 编辑 | Edit | 编辑操作 |
| 搜索 | Search | 搜索功能 |
| 重置 | Reset | 重置表单 |
| 加载更多 | Load More | 分页加载 |
## 标准流程
### 步骤 1在组件中使用国际化
#### Renderless 层
```typescript
// packages/renderless/src/my-component/index.ts
import { t } from '@opentiny/vue-locale'
export const api = ({ state, props }) => {
return {
getPlaceholder: () => {
return props.placeholder || t('ui.myComponent.placeholder')
}
}
}
```
#### Vue 视图层
```vue
<!-- packages/vue/src/my-component/src/mobile-first.vue -->
<script lang="ts">
import { useLocale } from '@opentiny/vue-locale'
export default defineComponent({
setup() {
const { t } = useLocale()
return {
t
}
}
})
</script>
<template>
<input :placeholder="t('ui.myComponent.placeholder')" />
<button>{{ t('ui.myComponent.confirm') }}</button>
</template>
```
### 步骤 2添加语言包条目
#### 中文zh-CN.ts
```typescript
// packages/vue-locale/src/lang/zh-CN.ts
export default {
ui: {
myComponent: {
placeholder: '请输入内容',
confirm: '确定',
cancel: '取消',
clear: '清空',
noData: '暂无数据',
loading: '加载中...',
error: '加载失败,请重试'
}
}
}
```
#### 英文en-US.ts
```typescript
// packages/vue-locale/src/lang/en-US.ts
export default {
ui: {
myComponent: {
placeholder: 'Please enter content',
confirm: 'Confirm',
cancel: 'Cancel',
clear: 'Clear',
noData: 'No data available',
loading: 'Loading...',
error: 'Failed to load, please try again'
}
}
}
```
### 步骤 3验证翻译完整性
确保所有语言包包含相同的 key
```bash
# 运行检查脚本(如果有的话)
pnpm check:i18n
# 或手动对比
diff packages/vue-locale/src/lang/zh-CN.ts packages/vue-locale/src/lang/en-US.ts
```
## 代码示例
### 动态文本插值
```typescript
// 带参数的翻译
t('ui.pagination.total', { total: 100 })
// 语言文件中定义
pagination: {
total: '共 {total} 条' // zh-CN
total: 'Total {total} items' // en-US
}
```
### 条件翻译
```vue
<template>
<span>{{
state.count === 0 ? t('ui.myComponent.noItems') : t('ui.myComponent.itemsCount', { count: state.count })
}}</span>
</template>
```
### 完整组件示例
```typescript
// packages/renderless/src/pagination/index.ts
import { t } from '@opentiny/vue-locale'
export const api = ({ state, props }) => {
return {
getTotalText: () => {
return t('ui.pagination.total', { total: state.total })
},
getJumpText: () => {
return t('ui.pagination.jumpTo')
},
getPageText: (page: number) => {
return t('ui.pagination.page', { page })
}
}
}
```
```vue
<!-- packages/vue/src/pagination/src/mobile-first.vue -->
<template>
<div class="tiny-pagination">
<span>{{ getTotalText() }}</span>
<button @click="prev">{{ t('ui.pagination.prev') }}</button>
<button @click="next">{{ t('ui.pagination.next') }}</button>
<span>{{ getJumpText() }} <input v-model="jumpPage" /></span>
</div>
</template>
<script lang="ts">
import { renderless, api } from '@opentiny/vue-renderless/pagination/vue'
import { props, setup, defineComponent } from '@opentiny/vue-common'
import { useLocale } from '@opentiny/vue-locale'
export default defineComponent({
name: 'TinyPagination',
props: [...props, 'total', 'pageSize'],
emits: ['update:currentPage', 'change'],
setup(props, context) {
const { t } = useLocale()
const renderlessResult = setup({ props, context, renderless, api })
return {
...renderlessResult,
t
}
}
})
</script>
```
## 常见陷阱
### ❌ 错误做法
1. **硬编码文本**
```vue
<!-- ❌ 错误 -->
<button>确定</button>
<!-- ✅ 正确 -->
<button>{{ t('ui.button.confirm') }}</button>
```
2. **拼接翻译字符串**
```typescript
// ❌ 错误
const text = t('ui.msg.prefix') + ' ' + value + ' ' + t('ui.msg.suffix')
// ✅ 正确
const text = t('ui.msg.complete', { value })
// 语言文件complete: '{value} 已成功处理'
```
3. **遗漏某些语言包**
```typescript
// ❌ 错误 - 只添加了中文
// zh-CN.ts
myComponent: {
label: '标签'
}
// en-US.ts - 忘记添加
// myComponent: { label: 'Label' } ← 缺失
// ✅ 正确 - 同时更新所有语言包
```
4. **翻译不一致**
```typescript
// ❌ 错误 - 不同组件用不同翻译
// button.ts: confirm: '确定'
// dialog.ts: ok: '确认'
// ✅ 正确 - 统一使用
// button.ts: confirm: '确定'
// dialog.ts: confirm: '确定'
```
### ✅ 最佳实践
1. **使用常量管理 Key**
```typescript
// packages/vue-locale/src/keys.ts
export const I18N_KEYS = {
BUTTON_CONFIRM: 'ui.button.confirm',
BUTTON_CANCEL: 'ui.button.cancel',
INPUT_PLACEHOLDER: 'ui.input.placeholder'
} as const
// 使用时
t(I18N_KEYS.BUTTON_CONFIRM)
```
2. **提供默认值**
```typescript
// 如果翻译缺失,使用默认值
t('ui.myComponent.label', { default: 'Default Label' })
```
3. **懒加载语言包**
```typescript
// 按需加载,减小初始包体积
const loadLocale = async (lang: string) => {
return import(`@/lang/${lang}.ts`)
}
```
4. **记录翻译上下文**
```typescript
// 添加注释说明使用场景
myComponent: {
// 用于下拉框的空状态提示
noData: '暂无数据',
// 用于加载状态的简短提示
loading: '加载中...'
}
```
## 翻译质量检查清单
添加新翻译时,确认:
### 准确性
- [ ] 翻译准确表达原意
- [ ] 符合目标语言的语法习惯
- [ ] 专业术语使用行业标准译法
- [ ] 没有机器翻译的生硬感
### 一致性
- [ ] 与现有翻译风格一致
- [ ] 相同概念使用相同译文
- [ ] 标点符号使用规范统一
- [ ] 大小写遵循目标语言规范
### 完整性
- [ ] 所有语言包都已更新
- [ ] 没有遗漏任何 key
- [ ] 参数占位符格式正确
- [ ] 特殊字符已转义
### 技术正确性
- [ ] Key 命名符合规范
- [ ] 没有硬编码文本
- [ ] 插值参数使用正确
- [ ] 类型定义已更新(如需要)
## 常用术语对照表
### 通用操作
| 中文 | 英文 | 备注 |
| ---- | --------------- | ---------- |
| 确定 | Confirm | 确认操作 |
| 取消 | Cancel | 取消操作 |
| 保存 | Save | 保存数据 |
| 删除 | Delete | 删除项目 |
| 编辑 | Edit | 编辑内容 |
| 新建 | New / Create | 创建新项目 |
| 修改 | Modify / Update | 更新现有项 |
| 查询 | Search / Query | 搜索功能 |
| 重置 | Reset | 恢复默认 |
| 提交 | Submit | 提交表单 |
### 状态提示
| 中文 | 英文 | 备注 |
| ------ | ----------- | -------- |
| 成功 | Success | 操作成功 |
| 失败 | Failed | 操作失败 |
| 警告 | Warning | 警告信息 |
| 错误 | Error | 错误信息 |
| 加载中 | Loading | 加载状态 |
| 已完成 | Completed | 完成状态 |
| 进行中 | In Progress | 进行状态 |
### 数据相关
| 中文 | 英文 | 备注 |
| -------- | ------------- | --------- |
| 暂无数据 | No Data | 空状态 |
| 加载更多 | Load More | 分页加载 |
| 全部 | All | 全选/全部 |
| 当前页 | Current Page | 分页信息 |
| 共 X 条 | Total X Items | 总数统计 |
| 第 X 页 | Page X | 页码显示 |
## 新增语言支持
如需添加新语言(如法语):
### 步骤 1创建语言文件
```typescript
// packages/vue-locale/src/lang/fr-FR.ts
export default {
ui: {
button: {
confirm: 'Confirmer',
cancel: 'Annuler'
}
// ... 复制其他组件的翻译
}
}
```
### 步骤 2注册语言
```typescript
// packages/vue-locale/src/index.ts
import frFR from './lang/fr-FR'
export const locales = {
'zh-CN': zhCN,
'en-US': enUS,
'fr-FR': frFR // 新增
}
```
### 步骤 3更新类型定义
```typescript
// packages/vue-locale/src/types.ts
export type Locale = 'zh-CN' | 'en-US' | 'fr-FR' // 添加新语言
```
## 参考资源
- [Vue I18n 官方文档](https://vue-i18n.intlify.dev/)
- [语言包目录](../../packages/vue-locale/src/lang/)
- [dialog-box 翻译示例](../../packages/vue-locale/src/lang/zh-CN.ts)
- [modal.js 翻译规范](../../AGENTS.md#代码注释规范)
- [API 文档翻译规范](../../CONTRIBUTING.md)

View File

@ -0,0 +1,36 @@
# ICON 开发规范
## 核心原理
所有的图标原始文件在 `packages/theme/src/svgs` 目录中每个svg文件对应一个图标图标名字为小写连字符的格式。
图标库的位置在 `packages\vue-icon` 它依赖 `@opentiny/vue-theme`包,通过`vite-svg-loader`插件引用svg文件转为标准的Vue组件。
图标库中的每一个图标都是一个函数,函数中调用适配层的 `svg`函数包装组件一下返回一个标准的Vue组件。
```typescript
import { svg } from '@opentiny/vue-common'
import Acceptance from '@opentiny/vue-theme/svgs/acceptance.svg'
export default () => svg({ name: 'IconAcceptance', component: Acceptance, filledComponent: Acceptance })()
```
`svg` 函数的路径为: `packages\vue-common\src\index.ts`
## 图标库的开发步骤
1. 设计师提供原始的svg图标
将图标放到 `packages/theme/src/svgs` 目录中,修改文件名为小写连字符的格式,该文件名即为最终的图标名称。
2. 自动同步图标脚本文件
`internals\automate` 文件夹中打开终端,然后执行脚本,会将 `packages/theme/src/svgs` 目录中的所有图标同步到`packages\vue-icon`中去。
```bash
pnpm run build-svgs
```
3. 发布图标库
`packages\vue-icon`目录中发布即可。

View File

@ -0,0 +1,274 @@
# 无渲染逻辑层开发规范
无渲染逻辑层中,是将一个组件的内部变量和内部方法整合在一起,返回给模板的使用。该模块不应该依赖第三方包,只能依赖 `@opentiny/utils` ,`@opentiny/vue-hooks` 这两个包。
约定它的必须有2个文件来开发一个组件的逻辑层
### Renderless 架构(必须遵守)
1. 无渲染逻辑层的入口: `packages/renderless/src/<component-name>/vue.ts`
它主要是导出一个 `renderless`的函数变量供适配层调用。 在每一个组件的 `setup`生命周期中,会调用且只调用一次 `renderless`的函数。 适配层会给`renderless`传入相应的参数, `renderless`的函数返回一个全新的上下文对象 {state,api} 。
在`renderless`函数中,除了定义 state,api之外还要处理 `watch`, `computed`等变化监听,也常常要处理 onMounted, onUnmounted 的生命周期需要处理的任务以及组件初始化的准备工作。
renderless 接收四个参数:
第一个参数: props, 组件初始化时Vue运行时生成的props对象
第二个参数: 传入Vue2包 或 Vue3包的导出对象也称其为hooks对象
第三个参数: 适配层生成的组件上下文对象。由于要兼容Vue2/3, 所以使用组件内部的对象时,需要使用该对象。
第四个参数: 这个是可选参数,如果模板中传入 `extendOptions`属性时, 从这个参数中接收。
一个完整的rendereless示例如下
```typescript
import type {
IAlertApi,
IAlertProps,
IAlertState,
ISharedRenderlessParamHooks,
IAlertRenderlessParamUtils
} from '@/types'
import {
computedGetIcon,
computedGetTitle,
computedStyle,
computedClass,
handleClose,
handleHeaderClick,
watchAutoHide,
handlerTargetNode
} from './index'
import { nanoid } from '@opentiny/utils'
export const api = ['handleClose', 'state', 'handleHeaderClick']
const initState = ({ api, computed, constants, reactive }): IAlertState => {
return reactive({
show: true,
contentVisible: false,
contentDescribeHeight: 0,
contentDefaultHeight: 0,
contentMaxHeight: constants.CONTENT_MAXHEUGHT,
scrollStatus: false,
getIcon: computed(() => api.computedGetIcon()),
getTitle: computed(() => api.computedGetTitle()),
alertClass: computed(() => api.computedClass()),
alertStyle: computed(() => api.computedStyle()),
titleId: `tiny-alert-title-${nanoid.api.nanoid(8)}`,
contentId: `tiny-alert-description-${nanoid.api.nanoid(8)}`
})
}
const initApi = ({ api, state, constants, props, designConfig, t, emit, vm, parent, nextTick, mode }): void => {
Object.assign(api, {
state,
computedGetIcon: computedGetIcon({ constants, props, designConfig }),
computedGetTitle: computedGetTitle({ constants, props, t }),
computedClass: computedClass({ props, mode }),
computedStyle: computedStyle({ props, mode }),
handleClose: handleClose({ emit, state }),
handleHeaderClick: handleHeaderClick({ state, props, vm }),
watchAutoHide: watchAutoHide({ api, props }),
handlerTargetNode: handlerTargetNode({ props, parent, vm, nextTick })
})
}
const initWatcher = ({ watch, props, api }) => {
watch(() => props.autoHide, api.watchAutoHide, { immediate: true })
watch(() => props.target, api.handlerTargetNode, { immediate: true })
}
export const renderless = (
props: IAlertProps,
{ computed, reactive, watch }: ISharedRenderlessParamHooks,
{ t, emit, constants, vm, designConfig, parent, nextTick, mode }: IAlertRenderlessParamUtils
): IAlertApi => {
const api = {} as IAlertApi
const state: IAlertState = initState({ api, computed, constants, reactive })
initApi({ api, state, constants, props, designConfig, t, emit, vm, parent, nextTick, mode })
initWatcher({ watch, props, api })
return api
}
```
#### 适配层生成的组件上下文对象
renderless函数的第三个参数是适配层生成的组件上下文对象 它是适配层为了兼容Vue2,Vue3在组件初始化时构造了一组相同的上下文对象来抹平跨框架的差异。
这个参数是在 `..\packages\vue-common\src\index.ts` 的setup函数中传入的它包含以下值
```typescript
// 适配层构造一个组件的vm变量将Vue实例上的某些值代理出来。
const vm = {
$attrs: { get: () => $attrs },
$children: { get: () => generateChildren(instance.subTree) },
$constants: { get: () => instance.props._constants },
$emit: { get: () => emit },
$el: { get: () => instance.vnode.el },
$listeners: { get: () => $listeners },
$mode: { get: () => instance._tiny_mode },
$nextTick: { get: () => hooks.nextTick },
$off: { get: () => $emitter.off },
$on: { get: () => $emitter.on },
$once: { get: () => $emitter.once },
$options: { get: () => ({ componentName: instance.type.componentName }) },
$parent: {
get: () => instance.parent && createVm({}, getRealParent(instance))
},
$refs: { get: () => instance.refs },
$renderless: { get: () => instance.props.tiny_renderless },
$scopedSlots: { get: () => instance.slots },
$set: { get: () => $set },
$slots: { get: () => instance.slots },
$template: { get: () => instance.props.tiny_template }
}
// 该值为传递给renderless第3个参数所有属性都可以从中解构出来。
const utils = {
$prefix,
t,
designConfig,
globalDesignConfig,
useBreakpoint,
mergeClass,
framework: 'vue3',
vm,
emit,
emitter,
route,
router,
dispatch,
broadcast,
parentHandler,
childrenHandler,
i18n,
refs,
slots: instance?.slots,
scopedSlots: instance?.slots,
attrs: context.attrs,
parent: parentVm,
nextTick: hooks.nextTick,
constants: instance?.props._constants,
mode,
isPCMode: mode === 'pc',
isMobileMode: mode === 'mobile',
service: root?.$getService ? root?.$getService(vm) : root?.$service,
getService: () => root?.$getService(vm),
setParentAttribute,
defineInstanceProperties,
defineParentInstanceProperties
}
```
我们观察到,有些属性在`utils`下存在,在`vm`下也存在。建议使用 `vm`下的相应值,比如 `vm.$refs, vm.$slots`
2. 辅助方法与函数
- **业务逻辑**`packages/renderless/src/<component-name>/index.ts`
1. 它们都是纯函数,不依赖任何 UI 框架,不依赖其它上下文
2. 在`index.ts`文件中, 禁止直接导入 Vue APIref、reactive、watch 等)
3. 每一个函数,通常都是要接收{state, api, props} 等传入的变量,生成一个全新的函数变量。这个函数只与当前组件产生互操作。
4. 所有的函数不要互相调用,通常应该是通过 api 变量来调用其它函数。
以下是一个简单的纯函数示例:
```typescript
export const watchAutoHide =
({ api, props }: Pick<IAlertRenderlessParams, 'api' | 'props'>) =>
(newVal: boolean) => {
if (props.autoHide && newVal) {
const timer = setTimeout(() => {
api.handleClose()
clearTimeout(timer)
}, ALERT_TIMEOUT)
}
}
```
3. 类型声明
- **类型声明**`packages/renderless/types/<component-name>.type.ts`
`types` 文件夹中,为每一个组件添加一个声明文件,这样在逻辑层代码开发,以及模板绑定属性和方法时,才会有正确的类型提示。
以下是一个类型声明的示例:
```typescript
import type { ExtractPropTypes, CSSProperties } from 'vue'
import type { alertProps, $constants } from '@/alert/src'
import type { ISharedRenderlessFunctionParams, ISharedRenderlessParamUtils } from './shared.type'
export interface IAlertState {
show: boolean
getIcon: string
getTitle: string
contentVisible: boolean
contentDescribeHeight: number
contentDefaultHeight: number
contentMaxHeight: number
scrollStatus: boolean
titleId: string
contentId: string
}
export type IAlertProps = ExtractPropTypes<typeof alertProps>
export type IAlertConstants = typeof $constants
export type IAlertRenderlessParams = ISharedRenderlessFunctionParams<IAlertConstants> & {
api: IAlertApi
state: IAlertState
props: IAlertProps
}
export interface IAlertApi {
state: IAlertState
computedGetIcon: () => string
computedGetTitle: () => string
handleClose: () => void
handleHeaderClick: () => void
watchAutoHide: (value: boolean) => void
computedStyle: () => CSSProperties
}
export type IAlertRenderlessParamUtils = ISharedRenderlessParamUtils<IAlertConstants>
```
TypeScript 类型安全的开发规范
- 优先使用 TypeScript
- 禁止使用 `any`,需要类型逃逸时加注释说明
- 类型定义放在 `packages/renderless/types/` 或组件目录下
### 开发规范
- 一份 renderless 逻辑同时服务 Vue 2 和 Vue 3
- 如需判断框架版本,使用 `@opentiny/vue-common` 中的工具函数, 不得在 renderless 层写 `if (vue3)` 这样的判断
- 应该尽量避免直接操作dom
- 尽量编写相应的类型声明, 包含 props, state, api下的属性和方法的签名。
- 不要引用 'vue' 包应该从renderless的第2个参数中获取vue包下的变量。
**在 renderless 层使用 Vue API**
```typescript
// ❌ 错误
import { ref } from 'vue'
const count = ref(0)
// ✅ 正确方式 1
import { hooks } from '@opentiny/vue-common'
const state = hooks.ref({ count: 0 })
// ✅ 正确方式 2
export const renderless = (props, { ref }) => {
const state = hooks.ref({ count: 0 })
}
```
## 参考资源
- [vue.ts](../../packages/renderless/src/button/vue.ts)
- [index.ts](../../packages/renderless/src/button/index.ts)
- [button.type.ts](../../packages/renderless/types/button.type.ts)

View File

@ -0,0 +1,522 @@
# TinyVue 测试编写指南
## 适用场景
- 为新组件编写测试
- 为现有组件补充测试用例
- 修复 Bug 时添加回归测试
- 重构代码后验证功能
## 核心原则
### 1. 测试金字塔
```
/\
/ \ E2E 测试(少量,覆盖关键流程)
/----\
/ \ 集成测试(适量,测试组件交互)
/--------\
/ \ 单元测试(大量,测试单个函数/组件)
/------------\
```
- **单元测试**Vitest测试 renderless 层的纯函数
- **E2E 测试**Playwright测试完整用户流程
- **比例建议**70% 单元 + 30% E2E
### 2. 测试命名规范
```typescript
// 格式should + 预期行为 + when + 条件
it('should emit change event when value is updated', () => {})
it('should not submit form when validation fails', () => {})
it('should display error message when input is invalid', () => {})
```
### 3. AAA 模式
每个测试用例遵循 **Arrange-Act-Assert**
```typescript
it('should calculate total price correctly', () => {
// Arrange - 准备数据
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 }
]
// Act - 执行操作
const total = calculateTotal(items)
// Assert - 验证结果
expect(total).toBe(250)
})
```
## 标准流程
### 单元测试Vitest
#### 步骤 1创建测试文件
```typescript
// packages/renderless/src/my-component/__tests__/index.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { api, renderless } from '../index'
describe('MyComponent Renderless', () => {
// 测试用例
})
```
#### 步骤 2编写基础测试
```typescript
describe('api', () => {
it('should return correct methods', () => {
const state = { value: '' }
const props = { disabled: false }
const emit = vi.fn()
const result = api({ state, props, emit })
expect(result).toHaveProperty('handleClick')
expect(result).toHaveProperty('updateValue')
})
it('should emit click event with current value', () => {
const state = { value: 'test' }
const props = { disabled: false }
const emit = vi.fn()
const { handleClick } = api({ state, props, emit })
handleClick()
expect(emit).toHaveBeenCalledWith('click', 'test')
})
})
```
#### 步骤 3边界情况测试
```typescript
describe('edge cases', () => {
it('should not emit when disabled', () => {
const state = { value: 'test' }
const props = { disabled: true }
const emit = vi.fn()
const { handleClick } = api({ state, props, emit })
handleClick()
expect(emit).not.toHaveBeenCalled()
})
it('should handle empty value', () => {
const state = { value: '' }
const props = { disabled: false }
const emit = vi.fn()
const { updateValue } = api({ state, props, emit })
updateValue('')
expect(state.value).toBe('')
})
})
```
### E2E 测试Playwright
#### 步骤 1创建测试文件
```typescript
// examples/sites/demos/my-component/test/my-component.spec.ts
import { test, expect } from '@playwright/test'
test.describe('MyComponent E2E', () => {
// 测试用例
})
```
#### 步骤 2编写交互测试
```typescript
test('should click button and show result', async ({ page }) => {
// 导航到示例页面
await page.goto('/my-component/basic')
// 找到按钮并点击
const button = page.getByRole('button', { name: '点击我' })
await button.click()
// 验证结果显示
const result = page.getByText('操作成功')
await expect(result).toBeVisible()
})
```
#### 步骤 3视觉回归测试
```typescript
test('should render correctly', async ({ page }) => {
await page.goto('/my-component/basic')
// 截取整个页面
await expect(page).toHaveScreenshot('my-component-basic.png', {
maxDiffPixels: 100
})
})
```
## 代码示例
### Vitest 完整示例
```typescript
// packages/renderless/src/input/__tests__/index.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { api, renderless } from '../index'
describe('Input Renderless', () => {
let mockState: any
let mockProps: any
let mockEmit: any
beforeEach(() => {
mockState = {
value: '',
hovering: false,
focused: false
}
mockProps = {
modelValue: '',
disabled: false,
readonly: false,
placeholder: '请输入'
}
mockEmit = vi.fn()
})
describe('handleInput', () => {
it('should update state value', () => {
const { handleInput } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
handleInput('new value')
expect(mockState.value).toBe('new value')
})
it('should emit update:modelValue event', () => {
const { handleInput } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
handleInput('test')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'test')
})
it('should not emit when value unchanged', () => {
mockState.value = 'existing'
mockProps.modelValue = 'existing'
const { handleInput } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
handleInput('existing')
expect(mockEmit).not.toHaveBeenCalled()
})
})
describe('handleFocus', () => {
it('should set focused to true', () => {
const { handleFocus } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
handleFocus()
expect(mockState.focused).toBe(true)
})
it('should emit focus event', () => {
const { handleFocus } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
handleFocus()
expect(mockEmit).toHaveBeenCalledWith('focus')
})
})
describe('clear', () => {
it('should clear value and emit events', () => {
mockState.value = 'some text'
const { clear } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
clear()
expect(mockState.value).toBe('')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
expect(mockEmit).toHaveBeenCalledWith('clear')
})
it('should not clear when disabled', () => {
mockProps.disabled = true
mockState.value = 'some text'
const { clear } = api({
state: mockState,
props: mockProps,
emit: mockEmit
})
clear()
expect(mockState.value).toBe('some text')
})
})
})
```
### Playwright 完整示例
```typescript
// examples/sites/demos/input/test/input.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Input Component', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/input/basic')
})
test('should render input correctly', async ({ page }) => {
const input = page.getByRole('textbox')
await expect(input).toBeVisible()
await expect(input).toHaveAttribute('placeholder', '请输入')
})
test('should handle user input', async ({ page }) => {
const input = page.getByRole('textbox')
await input.fill('Hello World')
await expect(input).toHaveValue('Hello World')
})
test('should clear input when clear button clicked', async ({ page }) => {
const input = page.getByRole('textbox')
await input.fill('Test')
const clearButton = page.getByRole('button', { name: 'clear' })
await clearButton.click()
await expect(input).toHaveValue('')
})
test('should not accept input when disabled', async ({ page }) => {
await page.goto('/input/disabled')
const input = page.getByRole('textbox')
await expect(input).toBeDisabled()
await input.fill('Should not work')
await expect(input).toHaveValue('')
})
test('should show word limit', async ({ page }) => {
await page.goto('/input/word-limit')
const input = page.getByRole('textbox')
await input.fill('12345')
const wordLimit = page.getByText('5/10')
await expect(wordLimit).toBeVisible()
})
test('should validate required field', async ({ page }) => {
await page.goto('/input/validation')
const submitButton = page.getByRole('button', { name: '提交' })
await submitButton.click()
const errorMsg = page.getByText('此字段为必填项')
await expect(errorMsg).toBeVisible()
})
})
```
## 常见陷阱
### ❌ 错误做法
1. **测试实现细节而非行为**
```typescript
// ❌ 错误 - 测试内部状态
it('should set state.hovering to true', () => {
expect(state.hovering).toBe(true)
})
// ✅ 正确 - 测试可见行为
it('should show tooltip on hover', async () => {
await element.hover()
await expect(tooltip).toBeVisible()
})
```
2. **测试之间相互依赖**
```typescript
// ❌ 错误
it('step 1: login', () => {})
it('step 2: navigate', () => {}) // 依赖 step 1
// ✅ 正确 - 每个测试独立
it('should navigate after login', async () => {
await login()
await navigate()
// 验证
})
```
3. **使用硬编码等待时间**
```typescript
// ❌ 错误
await page.waitForTimeout(5000)
// ✅ 正确
await expect(element).toBeVisible({ timeout: 5000 })
```
4. **忽略异步操作**
```typescript
// ❌ 错误
button.click()
expect(result).toBe('done')
// ✅ 正确
await button.click()
await expect(result).toHaveText('done')
```
### ✅ 最佳实践
1. **使用 beforeEach 重置状态**
```typescript
beforeEach(() => {
mockState = { value: '' }
mockEmit = vi.fn()
})
```
2. **测试失败时提供清晰信息**
```typescript
expect(result).toBe(expected)
// 失败时会显示Expected "expected" but received "actual"
```
3. **Mock 外部依赖**
```typescript
vi.mock('@opentiny/utils', () => ({
debounce: vi.fn((fn) => fn)
}))
```
4. **覆盖率目标**
- 行覆盖率:≥ 80%
- 分支覆盖率:≥ 75%
- 函数覆盖率:≥ 85%
## 检查清单
编写测试前,确认:
### 单元测试
- [ ] 测试了所有公共 API
- [ ] 覆盖了正常流程和异常流程
- [ ] 测试了边界值空值、null、undefined
- [ ] Mock 了所有外部依赖
- [ ] 每个测试用例独立运行
- [ ] 测试名称清晰描述预期行为
### E2E 测试
- [ ] 覆盖了主要用户操作流程
- [ ] 测试了不同浏览器尺寸(响应式)
- [ ] 验证了无障碍访问(键盘导航、屏幕阅读器)
- [ ] 包含了视觉回归测试(可选)
- [ ] 测试数据可重复使用
- [ ] 没有硬编码等待时间
### 通用
- [ ] 测试文件命名符合规范(\*.spec.ts
- [ ] 使用了 AAA 模式组织代码
- [ ] 没有测试私有实现细节
- [ ] 添加了必要的注释说明复杂逻辑
- [ ] 运行 `pnpm test:unit` 全部通过
- [ ] 运行 `pnpm test:e2e` 全部通过
## 运行测试
```bash
# 运行所有单元测试
pnpm test:unit
# 运行 Vue 3 单元测试
pnpm test:unit3
# 运行 Vue 2 单元测试
pnpm test:unit2
# 运行所有 E2E 测试
pnpm test:e2e
# 运行特定组件的 E2E 测试
pnpm test:e2e --grep "input"
# 生成覆盖率报告
pnpm test:unit --coverage
```
## 参考资源
- [Vitest 官方文档](https://vitest.dev/)
- [Playwright 官方文档](https://playwright.dev/)
- [Input 组件单元测试](../../packages/renderless/src/input/__tests__/)
- [Input 组件 E2E 测试](../../examples/sites/demos/input/test/)
- [AGENTS.md 测试要求](../../AGENTS.md#测试要求)

View File

@ -0,0 +1,370 @@
# TinyVue 主题定制指南
## 适用场景
- 为新组件编写样式
- 修改现有组件的视觉效果
- 创建自定义主题
- 调整响应式断点
## 核心原则
### 1. 主题架构
TinyVue 支持多主题系统:
```
packages/
├── theme/
│ └── src/
│ ├── base / # 公共变量和混入
│ │ ├── vars.less # 全局的CSS 变量定义
│ │ └── reset.less # rest 样式定义
│ │ └── transition.less # 全局动画 样式定义
│ │ └── aurora-theme.less # vars.less 中的一个变体, aurora风格
│ │ └── dark-theme.less # vars.less 中的一个变体, 暗黑风格
│ │ └── motion-theme.less # vars.less 中的一个变体, motion 风格
│ │ └── old-theme.less # vars.less 中的一个变体, 原来的主题风格
│ ├── svgs / # 所有图标的原始 svg 文件
│ └── <component>/ # 各组件样式
│ └── vars.less # 组件级的 CSS 变量定义
│ └── index.less # 组件的 CSS 样式
└── theme-saas/ # SAAS 主题
└── src/
└── ... # 类似结构,不同变量值
```
### 2. CSS 变量系统
Tinyvue的样式系统设计了一套多级CSS 变量系统,`base CSS 变量` 和 `common CSS 变量` 的定义都在 `packages/theme/src/base/vars.less`中;`组件的CSS 变量`的定义在各个组件的文件夹下面。
1. `base CSS 变量` 定义一套规范的颜色值和数字值, 以 --tv-base-\* 打头。
2. `common CSS 变量` 定义一套通用的CSS 变量值,有明确的使用场景约束, 更好的名称可读性。所有的值必须从`base CSS 变量`里面选取, 以 --tv-小写字母 打头, 比如 --tv-color-\* 。
3. `组件的CSS 变量` 定义在组件级别的CSS 变量值,方便用户定制组件样式。 它必须使用 `common CSS 变量` ,并且不允许使用 `base CSS 变量`的值。 以 --tv-组件名首字母大写 打头,比如: --tv-Button-\*。 每一个组件级CSS 变量的上面必须是对应的注释,写明它的使用的位置。
```less
// base CSS 变量
:root {
--tv-base-color-brand: #191919;
--tv-base-color-brand-1: #f0f7ff;
--tv-base-color-brand-2: #deecff;
--tv-base-color-brand-3: #b3d6ff;
--tv-base-color-brand-4: #7eb7fc;
--tv-base-color-brand-5: #4191fa;
--tv-base-color-brand-6: #1476ff;
--tv-base-color-brand-7: #0f5ed4;
--tv-base-color-brand-8: #0845a6;
--tv-base-color-brand-9: #022e7a;
--tv-base-color-brand-10: #001a4a;
--tv-base-color-brand-11: #3d6899;
--tv-base-color-brand-12: #7fa6d4;
--tv-base-color-brand-13: #b6d4f2;
}
// common CSS 变量
:root {
--tv-color-success-text: var(--tv-base-color-success-6); // #5cb300 成功-文本色 tag的light、plain类型
--tv-color-success-text-primary: var(--tv-base-color-common-11); // #191919 常规一级文本色(非主题色)
--tv-color-success-bg: var(--tv-base-color-success-6); //#5cb300 成功-背景色(深) tag的dark类型/tooltip/badge
--tv-color-success-bg-light: var(--tv-base-color-success-14); // #e6f2d5 成功-背景色(浅)
--tv-color-success-bg-1: var(--tv-base-color-success-14); // #e6f2d5 tag的light类型
--tv-color-success-border: var(--tv-base-color-success-6); // #5cb300 成功-边框色(深)
--tv-color-success-border-light: var(--tv-base-color-success-14); // #e6f2d5 成功-边框色(浅) 型
--tv-color-success-border-1: var(--tv-base-color-success-14); // #e6f2d5 tag的light类型
--tv-color-success-icon: var(--tv-base-color-success-6); // #5cb300 成功-图标色
}
// 组件级 CSS 变量
.inject-Button-vars() {
// 默认时按钮字重
--tv-Button-font-weight: var(--tv-font-weight-regular, 400);
// 默认时按钮边框宽度
--tv-Button-border-width: var(--tv-border-width, 1px);
// 按钮的文本行高
--tv-Button-line-height: var(--tv-line-height-number, 1.5);
}
```
**重要约束**
目前组件库已经稳定,在开发组件时,尽量复用已经存在的 `common CSS 变量`,不要添加新变量。
### 3. BEM 命名规范
在模板中,为节点添加类名时,使用 **Block\_\_Element--Modifier** 命名的规则:
```less
// Block: 组件名
.tiny-button {
// Element: 组成部分(双下划线)
&__icon {
margin-right: @spacing-xs;
}
&__text {
font-weight: bold;
}
// Modifier: 状态变体(双横线)
&--primary {
background-color: @color-brand;
}
&--disabled {
opacity: @opacity-disabled;
}
}
```
```less
@button-prefix-cls: ~'@{css-prefix}button';
.@{button-prefix-cls} {
.inject-Button-vars();
&.@{button-prefix-cls}--large {
.size-mixin(-large);
}
&.@{button-prefix-cls}--medium {
.size-mixin(-medium);
}
&.@{button-prefix-cls}--small {
.size-mixin(-small);
}
&.@{button-prefix-cls}--mini {
.size-mixin(-mini);
}
```
### 4. 组件的样式开发规范
一个组件通常都有三个样式文件:
1. vars.less: 组件级 CSS 变量。 通过分析组件中,哪些地方的样式需要适配不同主题,以及它们的值在 `common CSS 变量`中存在的,就需要找出来定义为组件级变量。 每一个组件级变量上面必须写注释指示该变量的使用场景。示例如下:
```less
.inject-Button-vars() {
// 默认时按钮字重
--tv-Button-font-weight: var(--tv-font-weight-regular, 400);
// 默认时按钮边框宽度
--tv-Button-border-width: var(--tv-border-width, 1px);
// 按钮的文本行高
--tv-Button-line-height: var(--tv-line-height-number, 1.5);
// 默认时按钮圆角
--tv-Button-border-radius: var(--tv-border-radius-md, 6px); // 默认还原为6px
// 大圆角时按钮圆角
--tv-Button-border-radius-round: var(--tv-border-radius-round, 999px);
// 圆形时按钮圆角
--tv-Button-border-radius-circle: var(--tv-border-radius-round, 999px);
}
```
2. index.less: 组件的样式编写。每个组件的根节点,
首先要注入一下自己的组件级 CSS 变量,以便这些变量生效。如果组件有多个根节点,或者有弹出层等场景,需要给所有的根节点或弹出层的根节点添加这个注入。
其次,尽量使用 `less`的`嵌套结构`和`父选择器 &` 的能力进行编写。
最后需要使用组件级的CSS变量避免使用 `common CSS 变量``base CSS 变量`
示例如下:
```less
@alert-prefix-cls: ~'@{css-prefix}alert';
.@{alert-prefix-cls} {
.inject-Alert-vars();
position: relative;
display: flex;
border: none;
border-radius: var(--tv-Alert-border-radius);
padding: var(--tv-Alert-padding-y) var(--tv-Alert-padding-x);
margin: var(--tv-Alert-margin-y) var(--tv-Alert-margin-x);
line-height: 1.5;
&.is-center {
justify-content: center;
align-items: center;
}
/** alert-icon 场景 */
.@{alert-prefix-cls}__icon:not(.@{alert-prefix-cls}__close) {
font-size: var(--tv-Alert-icon-size);
margin-right: var(--tv-Alert-icon-margin-right);
flex-shrink: 0;
margin-top: 2px;
}
}
```
3. 组件的响应式设计
组件在不同的屏幕尺寸时,有不同的规范要求时,就需要添加 `responsive.less` 样式文件,编写在指定的屏幕大小时应该呈现的样式。开发规范同 `index.ts` 一致。示例如下:
```less
@import '../custom.less';
@import './vars.less';
@alert-prefix-cls: ~'@{css-prefix}alert';
@media screen and (max-width: 1280px) {
.@{alert-prefix-cls} {
.inject-Alert-responsive-vars();
.@{alert-prefix-cls}__content {
.@{alert-prefix-cls}__title {
font-size: var(--tv-Alert-title-responsive-font-size);
}
.@{alert-prefix-cls}__description {
color: var(--tv-Alert-title-responsive-text-color);
}
}
}
}
```
### 5 移动优先的多端模板的开发规范
组件的多端模板 `mobile-first.vue`文件,不使用传统的 `BEM`样式规范,而是使用 `tailwind css`进行开发,`tailwind css`的配置文件在`..\packages\theme-saas\tailwind.config.js` 中,其中有定制的颜色值,断点值,布局等配置信息。
多端模板不需要引入任何的 css 文件在模板中只需要添加tailwind的类名即可示例如下
```vue
<template>
<div
data-tag="tiny-alert"
v-if="state.show"
:class="
m(
'min-h-min flex py-2 sm:py-3 px-4 my-2 rounded box-border font-light sm:font-normal text-color-text-primary',
{ 'bg-color-info-primary-subtler': type === 'info' || !type },
{ 'bg-color-error-subtler': type === 'error' },
{ 'bg-color-warning-subtler': type === 'warning' },
{ 'bg-color-success-subtler': type === 'success' },
{ 'text-center': center },
customClass
)
"
>
<span
v-else-if="closeText && closable"
data-tag="tiny-alert-close-text"
@click="handleClose"
class="leading-6 text-sm cursor-pointer"
>{{ closeText }}</span
>
</div>
</template>
```
上面的 `m函数`是适配层注入的tailwind merge函数用于合并类名。 如果元素上的类名太长,也可以将类名抽取为变量,集中编写到 `token.ts`文件中。
```typescript token.ts
export const classes = {
'button': 'inline-block.....',
'size-default': 'h-10 text-sm sm:h-7'
// ....
}
```
### Tailwind CSS 集成Mobile First
```vue
<!-- 使用 Tailwind 工具类 -->
<template>
<div
:class="
m(
'flex items-center justify-between',
'px-4 py-2 sm:px-6 sm:py-3',
'bg-white dark:bg-gray-800',
'border border-gray-200 rounded-lg',
'hover:border-blue-500 transition-colors'
)
"
>
<!-- 内容 -->
</div>
</template>
```
## 主题定制检查清单
编写样式时,确认:
### 变量使用
- [ ] 尽量使用common CSS 变量
- [ ] 所有间距使用间距变量
- [ ] 所有字体大小使用字体变量
- [ ] 没有硬编码的十六进制颜色值
### BEM 命名
- [ ] 类名遵循 BEM 规范
- [ ] Block 名称与组件名一致
- [ ] Element 使用双下划线 `__`
- [ ] Modifier 使用双横线 `--`
### 响应式
- [ ] 考虑移动端优先设计
- [ ] 测试不同屏幕尺寸
- [ ] 使用相对单位rem、em、%
- [ ] 避免固定宽度,使用 max-width
### 无障碍
- [ ] 颜色对比度符合 WCAG AA 标准
- [ ] 焦点状态清晰可见
- [ ] 禁用状态明确标识
- [ ] 支持键盘导航样式
### 性能
- [ ] 避免深层嵌套(不超过 3 层)
- [ ] 合理使用 CSS 过渡
- [ ] 避免使用 `!important`
- [ ] 合并重复的样式规则
### 兼容性
- [ ] 深色模式适配(如需要)
- [ ] 浏览器兼容性检查
## 调试技巧
### 查看 CSS 变量
```javascript
// 在浏览器控制台执行
getComputedStyle(document.documentElement).getPropertyValue('--tv-color-brand')
```
### 临时覆盖样式
```vue
<style scoped>
/* 仅用于调试,不要提交 */
.tiny-my-component {
border: 1px solid red !important;
}
</style>
```
### 使用浏览器 DevTools
1. 打开 Elements 面板
2. 查看 Computed 样式
3. 检查 CSS 变量值
4. 实时修改测试效果
## 参考资源
- [Less 官方文档](https://lesscss.org/)
- [BEM 命名规范](https://getbem.com/)
- [WCAG 无障碍指南](https://www.w3.org/WAI/WCAG21/quickref/)
- [Button 组件级变量示例](../../packages/theme/src/button/vars.less)
- [Button 组件样式示例](../../packages/theme/src/button/index.less)
- [Button 组件响应样式的示例](../../packages/theme/src/button/responsive.less)
- [Tailwind CSS 文档](https://tailwindcss.com/)

View File

@ -0,0 +1,122 @@
# @opentiny/utils 开发规范
## 适用场景
- 在 renderless 层或 vue-hooks 中编写与框架无关的纯逻辑
- 复用日期、字符串、DOM、校验等通用能力
- 新增跨组件工具函数
## 核心原则
### 1. 包定位
`packages/utils` 发布为 `@opentiny/utils`**不依赖 Vue**,可被以下模块引用:
- `packages/renderless`(无渲染逻辑层,主要消费方)
- `packages/vue-hooks`
- `packages/vue-directive`
- `packages/vue-common`(适配层少量使用)
renderless 层约定:**除 `@opentiny/utils``@opentiny/vue-hooks` 外,不得依赖其它第三方包**。
### 2. 目录结构
```text
packages/utils/src/
├── index.ts # 统一导出入口
├── array/ # 数组操作
├── bigInt/ # 大数/精度计算
├── calendar/ # 日历相关
├── crypt/ # sha256 等
├── date/ # 日期格式化、时区
├── date-util/ # 日期工具(与 date 部分能力重叠,待整理)
├── debounce/ # 防抖
├── throttle/ # 节流
├── dom/ # DOM 操作、样式、滚动
├── decimal/ # 小数精度
├── event/ # 事件派发
├── form/ # 表单常量(待迁移至组件内部)
├── function/ # noop、callInterceptor
├── globalConfig/ # isServer、browserInfo、globalConfig
├── logger/ # 日志
├── nanoid/ # 唯一 ID
├── object/ # 对象拷贝、合并、相等
├── string/ # 字符串格式化、驼峰/连字符
├── type/ # 类型判断
├── validate/ # 校验器 Validator
├── xss/ # XSS 过滤
├── tree-model/ # 树形数据结构
├── popper/ # Popper 定位
├── popup-manager/ # 弹层层级管理
└── ... # 其它模块见 src/index.ts
```
新增工具时:在对应子目录实现,并在 `src/index.ts` 中导出。
### 3. 模块编写规范
1. **纯函数**:不持有组件状态,不 import Vue API
2. **SSR 安全**:涉及 `window`/`document` 时,使用 `isServer`(来自 `globalConfig`)做守卫
3. **单职责**:一个文件聚焦一类能力,避免在 utils 中写组件专属常量(`common/`、`form/` 中部分常量标注为「待移除」,新代码勿再扩展)
4. **测试**:在模块目录下补充 `__tests__``__test__`,在 `packages/utils` 目录执行 `pnpm test`,或在仓库根目录执行 `pnpm --filter @opentiny/utils test`
### 4. 常用 API 分类
| 分类 | 代表导出 | 典型用途 |
| --------- | --------------------------------------------------------------------------------------------------- | -------------------------------------- |
| 类型判断 | `isObject`, `isFunction`, `isDate`, `typeOf` | 参数校验 |
| 字符串 | `camelize`, `hyphenate`, `formatString`, `guid` | 命名转换、展示格式化 |
| 日期 | `formatDate`, `toDateStr`, `limitTimeRange``calendar` 的 `parseDate``date-util` 的 `parseDate1` | 日期/时间组件(注意两套 parse 勿混用) |
| 对象/数组 | `extend`, `merge`, `isEqual`, `find`, `unique` | 数据处理 |
| DOM | `on`, `off`, `addClass`, `getScrollParent` | 事件与布局 |
| 性能 | `debounce`, `throttle`, `fastdom` | 高频回调、布局批处理 |
| 安全 | `xss`, `sha256` | 内容过滤、摘要 |
| ID | `nanoid``nanoid.api.nanoid(size)` | 无障碍 id、唯一 key |
### 5. 在 renderless 中的引用方式
```typescript
// ✅ 正确:从 @opentiny/utils 按需导入
import { debounce } from '@opentiny/utils'
import { nanoid } from '@opentiny/utils'
// 使用 nanoid
const id = `tiny-alert-title-${nanoid.api.nanoid(8)}`
// ❌ 错误:在 renderless 中 import 'vue'
// ❌ 错误:在 utils 中 import '@opentiny/vue-common'
```
### 6. nanoid 使用说明
`nanoid` 以命名空间导出,推荐通过 `nanoid.api` 访问:
```typescript
import { nanoid } from '@opentiny/utils'
nanoid.api.nanoid(8) // 默认长度
nanoid.api.customAlphabet('abc', 10) // 自定义字母表
nanoid.random() // 0~1 随机数SSR 下返回 0
```
### 7. 禁止事项
- ❌ 不得在 utils 中引入 Vue、`@opentiny/vue-common`、`@opentiny/vue-hooks`
- ❌ 不得将仅某一组件使用的常量长期放在 `common/`、`form/`(应下沉到对应 renderless 或组件)
- ❌ 不得重复造轮子:新增前先检索 `src/index.ts` 是否已有同类方法
- ❌ 注意 `date``date-util` 存在部分重名导出(如 `toDate` / `toDate1`),优先使用语义清晰的现有 API避免再增加别名
## 构建与发布
```bash
# 在 packages/utils 目录
pnpm build # vite 构建
pnpm test # vitest
pnpm pub # 发布(维护者)
```
## 参考资源
- [统一导出](../../packages/utils/src/index.ts)
- [debounce 示例](../../packages/utils/src/debounce/index.ts)
- [renderless 中的引用](../../packages/renderless/src/alert/vue.ts)

253
.agents/skills/vue.skill.md Normal file
View File

@ -0,0 +1,253 @@
# TinyVue 组件开发规范
## 核心原则
`packages/vue/src` 目录中,每个组件有一个独立的文件夹进行隔离。
每一个组件的结构为:
1. index.ts: 整个组件的对外导出对象
2. src/index.ts: 整合 pc/mobile-first 模板为一个统一的组件。通常要把pc/mobile-first模板的全量属性定义在这里并导出给2个模板使用
3. src/pc.vue: pc 浏览器下的模板需要引入外部的css样式文件。
4. src/mobile-first.vue: 移动优先的浏览器模板, 使用tailwind进行内联类名。
## 详细解释每个文件的编写规范
1. index.ts
该文件是整个组件的对外导出对象负责给组件添加install方法和veresion属性。 示例如下:
```typescript
import Alert from './src/index'
import { version } from './package.json'
Alert.install = function (Vue) {
Vue.component(Alert.name, Alert)
}
Alert.version = version
if (process.env.BUILD_TARGET === 'runtime') {
if (typeof window !== 'undefined' && window.Vue) {
Alert.install(window.Vue)
}
}
export default Alert
```
2. src/index.ts
整合 pc/mobile-first 模板为一个统一的组件。示例如下:
```typescript
import { $props, $prefix, $setup, defineComponent } from '@opentiny/vue-common'
import template from 'virtual-template?pc|mobile-first'
export const alertProps = {
// ......
}
export default defineComponent({
name: $prefix + 'Alert',
props: alertProps,
setup(props, context) {
return $setup({ props, context, template })
}
})
```
同时引入2个模板的语法为 `import template from 'virtual-template?pc|mobile-first'` 它是非标准的TS用法有专门的`vite`插件会将其编译为两个模板的引入。 `template`是一个函数,它需要传入 `$setup`函数。
3. src/pc.vue 和 src/mobile-first.vue 的模板共同规范
这2个文件是组件的**视图模板**
- 负责 UI 渲染和用户交互
- 通过适配层的 `setup` 调用 renderless 层的函数
- 可以包含2个跨端模板`mobile-first.vue`、`pc.vue`
- 禁止内联样式需要从外部引入。比如import '@opentiny/vue-theme/alert/index.less'
- 必须引入类型定义文件。 比如: import type { IAlertApi } from '@opentiny/vue-renderless/types/alert.type'
一个标准的模板文件写法如下:
```vue
<template>
<!-- 模板内容 -->
</template>
<script lang="ts">
import { renderless, api } from '@opentiny/vue-renderless/alert/vue'
import { props, setup, defineComponent } from '@opentiny/vue-common'
import type { IAlertApi } from '@opentiny/vue-renderless/types/alert.type'
import '@opentiny/vue-theme/alert/index.less'
export default defineComponent({
props: [...props /** 其它属性名 */],
setup(props, context) {
return setup({ props, context, renderless, api }) as unknown as IAlertApi
}
})
</script>
```
**适配层 setup** 函数作用:
1. 纽带作用
我们从 '@opentiny/vue-common' 引入 `setup`函数, 它是联系`无渲染逻辑层` 和 `模板`的纽带。 将`无渲染逻辑层`的组件逻辑函数传入`setup`后,
setup内部会调用该函数并传入组件实例的 porps, vue官方包对象以及适配层构造的vm上下文对象这样`无渲染逻辑层`的组件逻辑函数就会获得组件的所有令牌。 最后它会返回一个 {state,api} 对象,以便模板的绑定。
2. 处理模板选择
如果组件是跨端组件,那么它本质上是一个双层组件,父组件是用来选择使用 pc.vue ,mobile-first.vue 哪个模板, 子组件就是真实的组件。 `setup`函数内部负责向父组件同步状态和属性。此时 setup可选择性传入 `mono:false` 的属性来告诉setup函数来同步这个状态和属性。
如果组件是一个单一组件,没有通用 `$setup` 来选择模板那么在调用setup时 必须传入 `mono:true` 来指示,它是一个单一组件。示例如下:
```typescript
setup(props, context) {
return setup({ props, context, renderless, api, mono:true }) as unknown as IAlertApi
}
```
3. 向`无渲染逻辑层`的组件逻辑函数传入额外的数据
`无渲染逻辑层`的设计上,它不能依赖第三方的库,如果之它需要引入其它对象,需要在 `模板层` 引入它们,并通过`setup` 的`extendOptions`属性 传入 `无渲染逻辑层`的组件逻辑函数中,示例如下:
```typescript
import FluentEditor from '@opentiny/fluent-editor'
setup(props, context) {
return setup({ props, context, renderless, api,
extendOptions: {
FluentEditor
}})
}
```
## 模板规范
1. 必须使用 vue2, vue3同时兼容的模板语法。
2. 必须是单根节点
3. 不允许 v-if / v-for在同一个节点上 建议使用 <template> 来使用 v-if / v-for
4. 不允许使用 `id` 等属性
5. 允许使用 Teleport 组件, 但必须从 '@opentiny/vue-common' 包中导入。
6. 模板中可以使用 `a函数`,比如: ` v-bind="a($attrs, ['class', 'style', 'title', 'id'], true)"` `a 函数`的意思是从$attrs上过滤出一些属性绑定到元素上。 最后一个参数为true的话表示这些属性要保留下来如果为false则表示这些属性要过滤掉其它的属性才保留下来。
7. 模板中可以使用 `t函数`, 它是用来加载国际化内容。 比如: `{{ t('ui.base.cancel') }}`
8. 模板中建议增加 `aria-*`的 无障碍信息,尤其是表单元素和图标元素等。
### mobile-first.vue的规范
1. 它使用 `tailwind css`进行模板开发,不依赖外部的样式库。
2. 如果模板中的类名过长,可以将类名转换为变量,约定这些变量存放在 `tokens.ts` 文件中, 示例如下
```typescript token.ts
export const classes = {
'button': 'inline-block.....',
'size-default': 'h-10 text-sm sm:h-7'
// ....
}
```
3. mobile-first模板中由于不能使用类名表示节点的作用建议给关键的dom元素增加 `data-tag`属性来表示dom的作用。根结点统一要增加 data-tag, 示例如下:
```html
<button data-tag="tiny-button"></button>
```
## 参考资源
- [最终导出示例](../../packages/vue/src/button/index.ts)
- [整合模板为一个统一的组件示例](../../packages/vue/src/button/src/index.ts)
- [Button pc模板示例](../../packages/vue/src/button/src/pc.vue)
- [Button mobile-first模板示例](../../packages/vue/src/button/src/mobile-first.vue)
## 多模式介绍
TinyVue 提供了 PC 和 Mobile 组件库,两套组件库对外是同一份依赖`@opentiny/vue`,同名组件通过`tiny_mode`切换组件模式。
针对 SaaS 业务场景TinyVue 提供了基于`tailwind`实现的多端组件,
在 TinyVue 基础上新增`多端模式`,支持业务切换同名组件,同名组件默认情况下是`桌面模式`即`PC 组件`。
### 模式分类
AUI 组件库提供了三种组件模式:`桌面模式`、`多端模式(移动优先)`
| 模式 | 模式介绍 | 模式配置 |
| -------- | -------- | ------------ |
| 桌面模式 | 纯 PC | pc |
| 多端模式 | 多端一致 | mobile-first |
### 模式切换
AUI 组件模式设置优先级
`单组件切换` > `模式透传` > `全局切换` > `组件默认模式`
#### 单组件切换
可在组件标签上配置`tiny_mode`属性,指定组件模式,就会选择对应模板渲染:
- 桌面模式:`pc`
- 多端模式:`mobile-first`
参考示例如下:
```html
<tiny-button tiny_mode="mobile-first">默认按钮</tiny-button>
```
#### 全局切换
通过在 Vue 的原型上全局设置`tiny_mode`,可以指定所有同名组件的默认模式。
在项目入口  `src/main.js`  文件中导入 Vue 依赖后,增加如下配置:
```js
// Vue 2.0
Vue.prototype.tiny_mode = { value: 'mobile-first' }
// Vue 3.0
app.config.globalProperties.tiny_mode = { value: 'mobile-first' }
```
#### 模式透传
如果想在页面部分区域切换模式,如卡片级控制,
可以在外层 AUI 组件上添加`tiny_mode_root`属性,透传当前 AUI 组件的`tiny_mode`配置到所有子级 AUI 组件上,
参考 demo 示例如下:
```html
<template>
<div>
<tiny-layout>
<tiny-row tiny_mode="pc" tiny_mode_root>
<tiny-button>PC 按钮</tiny-button>
<tiny-button type="primary" native-type="submit">主要按钮</tiny-button>
<tiny-button type="success">成功按钮</tiny-button>
<tiny-button type="info">信息按钮</tiny-button>
<tiny-button type="warning">警告按钮</tiny-button>
<tiny-button type="danger">危险按钮</tiny-button>
</tiny-row>
<tiny-row tiny_mode="mobile-first" tiny_mode_root>
<tiny-button>多端按钮</tiny-button>
<tiny-button type="primary" native-type="submit">主要按钮</tiny-button>
<tiny-button type="success">成功按钮</tiny-button>
<tiny-button type="info">信息按钮</tiny-button>
<tiny-button type="warning">警告按钮</tiny-button>
<tiny-button type="danger">危险按钮</tiny-button>
</tiny-row>
</tiny-layout>
</div>
</template>
<script>
import { Button, Layout, Row } from '@opentiny/vue'
export default {
components: {
TinyButton: Button,
TinyLayout: Layout,
TinyRow: Row
}
}
</script>
```

View File

@ -38,10 +38,10 @@ tiny-vue/
## 环境要求
| 工具 | 版本要求 |
|------|------------------------------------------|
| Node | `>= 18` |
| pnpm | `>= 9.5`(必须,禁止使用 npm 或 yarn |
| 工具 | 版本要求 |
| ---- | -------------------------------------- |
| Node | `>= 18` |
| pnpm | `>= 9.5`(必须,禁止使用 npm 或 yarn |
## 核心命令
@ -131,18 +131,18 @@ TinyVue 使用 **Renderless 无渲染架构**。修改或新增组件时,必
**允许的 type**
| type | 用途 |
|-------------|--------------------------|
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档变更 |
| `style` | 代码格式(不影响逻辑) |
| `refactor` | 重构(无新功能/修复) |
| `perf` | 性能优化 |
| `test` | 测试用例 |
| `chore` | 构建/工具链变更 |
| `ci` | CI/CD 配置 |
| `revert` | 回滚提交 |
| type | 用途 |
| ---------- | ---------------------- |
| `feat` | 新功能 |
| `fix` | 缺陷修复 |
| `docs` | 文档变更 |
| `style` | 代码格式(不影响逻辑) |
| `refactor` | 重构(无新功能/修复) |
| `perf` | 性能优化 |
| `test` | 测试用例 |
| `chore` | 构建/工具链变更 |
| `ci` | CI/CD 配置 |
| `revert` | 回滚提交 |
**scope 规范:**
@ -175,7 +175,7 @@ test(button): 新增 E2E 测试用例
- 修复 Bug 时:必须同步补充能复现该 Bug 的测试用例
- 新增 Feature 时:需补充对应的单元测试,优先考虑同步 E2E 测试
- 单元测试位置:`examples/vue3/src/**/__tests__/`
- E2E 测试位置:`examples/sites/demos/<component-name>/`
- E2E 测试位置:`examples/sites/demos/{pc,mobile-first}/app/<component-name>/`
- 测试框架Vitest单元+ PlaywrightE2E
## E2E 测试触发

View File

@ -119,7 +119,7 @@ export default {
'zh-CN': '是否显示标题,在 size 为 large 时有效',
'en-US': 'Whether to show title Only valid when size is large'
},
mode: ['pc', 'mobile-first'],
mode: ['pc'],
pcDemo: 'title',
meta: {
stable: '3.21.0'
@ -138,7 +138,8 @@ export default {
},
{
name: 'size',
type: "'normal' | 'large'",
typeAnchorName: 'ISize',
type: 'ISize',
defaultValue: "'normal'",
desc: {
'zh-CN': '警告的尺寸大小',
@ -146,7 +147,7 @@ export default {
},
mode: ['pc', 'mobile-first'],
pcDemo: 'size',
mfDemo: ''
mfDemo: 'size'
},
{
name: 'title',
@ -279,6 +280,13 @@ export default {
type: 'type',
code: `
type IType = 'success' | 'warning' | 'info' | 'error' | 'simple'
`
},
{
name: 'ISize',
type: 'type',
code: `
type ISize = 'small' | 'medium' | 'normal' | 'large'
`
}
]

View File

@ -51,6 +51,21 @@ export default {
},
mode: ['mobile-first'],
mfDemo: ''
},
{
name: 'size',
type: 'String',
defaultValue: '',
desc: {
'zh-CN': '控制折叠面板的尺寸,可选值为 "" | "medium"',
'en-US': 'adjust the size of the fold panel. Available options: "" | "medium"'
},
mode: ['pc', 'mobile-first'],
mfDemo: '',
pcDemo: 'size.vue',
meta: {
stable: '3.31.0'
}
}
],
events: [

View File

@ -10,11 +10,11 @@ export default {
type: 'boolean',
defaultValue: 'false',
desc: {
'zh-CN': 'drawer 本身是否插入到 body',
'zh-CN': 'drawer 本身是否插入到 body',
'en-US': 'Whether the drawer itself is inserted into the body'
},
mode: ['pc', 'mobile-first'],
pcDemo: '',
pcDemo: 'drawer-to-body',
mfDemo: '',
meta: {
stable: '3.30.0'
@ -42,6 +42,21 @@ export default {
mode: ['mobile-first'],
mfDemo: ''
},
{
name: 'destroy-on-close',
type: 'boolean',
defaultValue: 'false',
desc: {
'zh-CN': '关闭时销毁抽屉内的元素,而非隐藏',
'en-US': 'Destroy elements inside the drawer when closing, instead of hiding them'
},
mode: ['pc'],
pcDemo: 'destroy-on-close',
mfDemo: '',
meta: {
stable: '3.31.0'
}
},
{
name: 'dragable',
type: 'boolean',

View File

@ -10,8 +10,10 @@ export default {
type: 'string',
defaultValue: '',
desc: {
'zh-CN': '限制文件类型thumbnail-mode 模式下此参数无效)',
'en-US': 'Restrict the types of files. This parameter is invalid in thumbnail-mode mode'
'zh-CN':
'限制文件类型thumbnail-mode 模式下此参数无效),支持<code> 后缀名(.pdfMIME类型image/pngMIME类型通配符image/*</code>, 多个格式以逗号分隔',
'en-US':
'Restrict the types of files. This parameter is invalid in thumbnail-mode mode, support <code>suffix (.pdf), MIME type (image/png), MIME type wildcard (image/*)</code>, separated by commas'
},
mode: ['pc', 'mobile-first'],
pcDemo: 'accept-file-image',

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,7 @@ export default {
'en-US': ''
},
mode: ['pc', 'mobile-first'],
pcDemo: ''
pcDemo: 'before-link-open'
},
{
name: 'data-type',

View File

@ -2954,7 +2954,7 @@ export default {
},
{
name: 'type',
type: "'index' | 'selection' | 'radio' | 'expand'",
type: "'index' | 'selection' | 'radio' | 'expand' | 'operation'",
defaultValue: '',
desc: {
'zh-CN': '设置内置列的类型',
@ -2963,6 +2963,20 @@ export default {
mode: ['pc', 'mobile-first'],
pcDemo: 'grid-serial-column#serial-column-default-serial-column'
},
{
name: 'operation-config',
typeAnchorName: 'IOperationConfig',
type: 'IOperationConfig',
defaultValue: '',
desc: {
'zh-CN': '当 <code>type="operation"</code> 时有效,通过 <code>operationConfig</code> 配置操作列',
'en-US':
'Effective when <code>type="operation"</code> is specified. Configures the operation column through <code>operationConfig</code>'
},
mode: ['pc', 'mobile-first'],
pcDemo: 'grid-operation-column#operation-column',
mfDemo: 'operation-column'
},
{
name: 'width',
type: 'number | string',
@ -2974,7 +2988,7 @@ export default {
'Set the column width. The value can be pixel, percentage, or auto. If the value is auto, the column width automatically adapts.; column width; The optional value of this property is integer/px/%'
},
mode: ['pc', 'mobile-first'],
pcDemo: 'grid-size#size-fixed-column-width'
pcDemo: 'operation-column'
}
],
events: [],
@ -4212,6 +4226,8 @@ interface IFilterConfig {
defaultFilter?: boolean
// 设置在过滤面板中显示输入筛选true 使用默认 input或传入 IInputFilterConfig 配置
inputFilter?: boolean | IInputFilterConfig
// 重置输入时的回调
onResetInputFilter?: (ref: any) => void
// 设置枚举选项的静态数据源,也可为函数 (params) => Promise<Array<{label,value,checked?}>>
values?: Array<{ [key: string]: any }> | (params: { property: string; filter: IFilterConfig }) => Promise<Array<{ [key: string]: any }>>
// 设置枚举数据的显示值属性字段,默认 'label'
@ -4245,8 +4261,6 @@ interface IInputFilterConfig {
relations?: IRelationFilterItem[]
// 默认选中的 relation 值
relation?: string
// 重置输入时的回调
onResetInputFilter?: (ref: any) => void
}
// 关系选项项
@ -4362,6 +4376,21 @@ interface ICustomConfig {
visible?: boolean
// 列宽
width?: number | string
}`
},
{
name: 'IOperationConfig',
type: 'type',
code: `
interface IOperationConfig {
// 操作列的按钮配置
buttons: Array<{name:string, icon:Icon, click:()=>void, hidden:(row)=> boolean, class?:string, disabled?:boolean| (row)=> boolean}>
// 最多显示按钮数默认值为3
max?: number
// 自定义操作列渲染函数, 优先级高
render?: ({h, buttons, params}) => VNode
// 禁用时需要添加的class
disabledClass?:string
}`
}
]

View File

@ -10,12 +10,16 @@ export default {
type: 'Function',
defaultValue: '',
desc: {
'zh-CN': '可以配置一个拦截弹窗关闭的方法。如果方法返回 false 值,则拦截弹窗关闭;否则不拦截',
'zh-CN': '设置关闭前的回调函数(仅点击关闭按钮或遮罩区域时被调用),如果回调函数返回 false 则阻止窗口关闭',
'en-US':
'Configure a method to intercept modal closing. If the method returns false, the modal closing is intercepted; otherwise it is not intercepted'
'Set the callback function before closing (only called when clicking the close button or mask area). If the callback function returns false, the modal will not be closed'
},
mode: ['mobile-first'],
mfDemo: ''
mode: ['pc', 'mobile-first'],
mfDemo: '',
pcDemo: 'before-close',
meta: {
stable: '3.31.0'
}
},
{
name: 'cancel-btn-props',

View File

@ -409,6 +409,22 @@ export default {
mode: ['pc', 'mobile-first'],
pcDemo: 'string-mode',
mfDemo: ''
},
{
name: 'parse-input',
type: '(value:string)=>string',
defaultValue: '',
desc: {
'zh-CN': '自定义输入值解析函数,优先级较高。当输入字符以及粘贴时,会调用该函数进行解析。',
'en-US':
'custom input value parsing function with high priority.This function is called for parsing when input characters are entered or pasted'
},
mode: ['pc', 'mobile-first'],
pcDemo: 'parse-input',
mfDemo: '',
meta: {
stable: '3.31.0'
}
}
],
events: [

View File

@ -1,5 +1,5 @@
export default {
mode: ['mobile-first'],
mode: ['pc', 'mobile-first'],
apis: [
{
name: 'slider-button',
@ -13,7 +13,7 @@ export default {
'zh-CN': '设置滑块项禁用态',
'en-US': ''
},
mode: ['mobile-first'],
mode: ['pc', 'mobile-first'],
mfDemo: ''
},
{
@ -27,17 +27,6 @@ export default {
mode: ['mobile-first'],
mfDemo: ''
},
{
name: 'disabled',
type: 'Boolean',
defaultValue: '',
desc: {
'zh-CN': '设置滑块项禁用态',
'en-US': ''
},
mode: ['mobile-first'],
mfDemo: ''
},
{
name: 'label',
type: 'Number / String',
@ -46,7 +35,7 @@ export default {
'zh-CN': '设置 Button 的内容',
'en-US': ''
},
mode: ['mobile-first'],
mode: ['pc', 'mobile-first'],
mfDemo: ''
},
{
@ -57,7 +46,7 @@ export default {
'zh-CN': '设置 Button 的内容',
'en-US': ''
},
mode: ['mobile-first'],
mode: ['pc', 'mobile-first'],
mfDemo: ''
}
],
@ -72,7 +61,7 @@ export default {
'zh-CN': '组件默认插槽',
'en-US': ''
},
mode: ['mobile-first'],
mode: ['pc', 'mobile-first'],
mfDemo: ''
}
]

View File

@ -147,6 +147,21 @@ export default {
},
mode: ['mobile-first'],
mfDemo: ''
},
{
name: 'display-only',
type: 'boolean',
defaultValue: 'false',
desc: {
'zh-CN': '设置开关为只读状态',
'en-US': 'Set the switch to read-only status'
},
meta: {
stable: '3.31.0'
},
mode: ['pc', 'mobile-first'],
pcDemo: 'display-only',
mfDemo: 'display-only'
}
],
events: [

View File

@ -16,6 +16,21 @@ export default {
mode: ['pc'],
pcDemo: 'delete'
},
{
name: 'round',
type: 'boolean',
defaultValue: '',
desc: {
'zh-CN': '是否设置圆角标签',
'en-US': 'Whether to set the rounded corner label'
},
mode: ['pc', 'mobile-first'],
meta: {
stable: '3.31.0'
},
pcDemo: 'rounded',
mfDemo: 'rounded'
},
{
name: 'closable',
type: 'boolean',
@ -246,7 +261,7 @@ type ISize = 'medium' | 'small' | ''
name: 'IType',
type: 'type',
code: `
type IType = 'success' | 'info' | 'warning' | 'danger'
type IType = 'success' | 'info' | 'warning' | 'danger' | 'alerting' | 'error'
`
}
]

View File

@ -1,7 +1,11 @@
<template>
<div>
<tiny-alert size="small" title="size 为 small" description="size 为 small"></tiny-alert>
<tiny-alert size="medium" title="size 为 medium" description="size 为 medium"></tiny-alert>
<tiny-alert size="normal" description="size 为 normal"></tiny-alert>
<tiny-alert size="large" title="size 为 large"></tiny-alert>
<tiny-alert size="large" title="size 为 large">
<!-- <span>自定义内容</span> -->
</tiny-alert>
</div>
</template>

View File

@ -18,13 +18,14 @@ export default {
{
demoId: 'size',
name: {
'zh-CN': '尺寸',
'en-US': 'Large size'
'zh-CN': '尺寸',
'en-US': 'Size'
},
desc: {
'zh-CN': '<p>通过 <code>size</code> 属性设置不同的尺寸可选值nomal、large默认值nomal 。</p>',
'zh-CN':
'<p>通过 <code>size</code> 设置不同的尺寸模式,可选值: <code>small</code> 、<code>medium</code> 、<code>normal</code> 、<code>large</code>。</p>',
'en-US':
'<p>Use the <code>size</code> attribute to set different sizes. The options are nomal and large. The default value is nomal.</p>'
'<p>Set different size modes through<code>size</code>, with optional values:<code>small</code> 、<code>medium</code> 、<code>normal</code> 、<code>large</code>.</p>'
},
codeFiles: ['size.vue']
},

View File

@ -41,7 +41,7 @@
<div>场景 5自定义图标 + 自定义样式</div>
<br />
<tiny-base-select
v-model="value4"
v-model="value5"
multiple
:dropdown-icon="iconPopup"
:drop-style="{ width: '200px', 'min-width': '200px' }"
@ -85,6 +85,7 @@ export default {
value2: ['选项 1', '选项 2'],
value3: ['选项 1', '选项 2'],
value4: [],
value5: [],
iconPopup: iconPopup()
}
}

View File

@ -0,0 +1,39 @@
<template>
<div>
<tiny-button @click="openDrawer" type="primary"> 抽屉组件AppendToBody </tiny-button>
<tiny-drawer
v-if="visible"
title="标题"
:visible="visible"
:append-to-body="true"
@update:visible="visible = $event"
@confirm="confirm"
>
<div>内容区域</div>
</tiny-drawer>
</div>
</template>
<script>
import { TinyDrawer, TinyButton } from '@opentiny/vue'
export default {
components: {
TinyDrawer,
TinyButton
},
data() {
return {
visible: false
}
},
methods: {
openDrawer() {
this.visible = true
},
confirm() {
this.visible = false
}
}
}
</script>

View File

@ -28,6 +28,18 @@ export default {
},
codeFiles: ['placement.vue']
},
{
demoId: 'to-body',
name: {
'zh-CN': '渲染在 body 中',
'en-US': 'Rendering in body'
},
desc: {
'zh-CN': '<p>添加 <code>append-to-body</code> 属性设置是否插入至<code>body</code></p>',
'en-US': 'Add<code>append to body</code>attribute to set whether to insert into<code>body</code>'
},
codeFiles: ['to-body.vue']
},
{
demoId: 'width',
name: {

View File

@ -1,121 +1,109 @@
<template>
<tiny-async-flowchart
ref="chart"
:fetch="fetchFunc"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-async-flowchart>
<div>
<tiny-async-flowchart
ref="chart"
:fetch="fetchFunc"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
/>
</div>
</template>
<script>
import { TinyFlowchart, TinyAsyncFlowchart, TinyModal } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createConfig, Node } = TinyFlowchart
const nodeWrapperSize = 32
const chartData = {
nodes: [
{
name: '0',
info: {
col: 0,
row: 0,
status: 1,
other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' }
},
hidden: false
},
{
name: '1',
info: {
col: 1,
row: 0,
status: 1,
other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' }
}
},
{
name: '2',
info: {
col: 2,
row: 0,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四、张五、张六、张七', auxi: '2023-01-03' }
}
},
{
name: '3',
info: {
col: 3,
row: 0,
status: 2,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' }
}
},
{
name: '4',
info: {
col: 4,
row: 0,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100, //
height: 90,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
renderOuter: (h, node) => {
return h(Node, { props: { node, config: chartConfig } })
},
type: 'dot',
nodeWrapperSize,
showArrow: false,
nodeSize: 'medium' /* mini/small/medium */
})
export default {
components: {
TinyAsyncFlowchart
components: { TinyAsyncFlowchart },
data() {
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100,
height: 90,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
renderOuter(h, node) {
return h(Node, { props: { node, config: chartConfig } })
},
type: 'dot',
nodeWrapperSize,
showArrow: false,
nodeSize: 'medium'
})
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, status: 1, other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' } },
hidden: false
},
{
name: '1',
info: { col: 1, row: 0, status: 1, other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' } }
},
{
name: '2',
info: {
col: 2,
row: 0,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四', auxi: '2023-01-03' }
}
},
{
name: '3',
info: { col: 3, row: 0, status: 2, other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' } }
},
{
name: '4',
info: {
col: 4,
row: 0,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
}
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
},
fetchFunc() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: chartData, config: chartConfig })
const self = this
return new Promise(function (resolve) {
setTimeout(function () {
resolve({ data: self.chartData, config: self.chartConfig })
}, 300)
})
}

View File

@ -1,13 +1,14 @@
<template>
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
<div>
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
/>
</div>
</template>
<script>
@ -17,104 +18,85 @@ import { hooks } from '@opentiny/vue-common'
const { createConfig, Node, resizeMixin } = TinyFlowchart
const nodeWrapperSize = 32
const chartData = {
nodes: [
{
name: '0',
info: {
col: 0,
row: 0,
status: 1,
other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' }
},
hidden: false
},
{
name: '1',
info: {
col: 1,
row: 0,
status: 1,
other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' }
}
},
{
name: '2',
info: {
col: 2,
row: 0,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四、张五、张六、张七', auxi: '2023-01-03' }
}
},
{
name: '3',
info: {
col: 3,
row: 0,
status: 2,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' }
}
},
{
name: '4',
info: {
col: 4,
row: 0,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100, //
height: 90,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
renderOuter: (h, node) => {
return h(Node, {
props: {
node,
config: chartConfig,
cursorPointerFn: (state) => {
//
if (state.statCompleted) {
return false
}
return true
}
}
})
},
type: 'dot',
nodeWrapperSize,
showArrow: false,
nodeSize: 'medium' /* mini/small/medium */
})
export default {
mixins: [resizeMixin({ refName: 'chart', nodeWrapperSize })],
components: {
TinyFlowchart
},
components: { TinyFlowchart },
data() {
const self = this
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100,
height: 90,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
renderOuter(h, node) {
return h(Node, {
props: {
node,
config: chartConfig,
cursorPointerFn(state) {
if (state.statCompleted) return false
return true
}
}
})
},
type: 'dot',
nodeWrapperSize,
showArrow: false,
nodeSize: 'medium'
})
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, status: 1, other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' } },
hidden: false
},
{
name: '1',
info: { col: 1, row: 0, status: 1, other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' } }
},
{
name: '2',
info: {
col: 2,
row: 0,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四', auxi: '2023-01-03' }
}
},
{
name: '3',
info: { col: 3, row: 0, status: 2, other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' } }
},
{
name: '4',
info: {
col: 4,
row: 0,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
@ -122,15 +104,12 @@ export default {
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
}
}

View File

@ -1,220 +1,194 @@
<template>
<tiny-async-flowchart
ref="chart"
:fetch="fetchFunc"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
@click-group="onClickGroup"
>
</tiny-async-flowchart>
<div>
<tiny-async-flowchart
ref="chart"
:fetch="fetchFunc"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
@click-group="onClickGroup"
/>
</div>
</template>
<script>
import { TinyFlowchart, TinyAsyncFlowchart, TinyModal } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createConfig, Node } = TinyFlowchart
const nodeWrapperSize = 32
const chartData = {
nodes: [
{
name: '0',
info: {
col: 0,
row: 0,
status: 1,
other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' }
},
hidden: false
},
{
name: '1',
info: {
col: 0,
row: 1,
status: 1,
other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' }
}
},
{
name: '2',
info: {
col: 0,
row: 2,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四、张五、张六、张七', auxi: '2023-01-03' }
}
},
{
name: '3',
info: {
col: 0,
row: 3,
status: 2,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' }
}
},
{
name: '4',
info: {
col: 1,
row: 3,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
},
{
name: '5',
info: {
col: 0,
row: 4,
status: 3,
other: { title: '复核会计', subtitle: '协同:张三、张四', auxi: '' }
}
},
{
name: '6',
info: { col: 0, row: 5, status: 3, other: { title: '结束' } },
hidden: false
}
],
links: [
{ from: '0', to: '1', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{
from: '2',
to: '4',
fromJoint: 'bottom',
toJoint: 'top',
linkOffset: 96,
showArrow: false,
info: { status: 1, style: 'solid', other: { title: '条件1' } }
},
{ from: '3', to: '5', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'solid' } },
{
from: '4',
to: '5',
fromJoint: 'bottom',
toJoint: 'top',
arrowEndMinus: 96,
// showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
},
{ from: '5', to: '6', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'dashed' } }
],
groups: [
{
nodes: ['2', '3'],
padding: [20, 100], // fillStyle: '#f5f6f8',
lineDash: [6, 6],
strokeStyle: '#8d959e',
title: '主账',
titlePosition: 'top-left' /* top/top-left */,
titleClass: 'bg-color-bg-1'
}
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 200, //
height: 0,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#0067D1', 2: '#0067d1', 3: 'rgba(22,30,38,0.2)', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'right',
type: 'dot',
renderOuter: (h, node) => {
return h(Node, { props: { node, config: chartConfig, titleClass: 'bg-color-bg-1' } })
},
nodeLayout: 'left-right' /* up-down/left-right */,
nodeSize: 'medium' /* mini/small/medium */,
nodeWrapperSize,
titleMaxWidth: 90,
linkEndMinus: 6,
showOnly: '' /* icon/title */,
linkPath: [
{
filter: { from: '4', to: '5' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0 / 2, y: y0 + h0 }
//
const t = { x: x1 + w1, y: y1 + h1 / 2 }
//
const c = { x: f.x, y: t.y }
// // a. 线
// return [f, c, t]
// b. 线线
return {
path: [f, c, t],
mid: { x: (f.x + c.x) / 2, y: (f.y + c.y) / 2 },
linear: { stops: [0, 1], colors: ['gold', 'blue'] }
}
}
},
{
filter: { from: '2', to: '4' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1 + w1 / 2, y: y1 }
//
const c = { x: t.x, y: f.y }
// a. 线
return [f, c, t]
// // b. 线
// return { path: [f, c, t], mid: c }
}
}
],
condClass: 'bg-color-bg-1'
})
export default {
components: {
TinyAsyncFlowchart
components: { TinyAsyncFlowchart },
data() {
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 200,
height: 0,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#0067D1', 2: '#0067d1', 3: 'rgba(22,30,38,0.2)', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'right',
type: 'dot',
renderOuter(h, node) {
return h(Node, { props: { node, config: chartConfig, titleClass: 'bg-color-bg-1' } })
},
nodeLayout: 'left-right',
nodeSize: 'medium',
nodeWrapperSize,
titleMaxWidth: 90,
linkEndMinus: 6,
showOnly: '',
linkPath: [
{
filter: { from: '4', to: '5' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width / 2,
y: afterNodeCouple[0].y + afterNodeCouple[0].height
}
const t = {
x: afterNodeCouple[1].x + afterNodeCouple[1].width,
y: afterNodeCouple[1].y + afterNodeCouple[1].height / 2
}
const c = { x: f.x, y: t.y }
return {
path: [f, c, t],
mid: { x: (f.x + c.x) / 2, y: (f.y + c.y) / 2 },
linear: { stops: [0, 1], colors: ['gold', 'blue'] }
}
}
},
{
filter: { from: '2', to: '4' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width,
y: afterNodeCouple[0].y + afterNodeCouple[0].height / 2
}
const t = { x: afterNodeCouple[1].x + afterNodeCouple[1].width / 2, y: afterNodeCouple[1].y }
const c = { x: t.x, y: f.y }
return [f, c, t]
}
}
],
condClass: 'bg-color-bg-1'
})
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, status: 1, other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' } },
hidden: false
},
{
name: '1',
info: { col: 0, row: 1, status: 1, other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' } }
},
{
name: '2',
info: {
col: 0,
row: 2,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四', auxi: '2023-01-03' }
}
},
{
name: '3',
info: { col: 0, row: 3, status: 2, other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' } }
},
{
name: '4',
info: {
col: 1,
row: 3,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
},
{
name: '5',
info: { col: 0, row: 4, status: 3, other: { title: '复核会计', subtitle: '协同:张三、张四', auxi: '' } }
},
{ name: '6', info: { col: 0, row: 5, status: 3, other: { title: '结束' } }, hidden: false }
],
links: [
{ from: '0', to: '1', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{
from: '2',
to: '4',
fromJoint: 'bottom',
toJoint: 'top',
linkOffset: 96,
showArrow: false,
info: { status: 1, style: 'solid', other: { title: '条件1' } }
},
{ from: '3', to: '5', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'solid' } },
{
from: '4',
to: '5',
fromJoint: 'bottom',
toJoint: 'top',
arrowEndMinus: 96,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
},
{ from: '5', to: '6', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'dashed' } }
],
groups: [
{
nodes: ['2', '3'],
padding: [20, 100],
lineDash: [6, 6],
strokeStyle: '#8d959e',
title: '主账',
titlePosition: 'top-left',
titleClass: 'bg-color-bg-1'
}
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
}
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
},
onClickGroup(afterGroup, e) {
// console.log(afterGroup, e)
TinyModal.message('click-group')
},
fetchFunc() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: chartData, config: chartConfig })
const self = this
return new Promise(function (resolve) {
setTimeout(function () {
resolve({ data: self.chartData, config: self.chartConfig })
}, 300)
})
}

View File

@ -1,14 +1,15 @@
<template>
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
@click-group="onClickGroup"
>
</tiny-flowchart>
<div>
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
@click-group="onClickGroup"
/>
</div>
</template>
<script>
@ -18,186 +19,155 @@ import { hooks } from '@opentiny/vue-common'
const { createConfig, Node, resizeMixin } = TinyFlowchart
const nodeWrapperSize = 32
const chartData = {
nodes: [
{
name: '0',
info: {
col: 0,
row: 0,
status: 1,
other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' }
},
hidden: false
},
{
name: '1',
info: {
col: 0,
row: 1,
status: 1,
other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' }
}
},
{
name: '2',
info: {
col: 0,
row: 2,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四、张五、张六、张七', auxi: '2023-01-03' }
}
},
{
name: '3',
info: {
col: 0,
row: 3,
status: 2,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' }
}
},
{
name: '4',
info: {
col: 1,
row: 3,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
},
{
name: '5',
info: {
col: 0,
row: 4,
status: 3,
other: { title: '复核会计', subtitle: '协同:张三、张四', auxi: '' }
}
},
{
name: '6',
info: { col: 0, row: 5, status: 3, other: { title: '结束' } },
hidden: false
}
],
links: [
{ from: '0', to: '1', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{
from: '2',
to: '4',
fromJoint: 'bottom',
toJoint: 'top',
linkOffset: 96,
showArrow: false,
info: { status: 1, style: 'solid', other: { title: '条件1' } }
},
{ from: '3', to: '5', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'solid' } },
{
from: '4',
to: '5',
fromJoint: 'bottom',
toJoint: 'top',
arrowEndMinus: 96,
// showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
},
{ from: '5', to: '6', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'dashed' } }
],
groups: [
{
nodes: ['2', '3'],
padding: [20, 100], // fillStyle: '#f5f6f8',
lineDash: [6, 6],
strokeStyle: '#8d959e',
title: '主账',
titlePosition: 'top-left' /* top/top-left */,
titleClass: 'bg-color-bg-1'
}
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 200, //
height: 0,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#0067D1', 2: '#0067d1', 3: 'rgba(22,30,38,0.2)', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'right',
type: 'dot',
renderOuter: (h, node) => {
return h(Node, { props: { node, config: chartConfig, titleClass: 'bg-color-bg-1' } })
},
nodeLayout: 'left-right' /* up-down/left-right */,
nodeSize: 'medium' /* mini/small/medium */,
nodeWrapperSize,
titleMaxWidth: 90,
linkEndMinus: 6,
showOnly: '' /* icon/title */,
linkPath: [
{
filter: { from: '4', to: '5' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0 / 2, y: y0 + h0 }
//
const t = { x: x1 + w1, y: y1 + h1 / 2 }
//
const c = { x: f.x, y: t.y }
// // a. 线
// return [f, c, t]
// b. 线线
return {
path: [f, c, t],
mid: { x: (f.x + c.x) / 2, y: (f.y + c.y) / 2 },
linear: { stops: [0, 1], colors: ['gold', 'blue'] }
}
}
},
{
filter: { from: '2', to: '4' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1 + w1 / 2, y: y1 }
//
const c = { x: t.x, y: f.y }
// a. 线
return [f, c, t]
// // b. 线
// return { path: [f, c, t], mid: c }
}
}
],
condClass: 'bg-color-bg-1'
})
export default {
mixins: [resizeMixin({ refName: 'chart', nodeWrapperSize })],
components: {
TinyFlowchart
},
components: { TinyFlowchart },
data() {
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 200,
height: 0,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#0067D1', 2: '#0067d1', 3: 'rgba(22,30,38,0.2)', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'right',
type: 'dot',
renderOuter(h, node) {
return h(Node, { props: { node, config: chartConfig, titleClass: 'bg-color-bg-1' } })
},
nodeLayout: 'left-right',
nodeSize: 'medium',
nodeWrapperSize,
titleMaxWidth: 90,
linkEndMinus: 6,
showOnly: '',
linkPath: [
{
filter: { from: '4', to: '5' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width / 2,
y: afterNodeCouple[0].y + afterNodeCouple[0].height
}
const t = {
x: afterNodeCouple[1].x + afterNodeCouple[1].width,
y: afterNodeCouple[1].y + afterNodeCouple[1].height / 2
}
const c = { x: f.x, y: t.y }
return {
path: [f, c, t],
mid: { x: (f.x + c.x) / 2, y: (f.y + c.y) / 2 },
linear: { stops: [0, 1], colors: ['gold', 'blue'] }
}
}
},
{
filter: { from: '2', to: '4' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width,
y: afterNodeCouple[0].y + afterNodeCouple[0].height / 2
}
const t = { x: afterNodeCouple[1].x + afterNodeCouple[1].width / 2, y: afterNodeCouple[1].y }
const c = { x: t.x, y: f.y }
return [f, c, t]
}
}
],
condClass: 'bg-color-bg-1'
})
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, status: 1, other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' } },
hidden: false
},
{
name: '1',
info: { col: 0, row: 1, status: 1, other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' } }
},
{
name: '2',
info: {
col: 0,
row: 2,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四', auxi: '2023-01-03' }
}
},
{
name: '3',
info: { col: 0, row: 3, status: 2, other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' } }
},
{
name: '4',
info: {
col: 1,
row: 3,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
},
{
name: '5',
info: { col: 0, row: 4, status: 3, other: { title: '复核会计', subtitle: '协同:张三、张四', auxi: '' } }
},
{ name: '6', info: { col: 0, row: 5, status: 3, other: { title: '结束' } }, hidden: false }
],
links: [
{ from: '0', to: '1', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'bottom', toJoint: 'top', info: { status: 1, style: 'solid' } },
{
from: '2',
to: '4',
fromJoint: 'bottom',
toJoint: 'top',
linkOffset: 96,
showArrow: false,
info: { status: 1, style: 'solid', other: { title: '条件1' } }
},
{ from: '3', to: '5', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'solid' } },
{
from: '4',
to: '5',
fromJoint: 'bottom',
toJoint: 'top',
arrowEndMinus: 96,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
},
{ from: '5', to: '6', fromJoint: 'bottom', toJoint: 'top', info: { status: 3, style: 'dashed' } }
],
groups: [
{
nodes: ['2', '3'],
padding: [20, 100],
lineDash: [6, 6],
strokeStyle: '#8d959e',
title: '主账',
titlePosition: 'top-left',
titleClass: 'bg-color-bg-1'
}
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
@ -205,19 +175,15 @@ export default {
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
},
onClickGroup(afterGroup, e) {
// console.log(afterGroup, e)
TinyModal.message('click-group')
}
}

View File

@ -1,14 +1,15 @@
<template>
<tiny-flowchart
ref="chart"
class="text-xs"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
<div>
<tiny-flowchart
ref="chart"
class="text-xs"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
/>
</div>
</template>
<script>
@ -18,313 +19,188 @@ import { hooks } from '@opentiny/vue-common'
const { createConfig, resizeMixin } = TinyFlowchart
const nodeWrapperSize = 130
const chartData = {
nodes: [
{
name: '0',
info: {
row: 0,
col: 1,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '1',
info: {
row: 0,
col: 2,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '2',
info: {
row: 1,
col: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '3',
info: {
row: 1,
col: 3,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '4',
info: {
row: 1,
col: 4,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '5',
info: {
row: 2,
col: 1.5,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
}
],
links: [
{
from: '2',
to: '0',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件1' } }
},
{
from: '0',
to: '1',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
},
{
from: '1',
to: '3',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件3' } }
},
{
from: '3',
to: '4',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件4' } }
},
{
from: '2',
to: '5',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件5' } }
},
{
from: '5',
to: '3',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件6' } }
}
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100, //
height: 240,
gap: 24,
padding: 12,
prior: 'vertical',
align: 'center',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
nodeWrapperSize,
type: 'dot',
lineWidth: 20,
hoverHit: 10,
autoAdjust: false,
layout: ({ afterNodes, graphHeight, graphWidth }) => {
const colSize = graphWidth / 5
const rowSize = graphHeight / 3
return afterNodes.map((afterNode) => ({
x: ~~((afterNode.col + 0.5) * colSize) - 70,
y: ~~((afterNode.row + 0.5) * rowSize) - 28
}))
},
linkPath: [
{
filter: { from: '2', to: '0' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 - 10 }
//
const t = { x: x1, y: y1 + h1 / 2 }
const c0 = { x: f.x + 30, y: f.y }
const c1 = { x: c0.x, y: t.y }
return {
path: [f, c0, c1, t],
mid: { x: (c1.x + t.x) / 2, y: (c1.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '2', to: '5' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 + 10 }
//
const t = { x: x1, y: y1 + h1 / 2 }
const c0 = { x: f.x + 30, y: f.y }
const c1 = { x: c0.x, y: t.y }
return {
path: [f, c0, c1, t],
mid: { x: (c1.x + t.x) / 2, y: (c1.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '0', to: '1' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1, y: y1 + h1 / 2 }
return {
path: [f, t],
mid: { x: (f.x + t.x) / 2, y: (f.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '1', to: '3' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1, y: y1 + h1 / 2 - 10 }
const c1 = { x: t.x - 30, y: t.y }
const c0 = { x: c1.x, y: f.y }
return {
path: [f, c0, c1, t],
mid: { x: (c1.x + t.x) / 2, y: (c1.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '5', to: '3' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1, y: y1 + h1 / 2 + 10 }
const c1 = { x: t.x - 30, y: t.y }
const c0 = { x: c1.x, y: f.y }
return {
path: [f, c0, c1, t],
mid: { x: (c1.x + t.x) / 2, y: (c1.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '3', to: '4' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1, y: y1 + h1 / 2 }
return {
path: [f, t],
mid: { x: (f.x + t.x) / 2, y: (f.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
}
],
renderOuter: (h, node) => {
return h(
'div',
{
style: { width: '100%', height: '100%', background: 'white', border: '1px solid #4facfe', borderRadius: '4px' }
},
`自定义区域-${node.name}`
)
},
renderCond: (h, afterLink, cfg) => {
const { raw: link } = afterLink
// link.info.other.title
return h('div', `${link.from}-${link.to}`)
}
})
export default {
mixins: [resizeMixin({ refName: 'chart', nodeWrapperSize })],
components: {
TinyFlowchart
},
components: { TinyFlowchart },
data() {
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100,
height: 240,
gap: 24,
padding: 12,
prior: 'vertical',
align: 'center',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
nodeWrapperSize,
type: 'dot',
lineWidth: 20,
hoverHit: 10,
autoAdjust: false,
layout({ afterNodes, graphHeight, graphWidth }) {
const colSize = graphWidth / 5
const rowSize = graphHeight / 3
return afterNodes.map(function (afterNode) {
return {
x: ~~((afterNode.col + 0.5) * colSize) - 70,
y: ~~((afterNode.row + 0.5) * rowSize) - 28
}
})
},
linkPath: [
{
filter: { from: '2', to: '0' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width,
y: afterNodeCouple[0].y + afterNodeCouple[0].height / 2 - 10
}
const t = { x: afterNodeCouple[1].x, y: afterNodeCouple[1].y + afterNodeCouple[1].height / 2 }
const c0 = { x: f.x + 30, y: f.y }
const c1 = { x: c0.x, y: t.y }
return {
path: [f, c0, c1, t],
mid: { x: (c1.x + t.x) / 2, y: (c1.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '2', to: '5' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width,
y: afterNodeCouple[0].y + afterNodeCouple[0].height / 2 + 10
}
const t = { x: afterNodeCouple[1].x, y: afterNodeCouple[1].y + afterNodeCouple[1].height / 2 }
const c0 = { x: f.x + 30, y: f.y }
const c1 = { x: c0.x, y: t.y }
return {
path: [f, c0, c1, t],
mid: { x: (c1.x + t.x) / 2, y: (c1.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
}
],
renderOuter(h, node) {
return h(
'div',
{
style: {
width: '100%',
height: '100%',
background: 'white',
border: '1px solid #4facfe',
borderRadius: '4px'
}
},
'自定义区域-' + node.name
)
}
})
const chartData = {
nodes: [
{
name: '0',
info: { row: 0, col: 1, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '1',
info: { row: 0, col: 2, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '2',
info: { row: 1, col: 0, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '3',
info: { row: 1, col: 3, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '4',
info: { row: 1, col: 4, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '5',
info: {
row: 2,
col: 1.5,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
}
],
links: [
{
from: '2',
to: '0',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件1' } }
},
{
from: '0',
to: '1',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
},
{
from: '1',
to: '3',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件3' } }
},
{
from: '3',
to: '4',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件4' } }
},
{
from: '2',
to: '5',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件5' } }
},
{
from: '5',
to: '3',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件6' } }
}
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
@ -332,15 +208,12 @@ export default {
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
}
}

View File

@ -1,14 +1,15 @@
<template>
<tiny-flowchart
ref="chart"
class="text-xs"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
<div>
<tiny-flowchart
ref="chart"
class="text-xs"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
/>
</div>
</template>
<script>
@ -18,142 +19,121 @@ import { hooks } from '@opentiny/vue-common'
const { createConfig, resizeMixin } = TinyFlowchart
const nodeWrapperSize = 130
const chartData = {
nodes: [
{
name: '0',
info: {
row: 0,
col: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '1',
info: {
row: 0,
col: 1,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
},
{
name: '2',
info: {
row: 0,
col: 2,
width: 130,
height: 56,
shape: 'rectangle',
status: 1,
other: { main: '', auxi: '' }
}
}
],
links: [
{
from: '0',
to: '1',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件1' } }
},
{
from: '1',
to: '2',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
}
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100, //
height: 0,
gap: 24,
padding: 12,
prior: 'vertical',
align: 'center',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
nodeWrapperSize,
type: 'dot',
lineWidth: 20,
hoverHit: 10,
linkPath: [
{
filter: { from: '0', to: '1' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1, y: y1 + h1 / 2 }
return {
path: [f, t],
mid: { x: (f.x + t.x) / 2, y: (f.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '1', to: '2' },
method: ({ afterLink, afterNodes }) => {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map((name) =>
afterNodes.find((afterNode) => afterNode.raw.name === name)
)
const { x: x0, y: y0, width: w0, height: h0 } = afterNodeCouple[0]
const { x: x1, y: y1, width: w1, height: h1 } = afterNodeCouple[1]
//
const f = { x: x0 + w0, y: y0 + h0 / 2 }
//
const t = { x: x1, y: y1 + h1 / 2 }
return {
path: [f, t],
mid: { x: (f.x + t.x) / 2, y: (f.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] } // '#43e97b','#38f9d7'
}
}
}
],
renderOuter: (h, node) => {
return h(
'div',
{
style: { width: '100%', height: '100%', background: 'white', border: '1px solid #4facfe', borderRadius: '4px' }
},
`自定义区域-${node.name}`
)
}
})
export default {
mixins: [resizeMixin({ refName: 'chart', nodeWrapperSize })],
components: {
TinyFlowchart
},
components: { TinyFlowchart },
data() {
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100,
height: 0,
gap: 24,
padding: 12,
prior: 'vertical',
align: 'center',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
nodeWrapperSize,
type: 'dot',
lineWidth: 20,
hoverHit: 10,
linkPath: [
{
filter: { from: '0', to: '1' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width,
y: afterNodeCouple[0].y + afterNodeCouple[0].height / 2
}
const t = { x: afterNodeCouple[1].x, y: afterNodeCouple[1].y + afterNodeCouple[1].height / 2 }
return {
path: [f, t],
mid: { x: (f.x + t.x) / 2, y: (f.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
},
{
filter: { from: '1', to: '2' },
method({ afterLink, afterNodes }) {
const afterNodeCouple = [afterLink.raw.from, afterLink.raw.to].map(function (name) {
return afterNodes.find(function (afterNode) {
return afterNode.raw.name === name
})
})
const f = {
x: afterNodeCouple[0].x + afterNodeCouple[0].width,
y: afterNodeCouple[0].y + afterNodeCouple[0].height / 2
}
const t = { x: afterNodeCouple[1].x, y: afterNodeCouple[1].y + afterNodeCouple[1].height / 2 }
return {
path: [f, t],
mid: { x: (f.x + t.x) / 2, y: (f.y + t.y) / 2 },
linear: { stops: [0, 1], colors: ['#4facfe', '#00f2fe'] }
}
}
}
],
renderOuter(h, node) {
return h(
'div',
{
style: {
width: '100%',
height: '100%',
background: 'white',
border: '1px solid #4facfe',
borderRadius: '4px'
}
},
'自定义区域-' + node.name
)
}
})
const chartData = {
nodes: [
{
name: '0',
info: { row: 0, col: 0, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '1',
info: { row: 0, col: 1, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
},
{
name: '2',
info: { row: 0, col: 2, width: 130, height: 56, shape: 'rectangle', status: 1, other: { main: '', auxi: '' } }
}
],
links: [
{
from: '0',
to: '1',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件1' } }
},
{
from: '1',
to: '2',
fromJoint: 'right',
toJoint: 'left',
showArrow: false,
info: { status: 3, style: 'solid', other: { title: '条件2' } }
}
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
@ -161,15 +141,12 @@ export default {
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
}
}

View File

@ -1,14 +1,13 @@
<template>
<tiny-flowchart
ref="chart"
class="text-xs"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
<div>
<tiny-flowchart
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
/>
</div>
</template>
<script>
@ -17,81 +16,76 @@ import { hooks } from '@opentiny/vue-common'
const { createConfig } = TinyFlowchart
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, width: 40, shape: 'circle', status: 2, other: { main: '开始' } }
},
{
name: '1',
info: {
col: 1,
row: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 3,
other: { main: '申请人', auxi: '张三' }
}
},
{
name: '2',
info: {
col: 2,
row: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 3,
other: { main: '制单会计', auxi: '协同:张三、张四、张五、张六、张七' }
}
},
{
name: '3',
info: {
col: 3,
row: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 3,
other: { main: '应付会计', auxi: '张四 0035837' }
}
},
{
name: '4',
info: { col: 4, row: 0, width: 40, shape: 'circle', status: 3, other: { main: '结束' } }
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
height: 0,
gap: 24,
padding: 12,
prior: 'vertical',
align: 'center',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'ongoing-fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom'
})
export default {
components: {
TinyFlowchart
},
components: { TinyFlowchart },
data() {
const chartConfig = createConfig()
chartConfig.width = 0
chartConfig.height = 0
chartConfig.gap = 24
chartConfig.padding = 12
chartConfig.prior = 'vertical'
chartConfig.align = 'center'
chartConfig.status = { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'ongoing-fail' }
chartConfig.colors = { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' }
chartConfig.ongoingBackgroundColor = '#f3f8fe'
chartConfig.popoverPlacement = 'bottom'
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, width: 46, shape: 'circle', status: 2, other: { main: '开始' } }
},
{
name: '1',
info: {
col: 1,
row: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 3,
other: { main: '申请人', auxi: '张三' }
}
},
{
name: '2',
info: {
col: 2,
row: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 3,
other: { main: '制单会计', auxi: '协同:张三、张四' }
}
},
{
name: '3',
info: {
col: 3,
row: 0,
width: 130,
height: 56,
shape: 'rectangle',
status: 3,
other: { main: '应付会计', auxi: '张四 0035837' }
}
},
{
name: '4',
info: { col: 4, row: 0, width: 46, shape: 'circle', status: 3, other: { main: '结束' } }
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
@ -99,15 +93,12 @@ export default {
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
}
}

View File

@ -1,127 +1,129 @@
<template>
<div class="demo-flowchart">
<span>切换节点尺寸 </span>
<tiny-button-group :data="groupData" v-model="chartConfig.nodeSize" @change="changeNodeSize"></tiny-button-group>
<div>
<div class="mb-4">
<tiny-button-group :data="sizeList" v-model="activeSize"></tiny-button-group>
</div>
<tiny-async-flowchart
ref="chart"
:fetch="fetchFunc"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-async-flowchart>
/>
</div>
</template>
<script>
import { TinyFlowchart, TinyAsyncFlowchart, TinyButtonGroup, TinyModal } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createConfig, Node } = TinyFlowchart
const nodeWrapperSize = 32
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, status: 1, other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' } },
hidden: false
},
{
name: '1',
info: { col: 1, row: 0, status: 1, other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' } }
},
{
name: '2',
info: {
col: 2,
row: 0,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四、张五、张六、张七', auxi: '2023-01-03' }
}
},
{
name: '3',
info: { col: 3, row: 0, status: 2, other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' } }
},
{
name: '4',
info: {
col: 4,
row: 0,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100, //
height: 90,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
renderOuter: (h, node) => {
return h(Node, { props: { node, config: chartConfig } })
},
type: 'dot',
nodeWrapperSize,
showArrow: false,
nodeSize: 'medium' /* mini/small/medium */
})
export default {
components: {
TinyAsyncFlowchart,
TinyButtonGroup
},
components: { TinyAsyncFlowchart, TinyButtonGroup },
data() {
return {
chartConfig,
groupData: [
{ text: 'medium默认', value: 'medium' },
{ text: 'small', value: 'small' },
{ text: 'mini', value: 'mini' }
const self = this
const chartConfig = createConfig()
Object.assign(chartConfig, {
width: 0,
extraWidth: 100,
height: 90,
gap: 60,
padding: 12,
prior: 'vertical',
align: 'left',
status: { 1: 'completed', 2: 'ongoing', 3: 'not-started', 4: 'fail' },
colors: { 1: '#00a874', 2: '#0067d1', 3: '#999', 4: '#eb171f' },
ongoingBackgroundColor: '#f3f8fe',
popoverPlacement: 'bottom',
renderOuter(h, node) {
return h(Node, { props: { node, config: chartConfig } })
},
type: 'dot',
nodeWrapperSize,
showArrow: false,
nodeSize: 'medium'
})
const chartData = {
nodes: [
{
name: '0',
info: { col: 0, row: 0, status: 1, other: { title: '开始', subtitle: '张三', auxi: '2023-01-01' } },
hidden: false
},
{
name: '1',
info: { col: 1, row: 0, status: 1, other: { title: '申请人', subtitle: '张三', auxi: '2023-01-02' } }
},
{
name: '2',
info: {
col: 2,
row: 0,
status: 1,
other: { title: '制单会计', subtitle: '协同:张三、张四', auxi: '2023-01-03' }
}
},
{
name: '3',
info: { col: 3, row: 0, status: 2, other: { title: '应付会计', subtitle: '张四 0035837', auxi: '' } }
},
{
name: '4',
info: {
col: 4,
row: 0,
status: 4,
other: { title: '应付会计', subtitle: '张四 0035837', auxi: '', error: '人员变更,未同步' }
}
}
],
links: [
{ from: '0', to: '1', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '1', to: '2', fromJoint: 'right', toJoint: 'left', info: { status: 2, style: 'solid' } },
{ from: '2', to: '3', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } },
{ from: '3', to: '4', fromJoint: 'right', toJoint: 'left', info: { status: 3, style: 'solid' } }
]
}
return {
activeSize: 'medium',
sizeList: [
{ text: 'mini', value: 'mini' },
{ text: 'small', value: 'small' },
{ text: 'medium', value: 'medium' }
],
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
}
},
watch: {
activeSize(newVal) {
this.chartConfig.nodeSize = newVal
this.$refs.chart && this.$refs.chart.refresh()
}
},
methods: {
onClickNode(afterNode, e) {
// console.log(afterNode, e)
TinyModal.message('click-node')
},
onClickLink(afterLink, e) {
// console.log(afterLink, e)
TinyModal.message('click-link')
},
onClickBlank(param, e) {
// console.log(param, e)
TinyModal.message('click-blank')
},
fetchFunc() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ data: chartData, config: chartConfig })
const self = this
return new Promise(function (resolve) {
setTimeout(function () {
self.chartConfig.nodeSize = self.activeSize
resolve({ data: self.chartData, config: self.chartConfig })
}, 300)
})
},
changeNodeSize() {
this.$refs.chart.refresh()
}
}
}

View File

@ -3,92 +3,92 @@ export default {
owner: '',
demos: [
{
demoId: 'basic-usage',
name: {
'zh-CN': '垂直图形',
'en-US': 'Vertical Graphics'
},
desc: {
'zh-CN': '<p>节点设置在不同的列data.nodes[i].info.row,就可以创建垂直图形</p>',
'en-US':
'<p>Nodes are set in different columns data.nodes[i].info.row, and vertical graphics can be created.</p>'
},
codeFiles: ['basic-usage.vue']
},
{
demoId: 'horizon',
name: {
'zh-CN': '水平图形',
'en-US': 'Horizontal Graph'
},
desc: {
'zh-CN': '<p>节点设置在不同的列data.nodes[i].info.col,就可以创建水平图形</p>',
'en-US':
'<p>Nodes are set in different columns data.nodes[i].info.col, and horizontal graphics can be created.</p>'
},
codeFiles: ['horizon.vue']
},
{
demoId: 'dot-vertical',
name: {
'zh-CN': '点模式-垂直图形',
'en-US': 'Point Mode - Vertical Graph'
},
desc: {
'zh-CN': '<p></p>',
'en-US': '<p></p>'
},
codeFiles: ['dot-vertical.vue']
},
{
demoId: 'dot-horizon',
name: {
'zh-CN': '点模式-水平图形',
'en-US': 'Point Mode - Horizontal Graph'
},
desc: {
'zh-CN': '<p></p>',
'en-US': '<p></p>'
},
codeFiles: ['dot-horizon.vue']
},
{
demoId: 'dot-horizon-async',
name: {
'zh-CN': '点模式-水平图形-异步',
'en-US': 'Point Mode - Horizontal Graph - Asynchronous'
},
desc: {
'zh-CN': '<p></p>',
'en-US': '<p></p>'
},
codeFiles: ['dot-horizon-async.vue']
},
{
demoId: 'dot-vertical-async',
name: {
'zh-CN': '点模式-垂直图形-异步',
'en-US': 'events'
},
desc: {
'zh-CN': '<p><p>',
'en-US': '<p>Point Mode - Vertical Graph - Asynchronous</p>'
},
codeFiles: ['dot-vertical-async.vue']
},
{
demoId: 'node-size',
name: {
'zh-CN': '节点尺寸',
'en-US': 'Node Size'
},
desc: {
'demoId': 'basic-usage',
'name': { 'zh-CN': '基本用法', 'en-US': 'Basic Usage' },
'desc': {
'zh-CN':
"<p>使用属性 <code>config.nodeSize</code> 可以设置节点尺寸,可选值为 <code>'mini'</code> , <code>'small'</code> , <code>'medium'</code>。<p>",
'Flowchart 流程图最基础的用法,展示节点与连线的基本渲染。通过 <code>createConfig</code> 创建配置与数据,并使用 <code>hooks.markRaw</code> 避免 Vue 对 Canvas 对象进行响应式代理。',
'en-US':
"<p>Using the attribute <code>config.nodeSize</code>, you can set the node size to <code>'mini'</code>, <code>'small'</code>, or <code>'medium'</code>.</p>"
'The most basic usage of Flowchart, demonstrating the basic rendering of nodes and links. Use <code>createConfig</code> to create configuration and data, and use <code>hooks.markRaw</code> to prevent Vue from making Canvas objects reactive.'
},
codeFiles: ['node-size.vue']
'codeFiles': ['basic-usage.vue']
},
{
'demoId': 'horizon',
'name': { 'zh-CN': '横向流程图', 'en-US': 'Horizontal Flowchart' },
'desc': {
'zh-CN':
'标准模式下的横向流程图示例。节点使用矩形形状,通过 <code>fromJoint</code> 和 <code>toJoint</code> 控制连线在节点的连接位置,支持 <code>click-node</code>、<code>click-link</code>、<code>click-blank</code> 事件。',
'en-US':
'A horizontal flowchart example in standard mode. Nodes use rectangular shapes, and the connection positions of links on nodes are controlled via <code>fromJoint</code> and <code>toJoint</code>. Supports <code>click-node</code>, <code>click-link</code>, and <code>click-blank</code> events.'
},
'codeFiles': ['horizon.vue']
},
{
'demoId': 'dot-horizon',
'name': { 'zh-CN': 'Dot 点模式横向', 'en-US': 'Dot Mode Horizontal' },
'desc': {
'zh-CN':
'Dot 点模式下的横向流程图。设置 <code>type: "dot"</code> 开启点模式,节点以圆形图标展示,支持 <code>renderOuter</code> 自定义节点渲染,使用 <code>resizeMixin</code> 实现自适应宽度。',
'en-US':
'Horizontal flowchart in dot mode. Set <code>type: "dot"</code> to enable dot mode, where nodes are displayed as circular icons. Supports <code>renderOuter</code> for custom node rendering and uses <code>resizeMixin</code> for adaptive width.'
},
'codeFiles': ['dot-horizon.vue']
},
{
'demoId': 'dot-vertical',
'name': { 'zh-CN': 'Dot 点模式纵向', 'en-US': 'Dot Mode Vertical' },
'desc': {
'zh-CN':
'Dot 点模式下的纵向流程图。设置 <code>nodeLayout: "left-right"</code> 使标签显示在节点右侧,支持 <code>linkPath</code> 自定义连线路径、<code>groups</code> 分组以及 <code>click-group</code> 事件。',
'en-US':
'Vertical flowchart in dot mode. Set <code>nodeLayout: "left-right"</code> to display labels on the right side of nodes. Supports <code>linkPath</code> for custom link paths, <code>groups</code> for grouping, and <code>click-group</code> events.'
},
'codeFiles': ['dot-vertical.vue']
},
{
'demoId': 'dot-horizon-async',
'name': { 'zh-CN': 'Dot 点模式横向异步', 'en-US': 'Dot Mode Horizontal Async' },
'desc': {
'zh-CN':
'使用 <code>TinyAsyncFlowchart</code> 组件实现异步加载的横向 Dot 点模式流程图。通过 <code>fetch</code> 属性传入数据加载函数,模拟异步请求后渲染图形。',
'en-US':
'Use the <code>TinyAsyncFlowchart</code> component to implement an asynchronously loaded horizontal dot mode flowchart. Data is loaded via the <code>fetch</code> prop, simulating an async request before rendering the chart.'
},
'codeFiles': ['dot-horizon-async.vue']
},
{
'demoId': 'dot-vertical-async',
'name': { 'zh-CN': 'Dot 点模式纵向异步', 'en-US': 'Dot Mode Vertical Async' },
'desc': {
'zh-CN':
'使用 <code>TinyAsyncFlowchart</code> 组件实现异步加载的纵向 Dot 点模式流程图。支持分组、自定义路径和条件连线,展示异步数据加载后的完整交互。',
'en-US':
'Use the <code>TinyAsyncFlowchart</code> component to implement an asynchronously loaded vertical dot mode flowchart. Supports grouping, custom paths, and conditional links, demonstrating full interaction after async data loading.'
},
'codeFiles': ['dot-vertical-async.vue']
},
{
'demoId': 'holistic',
'name': { 'zh-CN': '整体自定义渲染', 'en-US': 'Holistic Custom Rendering' },
'desc': {
'zh-CN':
'通过 <code>renderOuter</code> 完全自定义节点外观,通过 <code>linkPath</code> 自定义连线路径和渐变颜色。展示如何对流程图进行整体视觉定制。',
'en-US':
'Completely customize the node appearance via <code>renderOuter</code> and custom link paths and gradient colors via <code>linkPath</code>. Demonstrates how to perform overall visual customization of the flowchart.'
},
'codeFiles': ['holistic.vue']
},
{
'demoId': 'holistic-fork',
'name': { 'zh-CN': '整体自定义渲染-分叉', 'en-US': 'Holistic Custom Rendering - Fork' },
'desc': {
'zh-CN':
'在整体自定义渲染的基础上,展示分叉布局流程图。通过 <code>layout</code> 自定义节点位置,配合 <code>linkPath</code> 实现复杂的分叉连线路径。',
'en-US':
'Based on holistic custom rendering, demonstrates a fork layout flowchart. Uses <code>layout</code> to customize node positions and <code>linkPath</code> to implement complex forked link paths.'
},
'codeFiles': ['holistic-fork.vue']
},
{
demoId: 'link-path',
@ -103,28 +103,15 @@ export default {
codeFiles: ['link-path.vue']
},
{
demoId: 'holistic',
name: {
'zh-CN': '全景图',
'en-US': 'Panoramic view'
'demoId': 'node-size',
'name': { 'zh-CN': '节点尺寸', 'en-US': 'Node Size' },
'desc': {
'zh-CN':
'通过 <code>nodeSize</code> 配置切换节点尺寸mini / small / medium配合 <code>TinyButtonGroup</code> 实现动态切换,并调用 <code>refresh()</code> 方法刷新图形。',
'en-US':
'Switch node sizes (mini / small / medium) via the <code>nodeSize</code> configuration, combined with <code>TinyButtonGroup</code> for dynamic switching, and call the <code>refresh()</code> method to refresh the chart.'
},
desc: {
'zh-CN': '<p></p>',
'en-US': '<p></p>'
},
codeFiles: ['holistic.vue']
},
{
demoId: 'holistic-fork',
name: {
'zh-CN': '全景图-分叉',
'en-US': 'Panorama - Fork'
},
desc: {
'zh-CN': '<p></p>',
'en-US': '<p></p>'
},
codeFiles: ['holistic-fork.vue']
'codeFiles': ['node-size.vue']
}
]
}

View File

@ -0,0 +1,38 @@
<template>
<div>
<tiny-fluent-editor v-model="content" :before-link-open="handleBeforeLinkOpen"></tiny-fluent-editor>
<div class="fluent-editor-demo__before-link-open__tip">
点击编辑器中的链接会弹出确认框点击确定将打开链接点击取消将拦截跳转
</div>
</div>
</template>
<script>
import TinyFluentEditor from '@opentiny/vue-fluent-editor'
import { TinyModal } from '@opentiny/vue'
export default {
components: {
TinyFluentEditor
},
data() {
return {
content:
'{"ops":[{"insert":"点击访问 "},{"attributes":{"link":"https://opentiny.design"},"insert":"OpenTiny 官网"},{"insert":" 了解更多。"}]}'
}
},
methods: {
handleBeforeLinkOpen({ url }) {
return TinyModal.confirm(`即将打开链接:${url},是否允许跳转?`).then((res) => res === 'confirm')
}
}
}
</script>
<style scoped>
.fluent-editor-demo__before-link-open__tip {
margin: 16px 0;
color: #999;
font-size: 14px;
}
</style>

View File

@ -46,7 +46,8 @@ export default {
'en-US': ''
},
desc: {
'zh-CN': '通过 <code>options</code> 设置编辑器的配置项,支持的配置项和 Quill 的相同,可参考 <a href="https://quilljs.com/docs/configuration#options" target="_blank">Quill</a> 文档。',
'zh-CN':
'通过 <code>options</code> 设置编辑器的配置项,支持的配置项和 Quill 的相同,可参考 <a href="https://quilljs.com/docs/configuration#options" target="_blank">Quill</a> 文档。',
'en-US': ''
},
codeFiles: ['options.vue']
@ -64,5 +65,18 @@ export default {
},
codeFiles: ['data-switch.vue']
},
{
demoId: 'before-link-open',
name: {
'zh-CN': '超链接跳转拦截',
'en-US': ''
},
desc: {
'zh-CN':
'<p>通过 <code>before-link-open</code> 拦截富文本中超链接的跳转。该属性接收一个回调函数,返回 <code>false</code>(或 Promise resolve false可拦截跳转返回 <code>true</code> 继续跳转。</p>',
'en-US': ''
},
codeFiles: ['before-link-open.vue']
}
]
}

View File

@ -1,70 +1,110 @@
<template>
<div class="tiny-demo">
<tiny-radio v-model="value" label="alert" text="alert"></tiny-radio>
<tiny-radio v-model="value" label="confirm" text="confirm"></tiny-radio>
<tiny-radio v-model="value" label="message" text="message"></tiny-radio>
<div style="height: 16px"></div>
<tiny-button @click="handleClick">点击打开 Modal 弹窗</tiny-button>
<div>
<h2>标签式 + 函数调用</h2>
<div class="content">
<tiny-modal
v-model="mainVisible"
:width="600"
:height="300"
:before-close="handleBeforeClose1"
title="Num.1标签式加函数弹窗"
message="NO.1标签式加函数内容"
show-footer
>
</tiny-modal>
<tiny-button @click="mainVisible = true"> 标签式 + 函数式弹窗</tiny-button>
</div>
<h2>点击确认按钮 + 拦截弹窗</h2>
<div class="content">
<tiny-modal
v-model="mainVisible1"
:width="600"
:height="300"
:before-close="handleBeforeClose1"
title="Num.1确认按钮加拦截弹窗"
message="点击确认按钮,出现拦截弹窗"
show-footer
>
<template #footer>
<tiny-button type="primary" @click="onCancelClose1">取消</tiny-button>
<tiny-button style="margin-left: 12px" @click="onConfirmClose1">确定</tiny-button>
</template>
</tiny-modal>
<tiny-button @click="mainVisible1 = true"> 其他</tiny-button>
</div>
</div>
</template>
<script lang="jsx">
import { TinyRadio, TinyModal, TinyButton } from '@opentiny/vue'
import { Button, Modal } from '@opentiny/vue'
export default {
components: {
TinyRadio,
TinyButton
TinyButton: Button,
TinyModal: Modal
},
data() {
return {
value: 'alert'
mainVisible: false,
mainVisible1: false,
confirmVisible: false,
pendingDone: null
}
},
methods: {
beforeClose(type) {
if (this.value === 'alert') {
/* alert close,confirm,esc,mask
这里允许 confirm 关闭 */
return !~['close', 'esc', 'mask'].indexOf(type)
}
if (this.value === 'confirm') {
/* confirm close,confirm,cancel,esc,mask
这里允许 confirm cancel 关闭 */
return !~['close', 'esc', 'mask'].indexOf(type)
}
if (this.value === 'message') {
/* message show
这里允许 show 关闭 */
return type === 'show'
}
handleBeforeClose(type, instance, done) {
this.confirmVisible = true
this.pendingDone = done
return false //
},
handleClick() {
let method
switch (this.value) {
case 'message':
method = TinyModal.message
break
case 'confirm':
method = TinyModal.confirm
break
case 'alert':
method = TinyModal.alert
break
}
onConfirmClose() {
this.confirmVisible = false
this.pendingDone && this.pendingDone() // done()
this.pendingDone = null
},
method({
status: 'info',
title: '普通提示框',
escClosable: true,
maskClosable: true,
beforeClose: this.beforeClose,
message: (h) => [<div>文本信息文本信息文本信息</div>]
// / ->
onCancelClose() {
this.confirmVisible = false
this.pendingDone = null // done()
},
handleBeforeClose1(type, instance, done) {
Modal.confirm({
title: '关闭前确认Num.2',
message: '确认弹窗关闭Num.1',
events: {
confirm: () => done && done(), // ->
cancel: () => {} // ->
}
})
return false //
},
//
onConfirmClose1() {
// beforeClose
this.handleBeforeClose1('confirm', null, () => {
this.mainVisible1 = false
})
},
onCancelClose1() {
this.mainVisible1 = false
}
}
}
</script>
<style scoped>
h2 {
font-size: 16px;
font-weight: bold;
margin: 20px 0 12px;
}
.content {
margin: 8px;
}
</style>

View File

@ -325,8 +325,8 @@ export default {
},
desc: {
'zh-CN':
'<p>通过 `before-close` 属性可以配置一个拦截弹窗关闭的方法。如果方法返回 false 值,则拦截弹窗关闭;否则不拦截<br> 可以通过该拦截方法传入的参数获取关闭的操作类型<br>confirm 弹窗有以下关闭类型:<br> - confirm点击确认时关闭<br>- cancel点击取消时关闭<br> - close点击关闭按钮时关闭<br>- mask: 点击遮罩时关闭<br>- esc通过按钮 esc 时关闭<br>alert 弹窗比 confirm 弹窗少了 `confirm` 类型<br> message 弹窗只有 `show` 一种关闭类型<p>',
'en-US': '<p>bbutton click</p>'
'<p>通过 `before-close` 属性设置关闭前的回调函数仅点击关闭按钮或遮罩区域时被调用。函数入参有type弹窗类型、instance弹窗实例、done回调函数。</p>',
'en-US': ''
},
codeFiles: ['before-close.vue']
}

View File

@ -0,0 +1,41 @@
<template>
<div class="switch-demo">
<div class="demo-item">
<p>开关1只显示文字</p>
<br />
<tiny-switch v-model="val1" display-only></tiny-switch>
<br />
<br />
<p>开关2可交互设置val1的值</p>
<br />
<tiny-switch v-model="val1"></tiny-switch>
</div>
</div>
</template>
<script>
import { TinySwitch } from '@opentiny/vue'
export default {
components: {
TinySwitch
},
data() {
return {
val1: true
}
}
}
</script>
<style scoped>
.demo-item {
padding: 15px;
}
.demo-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 10px;
}
</style>

View File

@ -99,6 +99,20 @@ export default {
'en-US': '<p>bbutton click</p>'
},
codeFiles: ['custom-true-false-value.vue']
},
{
demoId: 'display-only',
name: {
'zh-CN': '只读状态',
'en-US': 'Display Only'
},
desc: {
'zh-CN':
'<p>`<code>display-only</code>属性表示开关为只读状态,默认值为 <code>false</code>。当设置 <code>display-only</code> 为 <code>true</code> 时,开关为只读状态,无法进行交互操作。</p>',
'en-US':
'<p><code>display-only</code> property indicates that the switch is in read-only mode, with a default value of <code>false</code>. When set to <code>true</code>, the switch is in read-only mode and cannot be interacted with.</p>'
},
codeFiles: ['display-only.vue']
}
]
}

View File

@ -0,0 +1,24 @@
<template>
<div class="tiny-tag-demo">
<tiny-tag type="success" effect="light" round> 圆角按钮1 </tiny-tag>
<tiny-tag type="danger" effect="dark" round> 圆角按钮2 </tiny-tag>
<tiny-tag type="warning" effect="plain" round> 圆角按钮3 </tiny-tag>
</div>
</template>
<script lang="jsx">
import { TinyTag } from '@opentiny/vue'
export default {
components: {
TinyTag
}
}
</script>
<style scoped>
.tiny-tag-demo .tiny-tag {
margin-right: 10px;
margin-bottom: 10px;
}
</style>

View File

@ -151,6 +151,18 @@ export default {
'en-US': '<p>Set custom content by binding the `value` property</p>'
},
codeFiles: ['content.vue']
},
{
demoId: 'rounded',
name: {
'zh-CN': '设置圆角按钮',
'en-US': 'Set rounded corner button'
},
desc: {
'zh-CN': '<p>通过设置<code>round</code>属性设置圆角按钮</p>',
'en-US': '<p>Set the round button by setting the<code>round</code>attribute</p>'
},
codeFiles: ['rounded.vue']
}
]
}

View File

@ -4,16 +4,14 @@ test('文字居中', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('alert#center')
// 等待网络空闲,确保页面完全加载
await page.waitForLoadState('networkidle')
// 用文本"文字居中"精确过滤,限定在当前 demo
const alert = page.locator('.tiny-alert').filter({ hasText: '文字居中' })
const alert = page.locator('.tiny-alert')
// 首先验证基础类存在
// 验证基础类
await expect(alert).toHaveClass(/tiny-alert--info/)
await expect(alert).toHaveClass(/tiny-alert--normal/)
// 然后验证居中类
// 验证居中类
await expect(alert).toHaveClass(/is-center/)
await expect(alert).toHaveCSS('justify-content', 'center')
})

View File

@ -1,5 +1,7 @@
<template>
<div>
<tiny-alert size="small" title="size 为 small" description="size 为 small"></tiny-alert>
<tiny-alert size="medium" title="size 为 medium" description="size 为 medium"></tiny-alert>
<tiny-alert size="normal" description="size 为 normal"></tiny-alert>
<tiny-alert size="large" title="size 为 large" description="size 为 large"></tiny-alert>
</div>

View File

@ -4,6 +4,37 @@ test('尺寸', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('alert#size')
const largeAlert = page.locator('.tiny-alert').nth(1)
// 用文本过滤,精确锁定 size demo 里的 4 个 alert
const smallAlert = page.locator('.tiny-alert').filter({ hasText: 'size 为 small' })
const mediumAlert = page.locator('.tiny-alert').filter({ hasText: 'size 为 medium' })
const normalAlert = page.locator('.tiny-alert').filter({ hasText: 'size 为 normal' })
const largeAlert = page.locator('.tiny-alert').filter({ hasText: 'size 为 large' })
// 1. 验证各尺寸类名
await expect(smallAlert).toHaveClass(/tiny-alert--small/)
await expect(mediumAlert).toHaveClass(/tiny-alert--medium/)
await expect(normalAlert).toHaveClass(/tiny-alert--normal/)
await expect(largeAlert).toHaveClass(/tiny-alert--large/)
// 2. 验证 small / medium 的 padding
await expect(smallAlert).toHaveCSS('padding-top', '2px')
await expect(smallAlert).toHaveCSS('padding-bottom', '2px')
await expect(mediumAlert).toHaveCSS('padding-top', '4px')
await expect(mediumAlert).toHaveCSS('padding-bottom', '4px')
// 3. 验证 small / medium / normal 不显示 titlelarge 显示 title
await expect(smallAlert.locator('.tiny-alert__title')).toHaveCount(0)
await expect(mediumAlert.locator('.tiny-alert__title')).toHaveCount(0)
await expect(normalAlert.locator('.tiny-alert__title')).toHaveCount(0)
await expect(largeAlert.locator('.tiny-alert__title')).toHaveCount(1)
// 4. 验证 small / medium 的 close 按钮样式
const smallClose = smallAlert.locator('.tiny-alert__close')
const mediumClose = mediumAlert.locator('.tiny-alert__close')
await expect(smallClose).toHaveCSS('top', '4px')
await expect(smallClose).toHaveCSS('transform', 'none')
await expect(smallClose).toHaveCSS('margin-top', '1px')
await expect(mediumClose).toHaveCSS('top', '6px')
await expect(mediumClose).toHaveCSS('transform', 'none')
await expect(mediumClose).toHaveCSS('margin-top', '1px')
})

View File

@ -1,5 +1,7 @@
<template>
<div>
<tiny-alert size="small" title="size 为 small" description="size 为 small"></tiny-alert>
<tiny-alert size="medium" title="size 为 medium" description="size 为 medium"></tiny-alert>
<tiny-alert size="normal" description="size 为 normal"></tiny-alert>
<tiny-alert size="large" title="size 为 large" description="size 为 large"></tiny-alert>
</div>
@ -7,6 +9,7 @@
<script>
import { TinyAlert } from '@opentiny/vue'
export default {
components: {
TinyAlert

View File

@ -4,8 +4,28 @@ test('测试 Alert 自定义交互操作', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('alert#slot-default')
// size 为 large 时可通过插槽自定义操作
const alert = page.locator('.tiny-alert--large').first()
const opration = alert.locator('.tiny-alert__opration')
// 第1个slot 自定义内容(有 opration但里面是 span 文本)
const alert1 = page.locator('.tiny-alert').filter({ hasText: 'slot 自定义内容' })
await expect(alert1.locator('.tiny-alert__opration')).toHaveCount(1)
await expect(alert1.locator('.tiny-alert__opration')).toHaveText('自定义内容')
// 第2个slot 自定义交互操作(有 opration里面是链接
const alert2 = page.locator('.tiny-alert').filter({ hasText: 'slot 自定义交互操作' })
const opration = alert2.locator('.tiny-alert__opration')
await expect(opration).toHaveCount(1)
await expect(opration.locator('a')).toHaveCount(2)
await expect(opration.locator('a').nth(0)).toHaveText('确定')
await expect(opration.locator('a').nth(1)).toHaveText('取消')
// 第3个成功有 opration + 描述)
const alert3 = page.locator('.tiny-alert').filter({ hasText: '成功' })
await expect(alert3.locator('.tiny-alert__opration')).toHaveCount(1)
// 第4个错误有 opration 但为空)
const alert4 = page.locator('.tiny-alert').filter({ hasText: '错误' })
await expect(alert4.locator('.tiny-alert__opration')).toHaveCount(1)
// 第5个警告有 opration
const alert5 = page.locator('.tiny-alert').filter({ hasText: '警告' })
await expect(alert5.locator('.tiny-alert__opration')).toHaveCount(1)
})

View File

@ -4,12 +4,15 @@ test('测试 Alert 自定义标题', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('alert#title')
// size 为 large 时可设置自定义标题
const alert = page.locator('.tiny-alert--large').first()
const alert = page.locator('.tiny-alert').filter({ hasText: '通过属性设置自定义 title' })
const title = alert.locator('.tiny-alert__title')
await expect(title).toHaveCount(1)
await expect(title).toHaveText('通过属性设置自定义 title')
const alert1 = page.locator('.tiny-alert--large').nth(2)
const alert1 = page
.locator('.tiny-alert')
.filter({ hasText: /^描述内容$/ })
.first()
await expect(alert1).not.toHaveText('消息')
})

View File

@ -26,22 +26,22 @@ export default {
},
desc: {
'zh-CN': `
通过 <code>size</code> <code>normal</code> <code>large</code> <br>
通过 <code>size</code> <code>small</code> <code>medium</code> <code>normal</code> <code>large</code> <br>
<div class="tip custom-block">
<p class="custom-block-title"> 尺寸模式区别 </p>
<ul>
<li> normal 模式下不会显示标题和交互操作的区域相当于简单模式</li>
<li> large 模式下显示全部元素相当于完整模式</li>
<li> <code>small</code> <code>medium</code> <code>normal</code> </li>
<li> <code>large</code> </li>
</ul>
</div>
`,
'en-US': `
Use <code>size</code> to set different size modes, optional values: <code>normal</code>, <code>large</code>. <br>
Use <code>size</code> to set different size modes, optional values: <code>small</code>, <code>medium</code>, <code>normal</code>, <code>large</code>. <br>
<div class="tip custom-block">
<p class="custom-block-title"> Size pattern difference </p>
<ul>
<li> normal mode, the header and interactive areas are not displayed, which is equivalent to simple mode. </li>
<li> large mode, all elements are displayed, equivalent to the full mode. </li>
<li> <code>small</code> <code>medium</code> <code>normal</code> mode, the header and interactive areas are not displayed, which is equivalent to simple mode. </li>
<li> <code>large</code> mode, all elements are displayed, equivalent to the full mode. </li>
</ul>
</div>
`

View File

@ -41,7 +41,7 @@
<div>场景 5自定义图标 + 自定义样式</div>
<br />
<tiny-base-select
v-model="value4"
v-model="value5"
multiple
:dropdown-icon="tinyIconPopup"
:drop-style="{ width: '200px', 'min-width': '200px' }"
@ -81,6 +81,7 @@ const value1 = ref(['选项 1', '选项 2'])
const value2 = ref(['选项 1', '选项 2'])
const value3 = ref([])
const value4 = ref([])
const value5 = ref([])
const tinyIconPopup = iconPopup()
</script>

View File

@ -41,7 +41,7 @@
<div>场景 5自定义图标 + 自定义样式</div>
<br />
<tiny-base-select
v-model="value4"
v-model="value5"
multiple
:dropdown-icon="iconPopup"
:drop-style="{ width: '200px', 'min-width': '200px' }"
@ -85,6 +85,7 @@ export default {
value2: ['选项 1', '选项 2'],
value3: ['选项 1', '选项 2'],
value4: [],
value5: [],
iconPopup: iconPopup()
}
}

View File

@ -0,0 +1,34 @@
<template>
<tiny-collapse class="demo-collapse-wrap" v-model="activeNames" size="medium">
<tiny-collapse-item title="一致性 Consistency" name="1">
<div>与现实生活一致与现实生活的流程逻辑保持一致遵循用户习惯的语言和概念</div>
<div>在界面中一致所有的元素和结构需保持一致比如设计样式图标和文本元素的位置等</div>
</tiny-collapse-item>
<tiny-collapse-item title="反馈 Feedback" name="2">
<div>控制反馈通过界面样式和交互动效让用户可以清晰的感知自己的操作</div>
<div>页面反馈操作后通过页面元素的变化清晰地展现当前状态</div>
</tiny-collapse-item>
<tiny-collapse-item title="效率 Efficiency" name="3">
<div>简化流程设计简洁直观的操作流程</div>
<div>清晰明确语言表达清晰且表意明确让用户快速理解进而作出决策</div>
<div>帮助用户识别界面简单直白让用户快速识别而非回忆减少用户记忆负担</div>
</tiny-collapse-item>
<tiny-collapse-item title="可控 Controllability" name="4">
<div>用户决策根据场景可给予用户操作建议或安全提示但不能代替用户进行决策</div>
<div>结果可控用户可以自由的进行操作包括撤销回退和终止当前操作等</div>
</tiny-collapse-item>
</tiny-collapse>
</template>
<script setup>
import { ref } from 'vue'
import { TinyCollapse, TinyCollapseItem } from '@opentiny/vue'
const activeNames = ref(['1', '3'])
</script>
<style scoped lang="less">
.demo-collapse-wrap ::v-deep .tiny-collapse-item__content > * {
line-height: 1.6;
}
</style>

View File

@ -0,0 +1,43 @@
<template>
<tiny-collapse class="demo-collapse-wrap" v-model="activeNames" size="medium">
<tiny-collapse-item title="一致性 Consistency" name="1">
<div>与现实生活一致与现实生活的流程逻辑保持一致遵循用户习惯的语言和概念</div>
<div>在界面中一致所有的元素和结构需保持一致比如设计样式图标和文本元素的位置等</div>
</tiny-collapse-item>
<tiny-collapse-item title="反馈 Feedback" name="2">
<div>控制反馈通过界面样式和交互动效让用户可以清晰的感知自己的操作</div>
<div>页面反馈操作后通过页面元素的变化清晰地展现当前状态</div>
</tiny-collapse-item>
<tiny-collapse-item title="效率 Efficiency" name="3">
<div>简化流程设计简洁直观的操作流程</div>
<div>清晰明确语言表达清晰且表意明确让用户快速理解进而作出决策</div>
<div>帮助用户识别界面简单直白让用户快速识别而非回忆减少用户记忆负担</div>
</tiny-collapse-item>
<tiny-collapse-item title="可控 Controllability" name="4">
<div>用户决策根据场景可给予用户操作建议或安全提示但不能代替用户进行决策</div>
<div>结果可控用户可以自由的进行操作包括撤销回退和终止当前操作等</div>
</tiny-collapse-item>
</tiny-collapse>
</template>
<script>
import { TinyCollapse, TinyCollapseItem } from '@opentiny/vue'
export default {
components: {
TinyCollapse,
TinyCollapseItem
},
data() {
return {
activeNames: ['1', '3']
}
}
}
</script>
<style scoped lang="less">
.demo-collapse-wrap ::v-deep .tiny-collapse-item__content > * {
line-height: 1.6;
}
</style>

View File

@ -46,6 +46,19 @@ export default {
},
codeFiles: ['disable.vue']
},
{
demoId: 'size',
name: {
'zh-CN': '面板大小',
'en-US': 'Custom Panel Size'
},
desc: {
'zh-CN': '通过 <code>size</code> 属性可以指定折叠面板的尺寸,可选值为 "" | "medium"。',
'en-US':
'by <code>size</code> prop can be used to specify the size of the collapse panel. The optional values are "" | "medium".'
},
codeFiles: ['size.vue']
},
{
demoId: 'title',
name: {

View File

@ -10,3 +10,25 @@ test('基本用法', async ({ page }) => {
await page.locator('.tiny-color-select-panel__inner__color-select').click()
await page.getByRole('button', { name: '确定' }).click()
})
test('在 hex 输入框内拖选文本、鼠标移出面板后松开,面板不应误关闭', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('color-picker#basic-usage')
await page.locator('.tiny-color-picker__inner').click()
const panel = page.locator('.tiny-color-select-panel')
await expect(panel).toBeVisible()
const input = panel.locator('.tiny-color-select-panel__tools-hex1 input')
const box = await input.boundingBox()
expect(box).not.toBeNull()
// 模拟用户拖选 hex 输入框文本:在输入框内按下,拖出面板后松开
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x - 300, box.y + box.height / 2, { steps: 10 })
await page.mouse.up()
// mousedown 发生在面板内部,拖选不应被误判为「点击外部」而关闭面板
await expect(panel).toBeVisible()
})

View File

@ -3,8 +3,8 @@ import { test, expect } from '@playwright/test'
test('渐变', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('color-picker#color-mode')
await page.locator('.tiny-color-picker > .tiny-color-picker__inner').click()
await page.locator('.tiny-color-picker .tiny-color-picker__inner').click()
await page.getByRole('button', { name: '确定' }).click()
await expect(page.locator('body')).toContainText('linear-gradient(90deg, #66ADFF 21%,#00FEF1 100%)')
await page.locator('.tiny-notify__icon-close > .tiny-svg').click()
await page.locator('.tiny-notify__icon-close .tiny-svg').click()
})

View File

@ -31,6 +31,7 @@ const value = ref('')
<style scoped>
.demo-date-picker-wrap {
width: 360px;
/* 输入框收窄到 240px比面板默认宽度 360px 窄,宽度差让 left/center/right 对齐效果清晰可见 */
width: 240px;
}
</style>

View File

@ -39,6 +39,7 @@ export default {
<style scoped>
.demo-date-picker-wrap {
width: 360px;
/* 输入框收窄到 240px比面板默认宽度 360px 窄,宽度差让 left/center/right 对齐效果清晰可见 */
width: 240px;
}
</style>

View File

@ -11,7 +11,8 @@ test('dialogSelect 设置多选状态', async ({ page }) => {
const trs = await page.locator('.tiny-grid table tbody tr').all()
for (let i = 0; i < trs.length; i++) {
const classes = await trs[i].getAttribute('class')
if (i === 1) {
// 第1行2行都选被中
if (i === 0 || i === 1) {
expect(classes?.includes('row__selected')).toBeTruthy()
} else {
expect(classes?.includes('row__selected')).toBeFalsy()

View File

@ -0,0 +1,23 @@
<template>
<div>
<div>
<tiny-radio v-model="value" :label="true">关闭时销毁</tiny-radio>
<tiny-radio v-model="value" :label="false">关闭时不销毁</tiny-radio>
</div>
<br />
<tiny-button @click="boxVisibility = true" type="primary">点击打开抽屉</tiny-button>
<tiny-drawer title="标题" v-model:visible="boxVisibility" :destroy-on-close="value">
<div>内容区域</div>
</tiny-drawer>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { TinyButton, TinyDrawer, TinyRadio } from '@opentiny/vue'
const value = ref(true)
const boxVisibility = ref(false)
</script>

View File

@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test'
test('关闭时销毁主体元素', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('drawer#destroy-on-close')
const demo = page.locator('#destroy-on-close')
const drawer = demo.locator('.tiny-drawer__main')
// 关闭时不销毁
await demo.locator('label').filter({ hasText: '关闭时不销毁' }).click()
await demo.getByRole('button', { name: '点击打开抽屉' }).click()
await expect(drawer).toBeVisible()
await page.click('.tiny-drawer__mask')
await expect(drawer).toBeHidden()
// 关闭时销毁
await demo.locator('label').filter({ hasText: '关闭时销毁' }).click()
await demo.getByRole('button', { name: '点击打开抽屉' }).click()
await expect(drawer).toBeVisible()
await page.click('.tiny-drawer__mask')
await expect(drawer).toHaveCount(0)
})

View File

@ -0,0 +1,33 @@
<template>
<div>
<div>
<tiny-radio v-model="value" :label="true">关闭时销毁</tiny-radio>
<tiny-radio v-model="value" :label="false">关闭时不销毁</tiny-radio>
</div>
<br />
<tiny-button @click="boxVisibility = true" type="primary">点击打开抽屉</tiny-button>
<tiny-drawer title="标题" v-model:visible="boxVisibility" :destroy-on-close="value">
<div>内容区域</div>
</tiny-drawer>
</div>
</template>
<script>
import { TinyButton, TinyDrawer, TinyRadio } from '@opentiny/vue'
export default {
components: {
TinyButton,
TinyDrawer,
TinyRadio
},
data() {
return {
value: true,
boxVisibility: false
}
}
}
</script>

View File

@ -0,0 +1,30 @@
<template>
<div>
<tiny-button @click="openDrawer" type="primary"> 抽屉组件AppendToBody </tiny-button>
<tiny-drawer
v-if="visible"
title="标题"
:visible="visible"
:append-to-body="true"
@update:visible="visible = $event"
@confirm="confirm"
>
<div>内容区域</div>
</tiny-drawer>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { TinyDrawer, TinyButton } from '@opentiny/vue'
const visible = ref(false)
function openDrawer() {
visible.value = true
}
function confirm() {
visible.value = false
}
</script>

View File

@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test'
test.describe('挂载节点', () => {
test('挂载节点', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('drawer#drawer-to-body')
const demo = page.locator('#drawer-to-body')
await expect(page.locator('body > .tiny-drawer')).toHaveCount(0)
const openBtn = demo.getByRole('button', { name: /抽屉组件AppendToBody/ })
await openBtn.click()
const drawer = page.locator('body > .tiny-drawer')
// drawer 被挂载到 document.body 下
const isAppendToBody = await drawer.evaluate((el) => el.parentElement === document.body)
expect(isAppendToBody).toBe(true)
// demo 容器内部不应该包含 drawer 的根 DOM
await expect(demo.locator('.tiny-drawer')).toHaveCount(0)
})
})

View File

@ -0,0 +1,39 @@
<template>
<div>
<tiny-button @click="openDrawer" type="primary"> 抽屉组件AppendToBody </tiny-button>
<tiny-drawer
v-if="visible"
title="标题"
:visible="visible"
:append-to-body="true"
@update:visible="visible = $event"
@confirm="confirm"
>
<div>内容区域</div>
</tiny-drawer>
</div>
</template>
<script>
import { TinyDrawer, TinyButton } from '@opentiny/vue'
export default {
components: {
TinyDrawer,
TinyButton
},
data() {
return {
visible: false
}
},
methods: {
openDrawer() {
this.visible = true
},
confirm() {
this.visible = false
}
}
}
</script>

View File

@ -16,6 +16,20 @@ export default {
},
codeFiles: ['basic-usage.vue']
},
{
demoId: 'drawer-to-body',
name: {
'zh-CN': '挂载节点',
'en-US': 'Mount node'
},
desc: {
'zh-CN':
'<code>append-to-body</code> 属性可以将抽屉挂载到 body 元素上。默认值为 <code>false</code>,即挂载在当前组件内。',
'en-US':
'The <code>append-to-body</code> attribute can mount the drawer to the body element. The default value is <code>false</code>, meaning it is mounted within the current component.'
},
codeFiles: ['drawer-to-body.vue']
},
{
demoId: 'close-on-press-escape',
name: {
@ -132,6 +146,20 @@ export default {
},
codeFiles: ['mask-closable.vue']
},
{
demoId: 'destroy-on-close',
name: {
'zh-CN': '关闭时销毁主体元素',
'en-US': 'Destroy on Close'
},
desc: {
'zh-CN':
'<p>可通过<code>destroy-on-close</code>属性设置<code>true</code>在关闭抽屉时销毁<code>drawer</code>抽屉内的所有元素,默认值为<code>false</code>。</p>',
'en-US':
'<p>By setting the<code>:destroy-on-close</code>attribute to<code>true</code>, all elements in the<code>drawer</code>drawer are destroyed when the pop-up window is closed, with the default value being<code>false</code>.</p>'
},
codeFiles: ['destroy-on-close.vue']
},
{
demoId: 'show-close',
name: {

View File

@ -1,78 +1,25 @@
<template>
<div class="tiny-demo">
<tiny-flowchart
ref="chart"
:data="chartDataRaw"
:config="chartConfigRaw"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
<div>
<tiny-flowchart :data="data" :config="config" />
</div>
</template>
<script setup>
import { TinyModal, TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
import { TinyFlowchart } from '@opentiny/vue'
import { reactive } from 'vue'
const { createNode, createLink, createConfig } = TinyFlowchart
const chartData = {
const config = createConfig()
config.width = 960
config.height = 260
const data = reactive({
nodes: [
createNode('1', 1, '基础信息', '2018.08.02', [], 1, 0),
createNode('2', 1, '调职补偿', '2018.08.02', [], 0, 2),
createNode('3', 1, '汇总调职补偿', '', [], 1, 4),
createNode('4', 3, '启动精算', '', [], 4, 5),
createNode('5', 3, '复核精算', '', [], 4, 6),
createNode('6', 3, '审核精算', '', [], 4, 7),
createNode('7', 1, '调职补偿', '2018.08.02', [], 2, 1),
createNode('8', 1, '复核', '2018.08.02', [], 2, 2),
createNode('9', 2, '审批', '2018.08.02', [], 2, 3),
createNode('10', 1, '复核', '2018.08.02', [], 4, 2),
createNode('11', 2, '审批', '2018.08.02', [], 4, 3),
createNode('12', 3, '运算调职兑现率', '', [], 4, 4),
createNode('13', 1, '复核', '2018.08.02', [], 6, 2),
createNode('14', 4, '审批审批审批审批审批 0123456789asdfghjkl', '2018.08.02', [], 6, 3)
createNode('1', 1, '提交申请', '2024-01-01', null, 1, 1),
createNode('2', 2, '主管审批', '2024-01-02', null, 1, 3),
createNode('3', 3, '流程结束', '', null, 1, 5)
],
links: [
createLink('1', '2', '0 r0.5 t1 c r1.5', 1),
createLink('2', '3', '0 r1.5 c b1 r0.5', 3),
createLink('3', '4', '0 r0.5 c b3 r0.5', 3),
createLink('4', '5', '', 3),
createLink('5', '6', '', 3),
createLink('1', '7', 'r0.5 b1 c r0.5', 1),
createLink('7', '8', '', 1),
createLink('8', '9', '', 1),
createLink('9', '3', '0 r0.5 c t1', 3),
createLink('10', '11', '', 1),
createLink('11', '12', '', 3),
createLink('12', '4', '0 r0.5', 3),
createLink('13', '14', '', 1),
createLink('14', '4', '0 r1.5 c t2', 3, 'dash')
]
}
const chartConfig = createConfig()
chartConfig.headUrl = `${import.meta.env.VITE_APP_BUILD_BASE_URL}static/images/mountain.png`
chartConfig.checkItemStatus = (item) => ~['已转审', '已同意'].indexOf(item.status)
chartConfig.adjustPos = (afterNode) => afterNode.raw.name === '2' && (afterNode.y += 1)
// 使 markRaw Vue
const chartDataRaw = hooks.markRaw(chartData)
const chartConfigRaw = hooks.markRaw(chartConfig)
function onClickNode(_afterNode, _e) {
TinyModal.message('click-node')
}
function onClickLink(_afterLink, _e) {
TinyModal.message('click-link')
}
function onClickBlank(_param, _e) {
TinyModal.message('click-blank')
}
links: [createLink('1', '2', '0 r2', 2, 'solid'), createLink('2', '3', '0 r2', 3, 'solid')]
})
</script>

View File

@ -1,17 +0,0 @@
import { test, expect } from '@playwright/test'
test('基本用法', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('flowchart#basic-usage')
const preview = page.locator('#basic-usage')
const flowchart = preview.locator('.tiny-flow-chart')
const nodes = flowchart.locator('.tiny-flow-chart__node-icon-wrapper')
await expect(flowchart).toBeVisible()
await expect(flowchart).toHaveCSS('width', '1024px')
await expect(flowchart.locator('.tiny-flow-chart__canvas')).toBeVisible()
await expect(nodes).toHaveCount(14)
await expect(nodes.first()).toContainText('基础信息')
await expect(nodes.first()).toContainText('2018.08.02')
await expect(nodes.nth(1)).toContainText('调职补偿')
})

View File

@ -1,70 +1,13 @@
<template>
<div class="tiny-demo">
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
<div>
<tiny-flowchart :data="data" :config="config" />
</div>
</template>
<script>
import { TinyModal, TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
import { TinyFlowchart } from '@opentiny/vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
const handlers = [
createItem('WX100001', '张三', '转审人', '已转审', '很好', '2018-08-20 12:00', ''),
createItem('WX100002', '李四', '主管', '已转审', '非常好', '2018-08-20 12:00', ''),
createItem('WX100003', '王五', '主管', '处理中', '', '', '')
]
const chartData = {
nodes: [
createNode('1', 1, '基础信息', '2018.08.02', [], 1, 0),
createNode('2', 1, '调职补偿', '2018.08.02', handlers, 0, 2),
createNode('3', 1, '汇总调职补偿', '', [], 1, 4),
createNode('4', 3, '启动精算', '', [], 4, 5),
createNode('5', 3, '复核精算', '', [], 4, 6),
createNode('6', 3, '审核精算', '', [], 4, 7),
createNode('7', 1, '调职补偿', '2018.08.02', [], 2, 1),
createNode('8', 1, '复核', '2018.08.02', [], 2, 2),
createNode('9', 2, '审批', '2018.08.02', [], 2, 3),
createNode('10', 1, '复核', '2018.08.02', [], 4, 2),
createNode('11', 2, '审批', '2018.08.02', [], 4, 3),
createNode('12', 3, '运算调职兑现率', '', [], 4, 4),
createNode('13', 1, '复核', '2018.08.02', [], 6, 2),
createNode('14', 4, '审批审批审批审批审批 0123456789asdfghjkl', '2018.08.02', [], 6, 3)
],
links: [
createLink('1', '2', '0 r0.5 t1 c r1.5', 1),
createLink('2', '3', '0 r1.5 c b1 r0.5', 3),
createLink('3', '4', '0 r0.5 c b3 r0.5', 3),
createLink('4', '5', '', 3),
createLink('5', '6', '', 3),
createLink('1', '7', 'r0.5 b1 c r0.5', 1),
createLink('7', '8', '', 1),
createLink('8', '9', '', 1),
createLink('9', '3', '0 r0.5 c t1', 3),
createLink('10', '11', '', 1),
createLink('11', '12', '', 3),
createLink('12', '4', '0 r0.5', 3),
createLink('13', '14', '', 1),
createLink('14', '4', '0 r1.5 c t2', 3, 'dash')
]
}
const chartConfig = createConfig()
chartConfig.headUrl = `${import.meta.env.VITE_APP_BUILD_BASE_URL}static/images/mountain.png`
chartConfig.checkItemStatus = (item) => ~['已转审', '已同意'].indexOf(item.status)
chartConfig.adjustPos = (afterNode) => afterNode.raw.name === '2' && (afterNode.y += 1)
const { createNode, createLink, createConfig } = TinyFlowchart
export default {
components: {
@ -72,20 +15,20 @@ export default {
},
data() {
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
config: createConfig(),
data: {
nodes: [
createNode('1', 1, '提交申请', '2024-01-01', null, 1, 1),
createNode('2', 2, '主管审批', '2024-01-02', null, 1, 3),
createNode('3', 3, '流程结束', '', null, 1, 5)
],
links: [createLink('1', '2', '0 r2', 2, 'solid'), createLink('2', '3', '0 r2', 3, 'solid')]
}
}
},
methods: {
onClickNode(_afterNode, _e) {
TinyModal.message('click-node')
},
onClickLink(_afterLink, _e) {
TinyModal.message('click-link')
},
onClickBlank(_param, _e) {
TinyModal.message('click-blank')
}
created() {
this.config.width = 960
this.config.height = 260
}
}
</script>

View File

@ -0,0 +1,190 @@
<template>
<div>
<p>示例1卡片式分支流程</p>
<tiny-flowchart :data="chartData1" :config="chartConfig1" @click-node="onClickNode1" @click-link="onClickLink1">
<template #icon="{ afterNode }">
<div class="icon-anchor">
<div v-if="afterNode.raw.name === '0'" class="node-circle node-start">开始</div>
<div v-else-if="afterNode.raw.name === '6'" class="node-circle node-end"></div>
<div
v-else
class="node-card"
:class="{
'card-blue': afterNode.raw.info.status === 2,
'card-red': afterNode.raw.info.status === 4,
'card-gray': afterNode.raw.info.status === 3
}"
>
<div class="card-title" :title="afterNode.raw.info.label">{{ afterNode.raw.info.label }}</div>
<div class="card-sub" :title="afterNode.raw.info.date">{{ afterNode.raw.info.date }}</div>
</div>
</div>
</template>
<template #label>
<span style="display: none"></span>
</template>
</tiny-flowchart>
<p>示例2标准网格路径流程</p>
<tiny-flowchart :data="chartData2" :config="chartConfig2" />
</div>
</template>
<script setup>
import { TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createNode, createLink, createConfig } = TinyFlowchart
// ==================== 1 ====================
const config1 = createConfig()
config1.width = 860
config1.height = 480
config1.rows = 6
config1.cols = 8
config1.labelSpacing = 0
config1.labelHeight = 1
config1.styleLink = (ctx, afterLink) => {
const colors = { 1: '#52c41a', 2: '#1890ff', 3: '#bfbfbf', 4: '#ff4d4f' }
ctx.strokeStyle = colors[afterLink.raw.info.status] || '#999'
ctx.lineWidth = 2
if (afterLink.raw.info.style !== 'solid') {
ctx.setLineDash([4, 4])
}
}
const data1 = {
nodes: [
createNode('0', 1, '开始', '', null, 0, 3),
createNode('1', 1, '申请人', '张三', null, 1, 3),
createNode('2', 1, '制单会计', '协同:张三、张四、张五', null, 2, 3),
createNode('3', 2, '应付会计', '张四 0035837', null, 3, 1),
createNode('4', 4, '应付会计', '张四 0035837', null, 3, 5),
createNode('5', 3, '复核会计', '自定义内容-5', null, 4, 3),
createNode('6', 1, '结束', '', null, 5, 3)
],
links: [
createLink('0', '1', '', 1, 'solid'),
createLink('1', '2', '', 1, 'solid'),
createLink('2', '3', '0 b1 l2', 1, 'solid'),
createLink('2', '4', '0 b1 r2', 1, 'solid'),
createLink('3', '5', '0 b1 r2', 3, 'solid'),
createLink('4', '5', '0 b1 l2', 3, 'solid'),
createLink('5', '6', '', 3, 'dash')
]
}
const chartData1 = hooks.markRaw(data1)
const chartConfig1 = hooks.markRaw(config1)
const onClickNode1 = (afterNode) => {
console.log('示例1点击节点:', afterNode.raw.info.label)
}
const onClickLink1 = (afterLink) => {
console.log('示例1点击连线:', afterLink.raw.from, '→', afterLink.raw.to)
}
// ==================== 2 ====================
const config2 = createConfig()
config2.width = 960
config2.height = 470
config2.rows = 10
config2.cols = 10
const data2 = {
nodes: [
createNode('start', 1, '提交报销', '2024-06-01', null, 2, 2),
createNode('check', 2, '金额审核', '2024-06-02', null, 4, 2),
createNode('pass', 1, '直接通过', '2024-06-03', null, 6, 1),
createNode('extra', 2, '额外审批', '2024-06-03', null, 6, 3),
createNode('end', 1, '流程结束', '2024-06-04', null, 8, 2)
],
links: [
createLink('start', 'check', '0 b2', 1, 'solid'),
createLink('check', 'pass', '0 b1 c l1 c b1', 1, 'solid'),
createLink('check', 'extra', '0 b1 c r1 c b1', 2, 'dashed'),
createLink('pass', 'end', '0 b1 c r1 c b1', 1, 'solid'),
createLink('extra', 'end', '0 b1 c l1 c b1', 2, 'solid')
]
}
const chartData2 = hooks.markRaw(data2)
const chartConfig2 = hooks.markRaw(config2)
</script>
<style scoped>
/* 示例1样式 */
.icon-anchor {
position: relative;
width: 1px;
height: 1px;
}
.node-circle {
position: absolute;
left: -12px;
top: -24px;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.node-start {
border: 2px solid #52c41a;
background: #fff;
color: #333;
}
.node-end {
background: #52c41a;
color: #fff;
font-size: 22px;
font-weight: bold;
}
.node-card {
position: absolute;
left: -70px;
top: -28px;
width: 160px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #d9d9d9;
background: #fff;
text-align: center;
box-sizing: border-box;
cursor: pointer;
}
.card-blue {
border-color: #1890ff;
background: #e6f7ff;
}
.card-red {
border-color: #ff4d4f;
background: #fff1f0;
}
.card-gray {
border-color: #bfbfbf;
background: #f5f5f5;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.card-sub {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,196 @@
<template>
<div>
<p>示例1卡片式分支流程</p>
<tiny-flowchart :data="chartData1" :config="chartConfig1" @click-node="onClickNode1" @click-link="onClickLink1">
<template #icon="{ afterNode }">
<div class="icon-anchor">
<div v-if="afterNode.raw.name === '0'" class="node-circle node-start">开始</div>
<div v-else-if="afterNode.raw.name === '6'" class="node-circle node-end"></div>
<div
v-else
class="node-card"
:class="{
'card-blue': afterNode.raw.info.status === 2,
'card-red': afterNode.raw.info.status === 4,
'card-gray': afterNode.raw.info.status === 3
}"
>
<div class="card-title" :title="afterNode.raw.info.label">{{ afterNode.raw.info.label }}</div>
<div class="card-sub" :title="afterNode.raw.info.date">{{ afterNode.raw.info.date }}</div>
</div>
</div>
</template>
<template #label>
<span style="display: none"></span>
</template>
</tiny-flowchart>
<p>示例2标准网格路径流程</p>
<tiny-flowchart :data="chartData2" :config="chartConfig2" />
</div>
</template>
<script>
import { TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createNode, createLink, createConfig } = TinyFlowchart
export default {
components: { TinyFlowchart },
data() {
// ==================== 1 ====================
const config1 = createConfig()
config1.width = 860
config1.height = 480
config1.rows = 6
config1.cols = 8
config1.labelSpacing = 0
config1.labelHeight = 1
config1.styleLink = (ctx, afterLink) => {
const colors = { 1: '#52c41a', 2: '#1890ff', 3: '#bfbfbf', 4: '#ff4d4f' }
ctx.strokeStyle = colors[afterLink.raw.info.status] || '#999'
ctx.lineWidth = 2
if (afterLink.raw.info.style !== 'solid') {
ctx.setLineDash([4, 4])
}
}
const data1 = {
nodes: [
createNode('0', 1, '开始', '', null, 0, 3),
createNode('1', 1, '申请人', '张三', null, 1, 3),
createNode('2', 1, '制单会计', '协同:张三、张四、张五', null, 2, 3),
createNode('3', 2, '应付会计', '张四 0035837', null, 3, 1),
createNode('4', 4, '应付会计', '张四 0035837', null, 3, 5),
createNode('5', 3, '复核会计', '自定义内容-5', null, 4, 3),
createNode('6', 1, '结束', '', null, 5, 3)
],
links: [
createLink('0', '1', '', 1, 'solid'),
createLink('1', '2', '', 1, 'solid'),
createLink('2', '3', '0 b1 l2', 1, 'solid'),
createLink('2', '4', '0 b1 r2', 1, 'solid'),
createLink('3', '5', '0 b1 r2', 3, 'solid'),
createLink('4', '5', '0 b1 l2', 3, 'solid'),
createLink('5', '6', '', 3, 'dash')
]
}
// ==================== 2 ====================
const config2 = createConfig()
config2.width = 960
config2.height = 470
config2.rows = 10
config2.cols = 10
const data2 = {
nodes: [
createNode('start', 1, '提交报销', '2024-06-01', null, 2, 2),
createNode('check', 2, '金额审核', '2024-06-02', null, 4, 2),
createNode('pass', 1, '直接通过', '2024-06-03', null, 6, 1),
createNode('extra', 2, '额外审批', '2024-06-03', null, 6, 3),
createNode('end', 1, '流程结束', '2024-06-04', null, 8, 2)
],
links: [
createLink('start', 'check', '0 b2', 1, 'solid'),
createLink('check', 'pass', '0 b1 c l1 c b1', 1, 'solid'),
createLink('check', 'extra', '0 b1 c r1 c b1', 2, 'dashed'),
createLink('pass', 'end', '0 b1 c r1 c b1', 1, 'solid'),
createLink('extra', 'end', '0 b1 c l1 c b1', 2, 'solid')
]
}
return {
chartData1: hooks.markRaw(data1),
chartConfig1: hooks.markRaw(config1),
chartData2: hooks.markRaw(data2),
chartConfig2: hooks.markRaw(config2)
}
},
methods: {
onClickNode1(afterNode) {
console.log('示例1点击节点:', afterNode.raw.info.label)
},
onClickLink1(afterLink) {
console.log('示例1点击连线:', afterLink.raw.from, '→', afterLink.raw.to)
}
}
}
</script>
<style scoped>
/* 示例1样式 */
.icon-anchor {
position: relative;
width: 1px;
height: 1px;
}
.node-circle {
position: absolute;
left: -12px;
top: -24px;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.node-start {
border: 2px solid #52c41a;
background: #fff;
color: #333;
}
.node-end {
background: #52c41a;
color: #fff;
font-size: 22px;
font-weight: bold;
}
.node-card {
position: absolute;
left: -70px;
top: -28px;
width: 160px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #d9d9d9;
background: #fff;
text-align: center;
box-sizing: border-box;
cursor: pointer;
}
.card-blue {
border-color: #1890ff;
background: #e6f7ff;
}
.card-red {
border-color: #ff4d4f;
background: #fff1f0;
}
.card-gray {
border-color: #bfbfbf;
background: #f5f5f5;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
}
.card-sub {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<div>
<p>示例1使用 drawLink 完全自定义绘制</p>
<tiny-flowchart :data="data1" :config="config1" />
<p>示例2包含悬停时连线样式的效果</p>
<div class="example2-wrapper">
<tiny-flowchart :key="refreshKey2" :data="data2" :config="config2" />
</div>
</div>
</template>
<script setup>
import { TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
import { ref, onMounted, onUnmounted } from 'vue'
const { createNode, createLink, createConfig } = TinyFlowchart
// ==================== 1drawLink ====================
const chartConfig1 = createConfig()
chartConfig1.width = 960
chartConfig1.height = 260
chartConfig1.rows = 4
chartConfig1.cols = 8
// 使 drawLink styleLink
chartConfig1.drawLink = ({ ctx, afterLink }) => {
const link = afterLink.raw
const colorMap = { 1: '#12cff8', 2: '#1ff9ff', 3: '#1a1a1a', 4: '#ff4d4f' }
ctx.strokeStyle = colorMap[link.info.status] || '#999'
ctx.lineWidth = link.info.status === 2 ? 3 : 2
if (link.info.style !== 'solid') {
ctx.setLineDash([4, 4])
} else {
ctx.setLineDash([])
}
afterLink.p.forEach((p) => {
const parts = p.split(',')
const cmd = parts[0]
const coords = parts.slice(1).map(Number)
if (cmd === 'm') {
ctx.moveTo(coords[0], coords[1])
} else if (cmd === 'l') {
ctx.lineTo(coords[0], coords[1])
} else if (cmd === 'a') {
ctx.arcTo(coords[0], coords[1], coords[2], coords[3], coords[4])
}
})
}
const chartData1 = {
nodes: [
createNode('a', 1, '已通过', '2024-06-01', [], 1, 1),
createNode('b', 2, '进行中', '2024-06-02', [], 1, 3),
createNode('c', 3, '待处理', '', [], 1, 5),
createNode('d', 4, '已失败', '2024-06-03', [], 1, 7)
],
links: [
createLink('a', 'b', '0 r2', 1, 'solid'),
createLink('b', 'c', '0 r2', 2, 'solid'),
createLink('c', 'd', '0 r2', 3, 'dash')
]
}
const data1 = hooks.markRaw(chartData1)
const config1 = hooks.markRaw(chartConfig1)
// ==================== 2styleLink + ====================
const refreshKey2 = ref(0)
const mousePos2 = ref({ x: -1, y: -1 })
const chartConfig2 = createConfig()
chartConfig2.width = 960
chartConfig2.height = 260
chartConfig2.rows = 4
chartConfig2.cols = 8
// 2 styleLink2 canvas
chartConfig2.styleLink = (ctx, afterLink) => {
const link = afterLink.raw
const key = `${link.from}-${link.to}`
// 2 wrapper canvas1
const canvas = document.querySelector('.example2-wrapper .tiny-flow-chart__canvas')
if (!canvas) {
const colorMap = { 1: '#52c41a', 2: '#1890ff', 3: '#d9d9d9', 4: '#ff4d4f' }
ctx.strokeStyle = colorMap[link.info.status] || '#999'
ctx.lineWidth = link.info.status === 2 ? 3 : 2
if (link.info.style !== 'solid') {
ctx.setLineDash([4, 4])
} else {
ctx.setLineDash([])
}
return
}
const rect = canvas.getBoundingClientRect()
const mx = mousePos2.value.x - rect.left
const my = mousePos2.value.y - rect.top
const nodeY = 97.5
const nodeX = { a: 180, b: 420, c: 660, d: 900 }
let isHover = false
if (Math.abs(my - nodeY) <= 25) {
if (key === 'a-b' && mx >= nodeX.a && mx <= nodeX.b) isHover = true
else if (key === 'b-c' && mx >= nodeX.b && mx <= nodeX.c) isHover = true
else if (key === 'c-d' && mx >= nodeX.c && mx <= nodeX.d) isHover = true
}
if (isHover) {
ctx.setLineDash([])
ctx.strokeStyle = '#faacff'
ctx.lineWidth = 8
ctx.shadowColor = 'rgba(250, 173, 20, 0.6)'
ctx.shadowBlur = 20
} else {
const colorMap = { 1: '#52c41a', 2: '#1890ff', 3: '#d9d9d9', 4: '#ff4d4f' }
ctx.strokeStyle = colorMap[link.info.status] || '#999'
ctx.lineWidth = link.info.status === 2 ? 3 : 2
if (link.info.style !== 'solid') {
ctx.setLineDash([4, 4])
} else {
ctx.setLineDash([])
}
}
}
const chartData2 = {
nodes: [
createNode('a', 1, '已通过', '2024-06-01', [], 1, 1),
createNode('b', 2, '进行中', '2024-06-02', [], 1, 3),
createNode('c', 3, '待处理', '', [], 1, 5),
createNode('d', 4, '已失败', '2024-06-03', [], 1, 7)
],
links: [
createLink('a', 'b', '0 r2', 1, 'solid'),
createLink('b', 'c', '0 r2', 2, 'solid'),
createLink('c', 'd', '0 r2', 3, 'dash')
]
}
const data2 = hooks.markRaw(chartData2)
const config2 = hooks.markRaw(chartConfig2)
// 2
const onMouseMove2 = (e) => {
mousePos2.value = { x: e.clientX, y: e.clientY }
}
let timer2 = null
const startHoverLoop2 = () => {
timer2 = setInterval(() => {
refreshKey2.value++
}, 80)
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove2)
startHoverLoop2()
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove2)
clearInterval(timer2)
})
</script>

View File

@ -0,0 +1,171 @@
<template>
<div>
<p>示例1使用 drawLink 完全自定义绘制</p>
<tiny-flowchart :data="data1" :config="config1" />
<p>示例2包含悬停时连线样式的效果</p>
<div class="example2-wrapper">
<tiny-flowchart :key="refreshKey2" :data="data2" :config="config2" />
</div>
</div>
</template>
<script>
import { TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createNode, createLink, createConfig } = TinyFlowchart
export default {
components: { TinyFlowchart },
data() {
// 1
const config1 = createConfig()
config1.width = 960
config1.height = 260
config1.rows = 4
config1.cols = 8
config1.drawLink = ({ ctx, afterLink }) => {
const link = afterLink.raw
const colorMap = { 1: '#12cff8', 2: '#1ff9ff', 3: '#1a1a1a', 4: '#ff4d4f' }
ctx.strokeStyle = colorMap[link.info.status] || '#999'
ctx.lineWidth = link.info.status === 2 ? 3 : 2
if (link.info.style !== 'solid') {
ctx.setLineDash([4, 4])
} else {
ctx.setLineDash([])
}
afterLink.p.forEach((p) => {
const parts = p.split(',')
const cmd = parts[0]
const coords = parts.slice(1).map(Number)
if (cmd === 'm') {
ctx.moveTo(coords[0], coords[1])
} else if (cmd === 'l') {
ctx.lineTo(coords[0], coords[1])
} else if (cmd === 'a') {
ctx.arcTo(coords[0], coords[1], coords[2], coords[3], coords[4])
}
})
}
const data1 = {
nodes: [
createNode('a', 1, '已通过', '2024-06-01', [], 1, 1),
createNode('b', 2, '进行中', '2024-06-02', [], 1, 3),
createNode('c', 3, '待处理', '', [], 1, 5),
createNode('d', 4, '已失败', '2024-06-03', [], 1, 7)
],
links: [
createLink('a', 'b', '0 r2', 1, 'solid'),
createLink('b', 'c', '0 r2', 2, 'solid'),
createLink('c', 'd', '0 r2', 3, 'dash')
]
}
// 2
const config2 = createConfig()
config2.width = 960
config2.height = 260
config2.rows = 4
config2.cols = 8
const self = this
config2.styleLink = function (ctx, afterLink) {
const link = afterLink.raw
const key = `${link.from}-${link.to}`
const canvas = document.querySelector('.example2-wrapper .tiny-flow-chart__canvas')
if (!canvas) {
const colorMap = { 1: '#52c41a', 2: '#1890ff', 3: '#d9d9d9', 4: '#ff4d4f' }
ctx.strokeStyle = colorMap[link.info.status] || '#999'
ctx.lineWidth = link.info.status === 2 ? 3 : 2
if (link.info.style !== 'solid') {
ctx.setLineDash([4, 4])
} else {
ctx.setLineDash([])
}
return
}
const rect = canvas.getBoundingClientRect()
const mx = self.mousePos2.x - rect.left
const my = self.mousePos2.y - rect.top
const nodeY = 97.5
const nodeX = { a: 180, b: 420, c: 660, d: 900 }
let isHover = false
if (Math.abs(my - nodeY) <= 25) {
if (key === 'a-b' && mx >= nodeX.a && mx <= nodeX.b) isHover = true
else if (key === 'b-c' && mx >= nodeX.b && mx <= nodeX.c) isHover = true
else if (key === 'c-d' && mx >= nodeX.c && mx <= nodeX.d) isHover = true
}
if (isHover) {
ctx.setLineDash([])
ctx.strokeStyle = '#faacff'
ctx.lineWidth = 8
ctx.shadowColor = 'rgba(250, 173, 20, 0.6)'
ctx.shadowBlur = 20
} else {
const colorMap = { 1: '#52c41a', 2: '#1890ff', 3: '#d9d9d9', 4: '#ff4d4f' }
ctx.strokeStyle = colorMap[link.info.status] || '#999'
ctx.lineWidth = link.info.status === 2 ? 3 : 2
if (link.info.style !== 'solid') {
ctx.setLineDash([4, 4])
} else {
ctx.setLineDash([])
}
}
}
const data2 = {
nodes: [
createNode('a', 1, '已通过', '2024-06-01', [], 1, 1),
createNode('b', 2, '进行中', '2024-06-02', [], 1, 3),
createNode('c', 3, '待处理', '', [], 1, 5),
createNode('d', 4, '已失败', '2024-06-03', [], 1, 7)
],
links: [
createLink('a', 'b', '0 r2', 1, 'solid'),
createLink('b', 'c', '0 r2', 2, 'solid'),
createLink('c', 'd', '0 r2', 3, 'dash')
]
}
return {
refreshKey2: 0,
mousePos2: { x: -1, y: -1 },
data1: hooks.markRaw(data1),
config1: hooks.markRaw(config1),
data2: hooks.markRaw(data2),
config2: hooks.markRaw(config2),
timer2: null
}
},
mounted() {
document.addEventListener('mousemove', this.onMouseMove2)
this.startHoverLoop2()
},
beforeUnmount() {
document.removeEventListener('mousemove', this.onMouseMove2)
clearInterval(this.timer2)
},
methods: {
onMouseMove2(e) {
this.mousePos2 = { x: e.clientX, y: e.clientY }
},
startHoverLoop2() {
this.timer2 = setInterval(() => {
this.refreshKey2++
}, 80)
}
}
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<div>
<tiny-flowchart :data="data" :config="config" />
</div>
</template>
<script setup>
import { TinyFlowchart } from '@opentiny/vue'
import { reactive } from 'vue'
const { createNode, createLink, createConfig } = TinyFlowchart
const config = createConfig()
config.colors = { 1: '#52c41a', 2: '#1890ff', 3: '#d9d9d9', 4: '#ff4d4f' }
config.background = '#f6ffed'
config.font = '14px "Microsoft YaHei"'
config.radius = 8
config.thin = false
config.iconWrapperSize = 32
config.iconSize = 26
config.iconSvgSize = 18
config.labelWidth = 100
config.labelHeight = 70
config.labelSpacing = 12
config.labelDateColor = '#666'
config.listWidth = 70
config.listIconSize = 24
config.headSize = 24
config.width = 860
config.height = 260
const data = reactive({
nodes: [
createNode('req', 1, '需求分析', '2024-06-01', null, 1, 1),
createNode('dev', 2, '开发中', '2024-06-05', null, 1, 3),
createNode('test', 3, '测试中', '', null, 1, 5),
createNode('release', 3, '待发布', '', null, 1, 7)
],
links: [
createLink('req', 'dev', '0 r2', 1, 'solid'),
createLink('dev', 'test', '0 r2', 2, 'solid'),
createLink('test', 'release', '0 r2', 3, 'dashed')
]
})
</script>

View File

@ -0,0 +1,54 @@
<template>
<div>
<tiny-flowchart :data="data" :config="config" />
</div>
</template>
<script>
import { TinyFlowchart } from '@opentiny/vue'
const { createNode, createLink, createConfig } = TinyFlowchart
export default {
components: { TinyFlowchart },
data() {
const config = createConfig()
config.colors = { 1: '#52c41a', 2: '#1890ff', 3: '#d9d9d9', 4: '#ff4d4f' }
config.background = '#f6ffed'
config.font = '14px "Microsoft YaHei"'
config.radius = 8
config.thin = false
config.iconWrapperSize = 32
config.iconSize = 26
config.iconSvgSize = 18
config.labelWidth = 100
config.labelHeight = 70
config.labelSpacing = 12
config.labelDateColor = '#666'
config.listWidth = 70
config.listIconSize = 24
config.headSize = 24
return {
config,
data: {
nodes: [
createNode('req', 1, '需求分析', '2024-06-01', null, 1, 1),
createNode('dev', 2, '开发中', '2024-06-05', null, 1, 3),
createNode('test', 3, '测试中', '', null, 1, 5),
createNode('release', 3, '待发布', '', null, 1, 7)
],
links: [
createLink('req', 'dev', '0 r2', 1, 'solid'),
createLink('dev', 'test', '0 r2', 2, 'solid'),
createLink('test', 'release', '0 r2', 3, 'dashed')
]
}
}
},
created() {
this.config.width = 860
this.config.height = 260
}
}
</script>

View File

@ -0,0 +1,183 @@
<template>
<div>
<div class="toolbar">
<button @click="approve">审批通过</button>
<button @click="reject">审批驳回</button>
<button @click="reset">重置流程</button>
</div>
<tiny-flowchart :data="data" :config="config" :key="chartKey">
<template #content="{ node, config: cfg }">
<div
v-if="node.info.items && node.info.items.length >= cfg.listThreshold"
class="dynamic-person-list"
:style="{ width: cfg.listWidth + 'px' }"
>
<div v-for="item in node.info.items" :key="item.key" class="dynamic-person-item">
<div class="dynamic-person-name">{{ item.name }}</div>
<div class="dynamic-person-comment" :style="{ color: cfg.colors[item.status] }">{{ item.comment }}</div>
</div>
</div>
</template>
</tiny-flowchart>
</div>
</template>
<script setup>
import { TinyFlowchart } from '@opentiny/vue'
import { reactive, ref } from 'vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
const config = createConfig()
config.width = 960
config.height = 260
config.rows = 4
config.cols = 8
config.delay = 15
config.listThreshold = 0 // createItemitems,1items10
const currentStep = ref(0)
const chartKey = ref(0)
const initialNodes = [
createNode(
'apply',
2,
'提交申请',
'2024-06-01',
[createItem('1', '张三', '申请人', 1, '已提交', '2024-06-01')],
1,
1
),
createNode('dept', 3, '部门审批', '', [createItem('2', '李四', '经理', 3, '待审批', '')], 1, 3),
createNode('finance', 3, '财务审批', '', [createItem('3', '王五', '财务', 3, '待审批', '')], 1, 5),
createNode('done', 3, '流程完成', '', null, 1, 7)
]
const initialLinks = [
createLink('apply', 'dept', '0 r2', 3, 'solid'),
createLink('dept', 'finance', '0 r2', 3, 'solid'),
createLink('finance', 'done', '0 r2', 3, 'solid')
]
const data = reactive({
nodes: JSON.parse(JSON.stringify(initialNodes)),
links: JSON.parse(JSON.stringify(initialLinks))
})
const approve = () => {
if (currentStep.value >= data.nodes.length - 1) return
const curr = data.nodes[currentStep.value]
curr.info.status = 1
if (curr.info.items) {
curr.info.items.forEach((it) => {
it.status = 1
it.comment = '已通过'
it.date = new Date().toISOString().split('T')[0]
})
}
if (currentStep.value < data.links.length) {
// links Vue 线
const links = [...data.links]
links[currentStep.value] = { ...links[currentStep.value], info: { ...links[currentStep.value].info, status: 1 } }
data.links = links
chartKey.value++
}
currentStep.value++
if (currentStep.value < data.nodes.length) {
const next = data.nodes[currentStep.value]
next.info.status = 2
if (next.info.items) {
next.info.items.forEach((it) => {
it.status = 2
it.comment = '审批中'
})
}
}
}
const reject = () => {
if (currentStep.value >= data.nodes.length - 1) return
const curr = data.nodes[currentStep.value]
curr.info.status = 4
if (curr.info.items) {
curr.info.items.forEach((it) => {
it.status = 4
it.comment = '已驳回'
})
}
if (currentStep.value < data.links.length) {
// links Vue 线
const links = [...data.links]
links[currentStep.value] = { ...links[currentStep.value], info: { ...links[currentStep.value].info, status: 4 } }
data.links = links
chartKey.value++
}
}
const reset = () => {
currentStep.value = 0
// 使 Object.assign Vue 3 reactive
Object.assign(data, {
nodes: JSON.parse(JSON.stringify(initialNodes)),
links: JSON.parse(JSON.stringify(initialLinks))
})
chartKey.value++
}
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 8px;
}
.toolbar button {
padding: 6px 16px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
cursor: pointer;
}
.toolbar button:hover {
border-color: #1890ff;
color: #1890ff;
}
.dynamic-person-list {
position: absolute;
bottom: 10px;
left: 60%;
transform: translateX(-50%);
min-width: 80px;
background: #fff;
border-radius: 8px;
padding: 8px 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #f0f0f0;
z-index: 10;
}
.dynamic-person-item {
padding: 6px 16px;
text-align: center;
transition: background 0.2s;
}
.dynamic-person-item:hover {
background: #fafafa;
}
.dynamic-person-item:not(:last-child) {
border-bottom: 1px solid #f5f5f5;
}
.dynamic-person-name {
font-size: 13px;
font-weight: 600;
color: #262626;
}
.dynamic-person-comment {
display: inline-block;
margin-top: 4px;
padding: 1px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,194 @@
<template>
<div>
<div class="toolbar">
<button @click="approve">审批通过</button>
<button @click="reject">审批驳回</button>
<button @click="reset">重置流程</button>
</div>
<tiny-flowchart :data="data" :config="config" :key="chartKey">
<template #content="{ node, config: cfg }">
<div
v-if="node.info.items && node.info.items.length >= cfg.listThreshold"
class="dynamic-person-list"
:style="{ width: cfg.listWidth + 'px' }"
>
<div v-for="item in node.info.items" :key="item.key" class="dynamic-person-item">
<div class="dynamic-person-name">{{ item.name }}</div>
<div class="dynamic-person-comment" :style="{ color: cfg.colors[item.status] }">{{ item.comment }}</div>
</div>
</div>
</template>
</tiny-flowchart>
</div>
</template>
<script>
import { TinyFlowchart } from '@opentiny/vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
export default {
components: { TinyFlowchart },
data() {
const config = createConfig()
config.width = 960
config.height = 260
config.rows = 4
config.cols = 8
config.delay = 15
config.listThreshold = 0 // createItemitems,1items10
this.initialNodes = [
createNode(
'apply',
2,
'提交申请',
'2024-06-01',
[createItem('1', '张三', '申请人', 1, '已提交', '2024-06-01')],
1,
1
),
createNode('dept', 3, '部门审批', '', [createItem('2', '李四', '经理', 3, '待审批', '')], 1, 3),
createNode('finance', 3, '财务审批', '', [createItem('3', '王五', '财务', 3, '待审批', '')], 1, 5),
createNode('done', 3, '流程完成', '', null, 1, 7)
]
this.initialLinks = [
createLink('apply', 'dept', '0 r2', 3, 'solid'),
createLink('dept', 'finance', '0 r2', 3, 'solid'),
createLink('finance', 'done', '0 r2', 3, 'solid')
]
return {
config,
currentStep: 0,
chartKey: 0, //
data: {
nodes: JSON.parse(JSON.stringify(this.initialNodes)),
links: JSON.parse(JSON.stringify(this.initialLinks))
}
}
},
methods: {
approve() {
if (this.currentStep >= this.data.nodes.length - 1) return
const curr = this.data.nodes[this.currentStep]
curr.info.status = 1
if (curr.info.items) {
curr.info.items.forEach((it) => {
it.status = 1
it.comment = '已通过'
it.date = new Date().toISOString().split('T')[0]
})
}
if (this.currentStep < this.data.links.length) {
// links Vue 线
const links = [...this.data.links]
links[this.currentStep] = {
...links[this.currentStep],
info: { ...links[this.currentStep].info, status: 1 }
}
this.data.links = links
this.chartKey++ //
}
this.currentStep++
if (this.currentStep < this.data.nodes.length) {
const next = this.data.nodes[this.currentStep]
next.info.status = 2
if (next.info.items) {
next.info.items.forEach((it) => {
it.status = 2
it.comment = '审批中'
})
}
}
},
reject() {
if (this.currentStep >= this.data.nodes.length - 1) return
const curr = this.data.nodes[this.currentStep]
curr.info.status = 4
if (curr.info.items) {
curr.info.items.forEach((it) => {
it.status = 4
it.comment = '已驳回'
})
}
if (this.currentStep < this.data.links.length) {
// links Vue 线
const links = [...this.data.links]
links[this.currentStep] = {
...links[this.currentStep],
info: { ...links[this.currentStep].info, status: 4 }
}
this.data.links = links
this.chartKey++ //
}
},
reset() {
this.currentStep = 0
// data tiny-flowchart
this.data = {
nodes: JSON.parse(JSON.stringify(this.initialNodes)),
links: JSON.parse(JSON.stringify(this.initialLinks))
}
this.chartKey++ //
}
}
}
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 8px;
}
.toolbar button {
padding: 6px 16px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
cursor: pointer;
}
.toolbar button:hover {
border-color: #1890ff;
color: #1890ff;
}
.dynamic-person-list {
position: absolute;
bottom: 10px;
left: 60%;
transform: translateX(-50%);
min-width: 80px;
background: #fff;
border-radius: 8px;
padding: 8px 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: 1px solid #f0f0f0;
z-index: 10;
}
.dynamic-person-item {
padding: 6px 16px;
text-align: center;
transition: background 0.2s;
}
.dynamic-person-item:hover {
background: #fafafa;
}
.dynamic-person-item:not(:last-child) {
border-bottom: 1px solid #f5f5f5;
}
.dynamic-person-name {
font-size: 13px;
font-weight: 600;
color: #262626;
}
.dynamic-person-comment {
display: inline-block;
margin-top: 4px;
padding: 1px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,81 @@
<template>
<div>
<div class="log-panel">
<div v-for="(log, i) in logs" :key="i" class="log-item">{{ log }}</div>
<div v-if="logs.length === 0" class="log-empty">点击节点连线或空白区域查看事件</div>
</div>
<tiny-flowchart
:data="data"
:config="config"
@click-node="handleNodeClick"
@click-link="handleLinkClick"
@click-blank="handleBlankClick"
/>
</div>
</template>
<script setup>
import { TinyFlowchart, Modal } from '@opentiny/vue'
import { reactive, ref } from 'vue'
const { createNode, createLink, createConfig } = TinyFlowchart
const config = createConfig()
config.width = 960
config.height = 260
const logs = ref([])
const handleNodeClick = (param, e) => {
Modal.message({
status: 'info',
message: `点击节点: ${param.raw.name} (${param.raw.info.label}) - 状态: ${param.raw.info.status}`
})
}
const handleLinkClick = (param, e) => {
Modal.message({
status: 'info',
message: `点击连线: ${param.raw.from}${param.raw.to} - 样式: ${param.raw.info.style}`
})
}
const handleBlankClick = (param, e) => {
Modal.message({
status: 'info',
message: `点击空白: 坐标 (${Math.round(e.x)}, ${Math.round(e.y)})`
})
}
const data = reactive({
nodes: [
createNode('a', 1, '步骤 A', '2024-06-01', null, 1, 1),
createNode('b', 2, '步骤 B', '2024-06-02', null, 1, 3),
createNode('c', 3, '步骤 C', '', null, 1, 5)
],
links: [createLink('a', 'b', '0 r2', 1, 'solid'), createLink('b', 'c', '0 r2', 2, 'solid')]
})
</script>
<style scoped>
.log-panel {
margin-bottom: 16px;
padding: 12px;
background: #f6f8fa;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
}
.log-item {
font-size: 12px;
color: #333;
padding: 2px 0;
border-bottom: 1px solid #eee;
}
.log-empty {
font-size: 12px;
color: #999;
text-align: center;
padding: 8px 0;
}
</style>

View File

@ -0,0 +1,86 @@
<template>
<div>
<div class="log-panel">
<div v-for="(log, i) in logs" :key="i" class="log-item">{{ log }}</div>
<div v-if="logs.length === 0" class="log-empty">点击节点连线或空白区域查看事件</div>
</div>
<tiny-flowchart
:data="data"
:config="config"
@click-node="handleNodeClick"
@click-link="handleLinkClick"
@click-blank="handleBlankClick"
/>
</div>
</template>
<script>
import { TinyFlowchart, Modal } from '@opentiny/vue'
const { createNode, createLink, createConfig } = TinyFlowchart
export default {
components: { TinyFlowchart },
data() {
return {
config: createConfig(),
logs: [],
data: {
nodes: [
createNode('a', 1, '步骤 A', '2024-06-01', null, 1, 1),
createNode('b', 2, '步骤 B', '2024-06-02', null, 1, 3),
createNode('c', 3, '步骤 C', '', null, 1, 5)
],
links: [createLink('a', 'b', '0 r2', 1, 'solid'), createLink('b', 'c', '0 r2', 2, 'solid')]
}
}
},
methods: {
handleNodeClick(param, e) {
Modal.message({
status: 'info',
message: `点击节点: ${param.raw.name} (${param.raw.info.label}) - 状态: ${param.raw.info.status}`
})
},
handleLinkClick(param, e) {
Modal.message({
status: 'info',
message: `点击连线: ${param.raw.from}${param.raw.to} - 样式: ${param.raw.info.style}`
})
},
handleBlankClick(param, e) {
Modal.message({
status: 'info',
message: `点击空白: 坐标 (${Math.round(e.x)}, ${Math.round(e.y)})`
})
}
},
created() {
this.config.width = 960
this.config.height = 260
}
}
</script>
<style scoped>
.log-panel {
margin-bottom: 16px;
padding: 12px;
background: #f6f8fa;
border-radius: 6px;
max-height: 120px;
overflow-y: auto;
}
.log-item {
font-size: 12px;
color: #333;
padding: 2px 0;
border-bottom: 1px solid #eee;
}
.log-empty {
font-size: 12px;
color: #999;
text-align: center;
padding: 8px 0;
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<div class="tiny-demo">
<tiny-flowchart
ref="chart"
:data="chartDataRaw"
:config="chartConfigRaw"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
</div>
</template>
<script setup>
import { TinyModal, TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createNode, createLink, createConfig } = TinyFlowchart
const chartData = {
nodes: [
createNode('1', 1, '基础信息', '2018.08.02', [], 1, 0),
createNode('2', 1, '调职补偿', '2018.08.02', [], 0, 2),
createNode('3', 1, '汇总调职补偿', '', [], 1, 4),
createNode('4', 3, '启动精算', '', [], 4, 5),
createNode('5', 3, '复核精算', '', [], 4, 6),
createNode('6', 3, '审核精算', '', [], 4, 7),
createNode('7', 1, '调职补偿', '2018.08.02', [], 2, 1),
createNode('8', 1, '复核', '2018.08.02', [], 2, 2),
createNode('9', 2, '审批', '2018.08.02', [], 2, 3),
createNode('10', 1, '复核', '2018.08.02', [], 4, 2),
createNode('11', 2, '审批', '2018.08.02', [], 4, 3),
createNode('12', 3, '运算调职兑现率', '', [], 4, 4),
createNode('13', 1, '复核', '2018.08.02', [], 6, 2),
createNode('14', 4, '审批审批审批审批审批 0123456789asdfghjkl', '2018.08.02', [], 6, 3)
],
links: [
createLink('1', '2', '0 r0.5 t1 c r1.5', 1),
createLink('2', '3', '0 r1.5 c b1 r0.5', 3),
createLink('3', '4', '0 r0.5 c b3 r0.5', 3),
createLink('4', '5', '', 3),
createLink('5', '6', '', 3),
createLink('1', '7', 'r0.5 b1 c r0.5', 1),
createLink('7', '8', '', 1),
createLink('8', '9', '', 1),
createLink('9', '3', '0 r0.5 c t1', 3),
createLink('10', '11', '', 1),
createLink('11', '12', '', 3),
createLink('12', '4', '0 r0.5', 3),
createLink('13', '14', '', 1),
createLink('14', '4', '0 r1.5 c t2', 3, 'dash')
]
}
const chartConfig = createConfig()
chartConfig.headUrl = `${import.meta.env.VITE_APP_BUILD_BASE_URL}static/images/mountain.png`
chartConfig.checkItemStatus = (item) => ~['已转审', '已同意'].indexOf(item.status)
chartConfig.adjustPos = (afterNode) => afterNode.raw.name === '2' && (afterNode.y += 1)
// 使 markRaw Vue
const chartDataRaw = hooks.markRaw(chartData)
const chartConfigRaw = hooks.markRaw(chartConfig)
function onClickNode(_afterNode, _e) {
TinyModal.message('click-node')
}
function onClickLink(_afterLink, _e) {
TinyModal.message('click-link')
}
function onClickBlank(_param, _e) {
TinyModal.message('click-blank')
}
</script>

View File

@ -0,0 +1,91 @@
<template>
<div class="tiny-demo">
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
</tiny-flowchart>
</div>
</template>
<script>
import { TinyModal, TinyFlowchart } from '@opentiny/vue'
import { hooks } from '@opentiny/vue-common'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
const handlers = [
createItem('WX100001', '张三', '转审人', '已转审', '很好', '2018-08-20 12:00', ''),
createItem('WX100002', '李四', '主管', '已转审', '非常好', '2018-08-20 12:00', ''),
createItem('WX100003', '王五', '主管', '处理中', '', '', '')
]
const chartData = {
nodes: [
createNode('1', 1, '基础信息', '2018.08.02', [], 1, 0),
createNode('2', 1, '调职补偿', '2018.08.02', handlers, 0, 2),
createNode('3', 1, '汇总调职补偿', '', [], 1, 4),
createNode('4', 3, '启动精算', '', [], 4, 5),
createNode('5', 3, '复核精算', '', [], 4, 6),
createNode('6', 3, '审核精算', '', [], 4, 7),
createNode('7', 1, '调职补偿', '2018.08.02', [], 2, 1),
createNode('8', 1, '复核', '2018.08.02', [], 2, 2),
createNode('9', 2, '审批', '2018.08.02', [], 2, 3),
createNode('10', 1, '复核', '2018.08.02', [], 4, 2),
createNode('11', 2, '审批', '2018.08.02', [], 4, 3),
createNode('12', 3, '运算调职兑现率', '', [], 4, 4),
createNode('13', 1, '复核', '2018.08.02', [], 6, 2),
createNode('14', 4, '审批审批审批审批审批 0123456789asdfghjkl', '2018.08.02', [], 6, 3)
],
links: [
createLink('1', '2', '0 r0.5 t1 c r1.5', 1),
createLink('2', '3', '0 r1.5 c b1 r0.5', 3),
createLink('3', '4', '0 r0.5 c b3 r0.5', 3),
createLink('4', '5', '', 3),
createLink('5', '6', '', 3),
createLink('1', '7', 'r0.5 b1 c r0.5', 1),
createLink('7', '8', '', 1),
createLink('8', '9', '', 1),
createLink('9', '3', '0 r0.5 c t1', 3),
createLink('10', '11', '', 1),
createLink('11', '12', '', 3),
createLink('12', '4', '0 r0.5', 3),
createLink('13', '14', '', 1),
createLink('14', '4', '0 r1.5 c t2', 3, 'dash')
]
}
const chartConfig = createConfig()
chartConfig.headUrl = `${import.meta.env.VITE_APP_BUILD_BASE_URL}static/images/mountain.png`
chartConfig.checkItemStatus = (item) => ~['已转审', '已同意'].indexOf(item.status)
chartConfig.adjustPos = (afterNode) => afterNode.raw.name === '2' && (afterNode.y += 1)
export default {
components: {
TinyFlowchart
},
data() {
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
}
},
methods: {
onClickNode(_afterNode, _e) {
TinyModal.message('click-node')
},
onClickLink(_afterLink, _e) {
TinyModal.message('click-link')
},
onClickBlank(_param, _e) {
TinyModal.message('click-blank')
}
}
}
</script>

View File

@ -0,0 +1,197 @@
<template>
<div>
<tiny-flowchart :data="data" :config="config">
<template #content="{ node, config: cfg }">
<div
v-if="node.info.items && node.info.items.length > cfg.listThreshold"
class="person-list"
:style="{ width: cfg.listWidth + 'px' }"
>
<div v-for="item in node.info.items" :key="item.key" class="person-item">
<div class="person-avatar">
<img
v-if="cfg.headUrl"
:src="cfg.headUrl"
:style="{ width: cfg.headSize + 'px', height: cfg.headSize + 'px' }"
/>
<div
v-else
class="avatar-placeholder"
:style="{ width: cfg.headSize + 'px', height: cfg.headSize + 'px' }"
>
{{ item.name.charAt(0) }}
</div>
<!-- 状态指示点 -->
<span class="status-dot" :style="{ background: cfg.colors[item.status] }"></span>
</div>
<div class="person-info">
<div class="person-name">{{ item.name }}</div>
<div class="person-role">{{ item.role }}</div>
<div class="person-comment" :style="{ color: cfg.colors[item.status] }">{{ item.comment }}</div>
</div>
</div>
</div>
</template>
</tiny-flowchart>
</div>
</template>
<script setup>
import { TinyFlowchart } from '@opentiny/vue'
import { reactive } from 'vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
const config = createConfig()
config.width = 860
config.height = 360
config.rows = 4
config.cols = 8
config.listWidth = 120 //
config.listThreshold = 0 // createItemitems,1items10
config.listBorderColor = '#e8e8e8'
config.listIconColor = '#bfbfbf'
config.listIconSize = 22
config.headSize = 28 //
const data = reactive({
nodes: [
createNode(
'apply',
1,
'提交申请',
'2024-06-01',
[
createItem('1', '张三', '申请人', 1, '已提交', '2024-06-01'),
createItem('2', '李四', '协助人', 1, '已确认', '2024-06-01')
],
1,
1
),
createNode(
'dept',
2,
'部门审批',
'2024-06-02',
[createItem('3', '王五', '部门经理', 2, '审批中', '2024-06-02')],
1,
3
),
createNode(
'finance',
3,
'财务审批',
'',
[createItem('4', '赵六', '财务主管', 3, '待审批', ''), createItem('5', '孙七', '出纳', 3, '待处理', '')],
1,
5
),
createNode('done', 3, '流程结束', '', null, 1, 7)
],
links: [
createLink('apply', 'dept', '0 r2', 1, 'solid'),
createLink('dept', 'finance', '0 r2', 2, 'solid'),
createLink('finance', 'done', '0 r2', 3, 'solid')
]
})
</script>
<style scoped>
/* 人员列表容器 */
.person-list {
position: absolute;
bottom: 10px;
left: 48%;
transform: translateX(-45%);
min-width: 100px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
padding: 8px;
background: #ffffff;
z-index: 10;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.05),
0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease;
}
/* 单个人员项 */
.person-item {
display: flex;
align-items: flex-start;
padding: 8px 6px;
border-radius: 6px;
transition: all 0.25s ease;
position: relative;
}
.person-item:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.person-item:hover {
background: rgba(24, 144, 255, 0.04);
}
/* 头像区域 */
.person-avatar {
margin-right: 10px;
flex-shrink: 0;
position: relative;
}
.avatar-placeholder {
border-radius: 50%;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
color: #1890ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(24, 144, 255, 0.15);
}
/* 状态指示圆点 */
.status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid #ffffff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* 信息区域 */
.person-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.person-name {
font-size: 13px;
font-weight: 600;
color: #1f1f1f;
line-height: 1.4;
letter-spacing: 0.2px;
}
.person-role {
font-size: 11px;
color: #8c8c8c;
line-height: 1.3;
font-weight: 400;
}
.person-comment {
font-size: 11px;
line-height: 1.3;
margin-top: 2px;
font-weight: 500;
}
</style>

View File

@ -0,0 +1,204 @@
<template>
<div>
<tiny-flowchart :data="data" :config="config">
<template #content="{ node, config: cfg }">
<div
v-if="node.info.items && node.info.items.length >= cfg.listThreshold"
class="person-list"
:style="{ width: cfg.listWidth + 'px' }"
>
<div v-for="item in node.info.items" :key="item.key" class="person-item">
<div class="person-avatar">
<img
v-if="cfg.headUrl"
:src="cfg.headUrl"
:style="{ width: cfg.headSize + 'px', height: cfg.headSize + 'px' }"
/>
<div
v-else
class="avatar-placeholder"
:style="{ width: cfg.headSize + 'px', height: cfg.headSize + 'px' }"
>
{{ item.name.charAt(0) }}
</div>
<!-- 状态指示点 -->
<span class="status-dot" :style="{ background: cfg.colors[item.status] }"></span>
</div>
<div class="person-info">
<div class="person-name">{{ item.name }}</div>
<div class="person-role">{{ item.role }}</div>
<div class="person-comment" :style="{ color: cfg.colors[item.status] }">{{ item.comment }}</div>
</div>
</div>
</div>
</template>
</tiny-flowchart>
</div>
</template>
<script>
import { TinyFlowchart } from '@opentiny/vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
export default {
components: { TinyFlowchart },
data() {
const config = createConfig()
config.width = 860
config.height = 360
config.rows = 4
config.cols = 8
config.listWidth = 120 //
config.listThreshold = 0 // createItemitems,1items10
config.listBorderColor = '#e8e8e8'
config.listIconColor = '#bfbfbf'
config.listIconSize = 22
config.headSize = 28 //
return {
config,
data: {
nodes: [
createNode(
'apply',
1,
'提交申请',
'2024-06-01',
[
createItem('1', '张三', '申请人', 1, '已提交', '2024-06-01'),
createItem('2', '李四', '协助人', 1, '已确认', '2024-06-01')
],
1,
1
),
createNode(
'dept',
2,
'部门审批',
'2024-06-02',
[createItem('3', '王五', '部门经理', 2, '审批中', '2024-06-02')],
1,
3
),
createNode(
'finance',
3,
'财务审批',
'',
[createItem('4', '赵六', '财务主管', 3, '待审批', ''), createItem('5', '孙七', '出纳', 3, '待处理', '')],
1,
5
),
createNode('done', 3, '流程结束', '', null, 1, 7)
],
links: [
createLink('apply', 'dept', '0 r2', 1, 'solid'),
createLink('dept', 'finance', '0 r2', 2, 'solid'),
createLink('finance', 'done', '0 r2', 3, 'solid')
]
}
}
}
}
</script>
<style scoped>
/* 人员列表容器 */
.person-list {
position: absolute;
bottom: 10px;
left: 48%;
transform: translateX(-45%);
min-width: 100px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
padding: 8px;
background: #ffffff;
z-index: 10;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.05),
0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease;
}
/* 单个人员项 */
.person-item {
display: flex;
align-items: flex-start;
padding: 8px 6px;
border-radius: 6px;
transition: all 0.25s ease;
position: relative;
}
.person-item:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.person-item:hover {
background: rgba(24, 144, 255, 0.04);
}
/* 头像区域 */
.person-avatar {
margin-right: 10px;
flex-shrink: 0;
position: relative;
}
.avatar-placeholder {
border-radius: 50%;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f9ff 100%);
color: #1890ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(24, 144, 255, 0.15);
}
/* 状态指示圆点 */
.status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid #ffffff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* 信息区域 */
.person-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.person-name {
font-size: 13px;
font-weight: 600;
color: #1f1f1f;
line-height: 1.4;
letter-spacing: 0.2px;
}
.person-role {
font-size: 11px;
color: #8c8c8c;
line-height: 1.3;
font-weight: 400;
}
.person-comment {
font-size: 11px;
line-height: 1.3;
margin-top: 2px;
font-weight: 500;
}
</style>

View File

@ -1,182 +1,134 @@
<template>
<div class="tiny-demo">
<tiny-flowchart
ref="chart"
:data="chartDataRaw"
:config="chartConfigRaw"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
<!-- content 插槽下拉形式展示收起时显示紧凑视图点击展开显示表单列表 -->
<template #content="params">
<tiny-popover
placement="bottom-start"
trigger="manual"
width="220"
popper-class="flowchart-content-popover"
:visible-arrow="false"
:model-value="params.dropdowns[params.node.name]"
@update:model-value="params.dropdowns[params.node.name] = $event"
<div>
<tiny-flowchart :data="data" :config="config">
<template #icon="{ node, config: cfg }">
<div
class="custom-icon"
:style="{
width: cfg.iconSize + 'px',
height: cfg.iconSize + 'px',
borderRadius: '50%',
background: cfg.colors[node.info.status] || '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px'
}"
>
<template #default>
<div class="flowchart-content-slot">
<div v-for="(item, i) in params.node.info.items" :key="item.key || i" class="content-item">
<span class="item-name">{{ item.name }}</span>
<span class="item-role">{{ item.role }}</span>
<span class="item-status">{{ item.status }}</span>
</div>
</div>
</template>
<template #reference>
<div
class="flowchart-content-trigger"
:style="{ borderColor: params.config.listBorderColor }"
@click.stop="params.dropdowns[params.node.name] = !params.dropdowns[params.node.name]"
>
<span class="trigger-text">处理人({{ params.node.info.items.length }})</span>
<component :is="params.dropdowns[params.node.name] ? IconUp : IconDown" class="trigger-icon" />
</div>
</template>
</tiny-popover>
{{ node.info.status === 1 ? '✓' : node.info.status === 2 ? '◉' : '○' }}
</div>
</template>
<template #label="{ node }">
<div class="custom-label">
<div class="label-title">{{ node.info.label }}</div>
<div v-if="node.info.date" class="label-date">{{ node.info.date }}</div>
</div>
</template>
<template #content="{ node, config: cfg }">
<div class="custom-content">
<div
v-for="item in getNodeItems(node.name)"
:key="item.key"
class="person-tag"
:class="{ done: item.status === 1, doing: item.status === 2 }"
>
{{ item.name }} - {{ item.comment }}
</div>
</div>
</template>
</tiny-flowchart>
</div>
</template>
<script setup>
import { TinyModal, TinyPopover, TinyFlowchart } from '@opentiny/vue'
import { iconChevronDown, iconChevronUp } from '@opentiny/vue-icon'
import { hooks } from '@opentiny/vue-common'
import { TinyFlowchart } from '@opentiny/vue'
import { reactive } from 'vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
const IconDown = iconChevronDown()
const IconUp = iconChevronUp()
const config = createConfig()
config.width = 960
config.height = 260 //
config.rows = 4 //
config.cols = 8 //
config.listWidth = 80
config.listThreshold = 0 // createItemitems,1items10
config.listBorderColor = '#e8e8e8'
config.listIconColor = '#bfbfbf'
config.listIconSize = 22
config.headSize = 22
const handlers = [
createItem('WX100001', '张三', '转审人', '已转审', '很好', '2018-08-20 12:00', ''),
createItem('WX100002', '李四', '主管', '已转审', '非常好', '2018-08-20 12:00', ''),
createItem('WX100003', '王五', '主管', '处理中', '', '', '')
]
function getNodeItems(nodeName) {
const node = data.nodes.find((n) => n.name === nodeName)
return node?.info?.items || []
}
const chartData = {
const data = reactive({
nodes: [
createNode('1', 1, '基础信息', '2018.08.02', [], 1, 0),
createNode('2', 1, '调职补偿', '2018.08.02', handlers, 0, 2),
createNode('3', 1, '汇总调职补偿', '', [], 1, 4),
createNode('4', 3, '启动精算', '', [], 4, 5),
createNode('5', 3, '复核精算', '', [], 4, 6),
createNode('6', 3, '审核精算', '', [], 4, 7),
createNode('7', 1, '调职补偿', '2018.08.02', [], 2, 1),
createNode('8', 1, '复核', '2018.08.02', [], 2, 2),
createNode('9', 2, '审批', '2018.08.02', [], 2, 3),
createNode('10', 1, '复核', '2018.08.02', [], 4, 2),
createNode('11', 2, '审批', '2018.08.02', [], 4, 3),
createNode('12', 3, '运算调职兑现率', '', [], 4, 4),
createNode('13', 1, '复核', '2018.08.02', [], 6, 2),
createNode('14', 4, '审批审批审批审批审批 0123456789asdfghjkl', '2018.08.02', [], 6, 3)
createNode(
'step1',
1,
'提交申请',
'2024-06-01',
[createItem('1', '张三', '申请人', 1, '已提交', '2024-06-01')],
1,
1
),
createNode(
'step2',
2,
'主管审批',
'2024-06-02',
[createItem('2', '李四', '审批人', 2, '审批中', '2024-06-02')],
1,
3
),
createNode('step3', 3, '流程结束', '', null, 1, 5)
],
links: [
createLink('1', '2', '0 r0.5 t1 c r1.5', 1),
createLink('2', '3', '0 r1.5 c b1 r0.5', 3),
createLink('3', '4', '0 r0.5 c b3 r0.5', 3),
createLink('4', '5', '', 3),
createLink('5', '6', '', 3),
createLink('1', '7', 'r0.5 b1 c r0.5', 1),
createLink('7', '8', '', 1),
createLink('8', '9', '', 1),
createLink('9', '3', '0 r0.5 c t1', 3),
createLink('10', '11', '', 1),
createLink('11', '12', '', 3),
createLink('12', '4', '0 r0.5', 3),
createLink('13', '14', '', 1),
createLink('14', '4', '0 r1.5 c t2', 3, 'dash')
]
}
const chartConfig = createConfig()
chartConfig.headUrl = `${import.meta.env.VITE_APP_BUILD_BASE_URL}static/images/mountain.png`
chartConfig.checkItemStatus = (item) => ~['已转审', '已同意'].indexOf(item.status)
chartConfig.adjustPos = (afterNode) => afterNode.raw.name === '2' && (afterNode.y += 1)
// content listWidth 62px
chartConfig.listWidth = 150
const chartDataRaw = hooks.markRaw(chartData)
const chartConfigRaw = hooks.markRaw(chartConfig)
function onClickNode(_afterNode, _e) {
TinyModal.message('click-node')
}
function onClickLink(_afterLink, _e) {
TinyModal.message('click-link')
}
function onClickBlank(_param, _e) {
TinyModal.message('click-blank')
}
links: [createLink('step1', 'step2', '0 r2', 1, 'solid'), createLink('step2', 'step3', '0 r2', 2, 'solid')]
})
</script>
<style scoped>
/* 覆盖 content 插槽容器的固定高度(默认 24px),否则下拉触发区会被挤压 */
:deep(.tiny-flow-chart__node-item) {
min-height: 24px !important;
height: auto !important;
.custom-label {
text-align: center;
}
/* 下拉触发区:收起时显示的紧凑视图 */
.flowchart-content-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
min-height: 22px;
padding: 0 4px;
border: 1px solid #d9d9d9;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
.label-title {
font-size: 13px;
font-weight: 600;
color: #333;
}
.flowchart-content-trigger .trigger-text {
.label-date {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.custom-content {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.person-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
margin: 2px;
background: #f5f5f5;
color: #666;
white-space: nowrap;
}
.flowchart-content-trigger .trigger-icon {
flex-shrink: 0;
.person-tag.done {
background: #f6ffed;
color: #52c41a;
}
/* content 插槽:下拉展开后的表单列表 */
.flowchart-content-slot {
padding: 4px 8px;
font-size: 12px;
}
.flowchart-content-slot .content-item {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px dashed #e8e8e8;
}
.flowchart-content-slot .content-item:last-child {
border-bottom: none;
}
.flowchart-content-slot .item-name {
min-width: 40px;
}
.flowchart-content-slot .item-role {
min-width: 50px;
color: #666;
}
.flowchart-content-slot .item-status {
.person-tag.doing {
background: #e6f7ff;
color: #1890ff;
}
/* 下拉弹层样式 */
:deep(.flowchart-content-popover.tiny-popper) {
margin-top: 2px;
padding: 0;
}
</style>

View File

@ -1,24 +0,0 @@
import { test, expect } from '@playwright/test'
test('插槽定制', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('flowchart#slots')
const preview = page.locator('#slots')
const flowchart = preview.locator('.tiny-flow-chart')
const nodes = flowchart.locator('.tiny-flow-chart__node-icon-wrapper')
await expect(flowchart).toBeVisible()
await expect(flowchart.locator('.tiny-flow-chart__canvas')).toBeVisible()
await expect(nodes).toHaveCount(14)
// content 插槽:下拉形式,点击「处理人(3)」展开后显示表单列表(弹层可能 teleport 到 body
const trigger = flowchart.locator('.flowchart-content-trigger')
await expect(trigger).toBeVisible()
await expect(trigger).toContainText('处理人(3)')
await trigger.click()
const contentSlot = page.locator('.flowchart-content-slot')
await expect(contentSlot).toBeVisible()
await expect(contentSlot).toContainText('张三')
await expect(contentSlot).toContainText('李四')
await expect(contentSlot).toContainText('王五')
})

View File

@ -1,193 +1,142 @@
<template>
<div class="tiny-demo">
<tiny-flowchart
ref="chart"
:data="chartData"
:config="chartConfig"
@click-node="onClickNode"
@click-link="onClickLink"
@click-blank="onClickBlank"
>
<!-- content 插槽下拉形式展示收起时显示紧凑视图点击展开显示表单列表 -->
<template #content="params">
<tiny-popover
placement="bottom-start"
trigger="manual"
width="220"
popper-class="flowchart-content-popover"
:visible-arrow="false"
:model-value="params.dropdowns[params.node.name]"
@update:model-value="params.dropdowns[params.node.name] = $event"
<div>
<tiny-flowchart :data="data" :config="config">
<template #icon="{ node, config: cfg }">
<div
class="custom-icon"
:style="{
width: cfg.iconSize + 'px',
height: cfg.iconSize + 'px',
borderRadius: '50%',
background: cfg.colors[node.info.status] || '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px'
}"
>
<template #default>
<div class="flowchart-content-slot">
<div v-for="(item, i) in params.node.info.items" :key="item.key || i" class="content-item">
<span class="item-name">{{ item.name }}</span>
<span class="item-role">{{ item.role }}</span>
<span class="item-status">{{ item.status }}</span>
</div>
</div>
</template>
<template #reference>
<div
class="flowchart-content-trigger"
:style="{ borderColor: params.config.listBorderColor }"
@click.stop="params.dropdowns[params.node.name] = !params.dropdowns[params.node.name]"
>
<span class="trigger-text">处理人({{ params.node.info.items.length }})</span>
<component :is="params.dropdowns[params.node.name] ? IconUp : IconDown" class="trigger-icon" />
</div>
</template>
</tiny-popover>
{{ node.info.status === 1 ? '✓' : node.info.status === 2 ? '◉' : '○' }}
</div>
</template>
<template #label="{ node }">
<div class="custom-label">
<div class="label-title">{{ node.info.label }}</div>
<div v-if="node.info.date" class="label-date">{{ node.info.date }}</div>
</div>
</template>
<template #content="{ node, config: cfg }">
<div class="custom-content">
<div
v-for="item in getNodeItems(node.name)"
:key="item.key"
class="person-tag"
:class="{ done: item.status === 1, doing: item.status === 2 }"
>
{{ item.name }} - {{ item.comment }}
</div>
</div>
</template>
</tiny-flowchart>
</div>
</template>
<script>
import { TinyModal, TinyPopover, TinyFlowchart } from '@opentiny/vue'
import { iconChevronDown, iconChevronUp } from '@opentiny/vue-icon'
import { hooks } from '@opentiny/vue-common'
import { TinyFlowchart } from '@opentiny/vue'
const { createNode, createLink, createItem, createConfig } = TinyFlowchart
const IconDown = iconChevronDown()
const IconUp = iconChevronUp()
const handlers = [
createItem('WX100001', '张三', '转审人', '已转审', '很好', '2018-08-20 12:00', ''),
createItem('WX100002', '李四', '主管', '已转审', '非常好', '2018-08-20 12:00', ''),
createItem('WX100003', '王五', '主管', '处理中', '', '', '')
]
const chartData = {
nodes: [
createNode('1', 1, '基础信息', '2018.08.02', [], 1, 0),
createNode('2', 1, '调职补偿', '2018.08.02', handlers, 0, 2),
createNode('3', 1, '汇总调职补偿', '', [], 1, 4),
createNode('4', 3, '启动精算', '', [], 4, 5),
createNode('5', 3, '复核精算', '', [], 4, 6),
createNode('6', 3, '审核精算', '', [], 4, 7),
createNode('7', 1, '调职补偿', '2018.08.02', [], 2, 1),
createNode('8', 1, '复核', '2018.08.02', [], 2, 2),
createNode('9', 2, '审批', '2018.08.02', [], 2, 3),
createNode('10', 1, '复核', '2018.08.02', [], 4, 2),
createNode('11', 2, '审批', '2018.08.02', [], 4, 3),
createNode('12', 3, '运算调职兑现率', '', [], 4, 4),
createNode('13', 1, '复核', '2018.08.02', [], 6, 2),
createNode('14', 4, '审批审批审批审批审批 0123456789asdfghjkl', '2018.08.02', [], 6, 3)
],
links: [
createLink('1', '2', '0 r0.5 t1 c r1.5', 1),
createLink('2', '3', '0 r1.5 c b1 r0.5', 3),
createLink('3', '4', '0 r0.5 c b3 r0.5', 3),
createLink('4', '5', '', 3),
createLink('5', '6', '', 3),
createLink('1', '7', 'r0.5 b1 c r0.5', 1),
createLink('7', '8', '', 1),
createLink('8', '9', '', 1),
createLink('9', '3', '0 r0.5 c t1', 3),
createLink('10', '11', '', 1),
createLink('11', '12', '', 3),
createLink('12', '4', '0 r0.5', 3),
createLink('13', '14', '', 1),
createLink('14', '4', '0 r1.5 c t2', 3, 'dash')
]
}
const chartConfig = createConfig()
chartConfig.headUrl = `${import.meta.env.VITE_APP_BUILD_BASE_URL}static/images/mountain.png`
chartConfig.checkItemStatus = (item) => ~['已转审', '已同意'].indexOf(item.status)
chartConfig.adjustPos = (afterNode) => afterNode.raw.name === '2' && (afterNode.y += 1)
// content listWidth 62px
chartConfig.listWidth = 150
export default {
components: {
TinyFlowchart,
TinyPopover,
IconDown,
IconUp
},
components: { TinyFlowchart },
data() {
const config = createConfig()
config.width = 960
config.height = 260 //
config.rows = 4 //
config.cols = 8 //
config.listWidth = 80
config.listThreshold = 0 // createItemitems,1items10
config.listBorderColor = '#e8e8e8'
config.listIconColor = '#bfbfbf'
config.listIconSize = 22
config.headSize = 22
return {
chartData: hooks.markRaw(chartData),
chartConfig: hooks.markRaw(chartConfig)
config,
data: {
nodes: [
createNode(
'step1',
1,
'提交申请',
'2024-06-01',
[createItem('1', '张三', '申请人', 1, '已提交', '2024-06-01')],
1,
1
),
createNode(
'step2',
2,
'主管审批',
'2024-06-02',
[createItem('2', '李四', '审批人', 2, '审批中', '2024-06-02')],
1,
3
),
createNode('step3', 3, '流程结束', '', null, 1, 5)
],
links: [createLink('step1', 'step2', '0 r2', 1, 'solid'), createLink('step2', 'step3', '0 r2', 2, 'solid')]
}
}
},
methods: {
onClickNode(_afterNode, _e) {
TinyModal.message('click-node')
},
onClickLink(_afterLink, _e) {
TinyModal.message('click-link')
},
onClickBlank(_param, _e) {
TinyModal.message('click-blank')
getNodeItems(nodeName) {
const node = this.data.nodes.find((n) => n.name === nodeName)
return node?.info?.items || []
}
}
}
</script>
<style scoped>
/* 覆盖 content 插槽容器的固定高度(默认 24px),否则下拉触发区会被挤压 */
:deep(.tiny-flow-chart__node-item) {
min-height: 24px !important;
height: auto !important;
.custom-label {
text-align: center;
}
/* 下拉触发区:收起时显示的紧凑视图 */
.flowchart-content-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
min-height: 22px;
padding: 0 4px;
border: 1px solid #d9d9d9;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
.label-title {
font-size: 13px;
font-weight: 600;
color: #333;
}
.flowchart-content-trigger .trigger-text {
.label-date {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.custom-content {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.person-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
margin: 2px;
background: #f5f5f5;
color: #666;
white-space: nowrap;
}
.flowchart-content-trigger .trigger-icon {
flex-shrink: 0;
.person-tag.done {
background: #f6ffed;
color: #52c41a;
}
/* content 插槽:下拉展开后的表单列表 */
.flowchart-content-slot {
padding: 4px 8px;
font-size: 12px;
}
.flowchart-content-slot .content-item {
display: flex;
gap: 8px;
padding: 4px 0;
border-bottom: 1px dashed #e8e8e8;
}
.flowchart-content-slot .content-item:last-child {
border-bottom: none;
}
.flowchart-content-slot .item-name {
min-width: 40px;
}
.flowchart-content-slot .item-role {
min-width: 50px;
color: #666;
}
.flowchart-content-slot .item-status {
.person-tag.doing {
background: #e6f7ff;
color: #1890ff;
}
/* 下拉弹层样式 */
:deep(.flowchart-content-popover.tiny-popper) {
margin-top: 2px;
padding: 0;
}
</style>

View File

@ -12,12 +12,54 @@ export default {
},
desc: {
'zh-CN':
"\n<p>节点支持 <code>icon</code> | <code>label</code> | <code>content</code> 插槽定制内容,示例提供了 <code>content</code> 插槽的默认实现。节点使用 <code>row</code> | <code>col</code> 属性进行行列配置。连线使用 <code>p</code> 属性进行相对路径配置。流程图的其它设置通过 <code>config</code> 进行配置。组件预置了 <code>createItem</code> | <code>createNode</code> | <code>createLink</code> | <code>createConfig</code> 静态方法,以便于快速构建选项。流程图的行高列宽由 <code>config</code> 的属性 <code>width</code> | <code>height</code> | <code>cols</code> | <code>rows</code> 确定,节点的位置由流程图的行高列宽,以及节点的 <code>row</code> | <code>col</code> 位置确定。</p>\n <p>连线相对路径配置详细介绍:</p>\n <ul>\n <li>'0' 表示从起始节点 1 的位置开始</li>\n <li>'r2' 表示向右画两列宽度的连线</li>\n <li>'c' 表示画一个圆角</li>\n <li>'b2' 表示向下画两行高度的连线</li>\n <li>'c' 再画一个圆角</li>\n <li>'l1' 表示向左画一列宽度的连线</li>\n <li>'c' 再画一个圆角</li>\n <li>'t1' 向上画一行高度的连线</li>\n </ul>\n <p>连线配置参数举例:</p>\n <span>const link = { from: '1', to: '2', p: '0 r2 c b2 c l1 c t1', status: 1 }</span>\n ",
"\n<p>节点支持 <code>icon</code> | <code>label</code> | <code>content</code> 插槽定制内容,示例提供了 <code>content</code> 插槽的默认实现。节点使用 <code>row</code> | <code>col</code> 属性进行行列配置。连线使用 <code>p</code> 属性进行相对路径配置。流程图的其它设置通过 <code>config</code> 进行配置。组件预置了 <code>createItem</code> | <code>createNode</code> | <code>createLink</code> | <code>createConfig</code> 静态方法,以便于快速构建选项。流程图的行高列宽由 <code>config</code> 的属性 <code>width</code> | <code>height</code> | <code>cols</code> | <code>rows</code> 确定,节点的位置由流程图的行高列宽,以及节点的 <code>row</code> | <code>col</code> 位置确定。</p>\n<p>连线相对路径配置详细介绍:</p>\n<ul>\n<li>'0' 表示从起始节点 1 的位置开始</li>\n<li>'r2' 表示向右画两列宽度的连线</li>\n<li>'c' 表示画一个圆角</li>\n<li>'b2' 表示向下画两行高度的连线</li>\n<li>'l1' 表示向左画一列宽度的连线</li>\n<li>'t1' 向上画一行高度的连线</li>\n</ul>\n<p>连线配置参数举例:</p>\n<pre><code>const link = { from: '1', to: '2', p: '0 r2 c b2 c l1 c t1', status: 1 }</code></pre>\n",
'en-US':
"\n<p>The node supports custom content through the <code>icon</code> | <code>label</code> | <code>content</code> slots, with a default implementation provided for the <code>content</code> slot. Nodes can be configured for row and column placement using the <code>row</code> | <code>col</code> attributes, while connections between nodes can be configured using the <code>p</code> attribute for relative path configuration. Other settings for the flowchart can be configured through the <code>config</code> attribute. The component also provides static methods <code>createItem</code> | <code>createNode</code> | <code>createLink</code> | <code>createConfig</code> for quick option building. The row height and column width of the flowchart are determined by the <code>width</code> | <code>height</code> | <code>cols</code> | <code>rows</code> attributes of the <code>config</code>, while the position of each node is determined by the row height, column width, and the <code>row</code> | <code>col</code> position of the node.</p>\n <p>Detailed introduction to configuring relative paths for connections:</p>\n <ul>\n <li>'0' represents starting from node 1 position.</li>\n <li>'r2' represents drawing a line two columns wide to the right.</li>\n <li>'c' represents drawing a circle.</li>\n <li>'b2' represents a downward line of two height units.</li>\n <li>'c' represents drawing a circle again.</li>\n <li>'l1' represents drawing a line one columns wide to the left.</li>\n <li>'t1' represents a upward line of two height units.</li>\n </ul>\n <p>Example of connection configuration parameters:</p>\n <span>const link = { from: '1', to: '2', p: '0 r2 c b2 c l1 c t1', status: 1 }</span>\n "
'\n<p>The node supports custom content through the <code>icon</code> | <code>label</code> | <code>content</code> slots, with a default implementation provided for the <code>content</code> slot. Nodes can be configured for row and column placement using the <code>row</code> | <code>col</code> attributes, while connections between nodes can be configured using the <code>p</code> attribute for relative path configuration. Other settings for the flowchart can be configured through the <code>config</code> attribute. The component also provides static methods <code>createItem</code> | <code>createNode</code> | <code>createLink</code> | <code>createConfig</code> for quick option building. The row height and column width of the flowchart are determined by the <code>width</code> | <code>height</code> | <code>cols</code> | <code>rows</code> attributes of the <code>config</code>, while the position of each node is determined by the row height, column width, and the <code>row</code> | <code>col</code> position of the node.</p>\n'
},
codeFiles: ['basic-usage.vue']
},
{
demoId: 'custom-style',
name: {
'zh-CN': '自定义样式',
'en-US': 'Custom Style'
},
desc: {
'zh-CN':
'\n<p>通过 <code>config</code> 对象的属性可以自定义流程图的样式,包括:</p>\n<ul>\n<li><code>colors</code>:自定义状态颜色映射</li>\n<li><code>background</code>:画布背景色</li>\n<li><code>font</code>Canvas 字体</li>\n<li><code>radius</code>:圆角半径</li>\n<li><code>thin</code>:是否使用细线模式</li>\n<li><code>iconWrapperSize</code> / <code>iconSize</code> / <code>iconSvgSize</code>:图标尺寸</li>\n<li><code>labelWidth</code> / <code>labelHeight</code> / <code>labelSpacing</code> / <code>labelDateColor</code>:标签样式</li>\n<li><code>listWidth</code> / <code>listIconSize</code> / <code>headSize</code>:人员列表样式</li>\n</ul>\n',
'en-US':
'\n<p>Customize the flowchart style through the <code>config</code> object properties, including <code>colors</code>, <code>background</code>, <code>font</code>, <code>radius</code>, <code>thin</code>, <code>icon*</code>, <code>label*</code>, and <code>list*</code> properties.</p>\n'
},
codeFiles: ['custom-style.vue']
},
{
demoId: 'person-list',
name: {
'zh-CN': '人员列表',
'en-US': 'Person List'
},
desc: {
'zh-CN':
'\n<p>通过 <code>createItem</code> 静态方法创建人员列表项,传入节点的 <code>items</code> 属性中。当 <code>items</code> 数量大于 <code>config.listThreshold</code>(默认 1节点下方会显示人员列表。可以通过 <code>config.listWidth</code>、<code>config.listBorderColor</code>、<code>config.listIconColor</code>、<code>config.listIconSize</code>、<code>config.headSize</code>、<code>config.headUrl</code> 等属性自定义人员列表的样式。</p>\n',
'en-US':
"\n<p>Use the <code>createItem</code> static method to create personnel list items and pass them into the node's <code>items</code> property. When the number of <code>items</code> exceeds <code>config.listThreshold</code> (default 1), a personnel list is displayed below the node. Customize the list style through <code>config.listWidth</code>, <code>config.listBorderColor</code>, <code>config.listIconColor</code>, <code>config.listIconSize</code>, <code>config.headSize</code>, and <code>config.headUrl</code>.</p>\n"
},
codeFiles: ['person-list.vue']
},
{
demoId: 'branch-flow',
name: {
'zh-CN': '分支流程',
'en-US': 'Branch Flow'
},
desc: {
'zh-CN':
'\n<p>展示条件分支流程,使用 <code>row</code> 和 <code>col</code> 控制节点在网格中的位置。连线使用 <code>p</code> 属性控制走向,支持 <code>solid</code>(实线)和 <code>dashed</code>(虚线)两种样式。通过调整 <code>config.width</code>、<code>config.height</code>、<code>config.rows</code>、<code>config.cols</code> 可以扩展画布以容纳更多节点。</p>\n',
'en-US':
'\n<p>Demonstrate conditional branch flows using <code>row</code> and <code>col</code> to control node positions in the grid. Links use the <code>p</code> property to control direction, supporting <code>solid</code> and <code>dashed</code> styles. Adjust <code>config.width</code>, <code>config.height</code>, <code>config.rows</code>, and <code>config.cols</code> to expand the canvas.</p>\n'
},
codeFiles: ['branch-flow.vue']
},
{
demoId: 'slots',
name: {
@ -25,75 +67,149 @@ export default {
'en-US': 'Slots Usage'
},
desc: {
'zh-CN': '节点支持 content 插槽自定义内容。',
'en-US': 'The node supports content slots for custom content.'
'zh-CN':
'\n<p>节点支持 <code>icon</code>、<code>label</code>、<code>content</code> 三个插槽自定义内容。插槽参数包含 <code>afterNode</code>、<code>node</code>、<code>config</code>、<code>allItem</code>、<code>dropdowns</code>、<code>showPop</code> 等上下文信息。</p>\n',
'en-US':
'\n<p>Nodes support three slots for customization: <code>icon</code>, <code>label</code>, and <code>content</code>. Slot parameters include <code>afterNode</code>, <code>node</code>, <code>config</code>, <code>allItem</code>, <code>dropdowns</code>, and <code>showPop</code>.</p>\n'
},
codeFiles: ['slots.vue']
},
{
demoId: 'event-handling',
name: {
'zh-CN': '事件处理',
'en-US': 'Event Handling'
},
desc: {
'zh-CN':
'\n<p>流程图支持 <code>click-node</code>、<code>click-link</code>、<code>click-blank</code> 三种点击事件。事件回调参数包含节点/连线/空白区域的详细信息,可用于实现交互逻辑。</p>\n',
'en-US':
'\n<p>The flowchart supports three click events: <code>click-node</code>, <code>click-link</code>, and <code>click-blank</code>. Event callback parameters contain detailed information about nodes, links, or blank areas.</p>\n'
},
codeFiles: ['event-handling.vue']
},
{
demoId: 'dynamic-update',
name: {
'zh-CN': '动态更新',
'en-US': 'Dynamic Update'
},
desc: {
'zh-CN':
'\n<p>演示如何动态更新流程图的状态。通过修改 <code>data.nodes[i].info.status</code> 和 <code>data.links[i].info.status</code> 可以实时刷新流程图。配合 <code>config.delay</code> 属性可以控制 Canvas 绘制的动画延迟效果。</p>\n',
'en-US':
'\n<p>Demonstrate how to dynamically update flowchart status. Modify <code>data.nodes[i].info.status</code> and <code>data.links[i].info.status</code> to refresh the flowchart in real-time. Use <code>config.delay</code> to control Canvas drawing animation delay.</p>\n'
},
codeFiles: ['dynamic-update.vue']
},
{
demoId: 'custom-link-style',
name: {
'zh-CN': '自定义连线样式',
'en-US': 'Custom Link Style'
},
desc: {
'zh-CN':
'<p>通过 <code>config.styleLink</code> 可以自定义连线的样式。这个函数接收连线数据对象,返回 Canvas 样式对象(如 <code>strokeStyle</code>、<code>lineWidth</code>、<code>shadowColor</code> 等)。<code>config.drawLink</code> 则提供更底层的自定义 Canvas 绘制能力,与<code>config.styleLink</code>互斥。</p>',
'en-US':
'You can customize the style of connections via <code>config.styleLink</code>. This function accepts a connection data object and returns a Canvas style object (e.g., <code>strokeStyle</code>, <code>lineWidth</code>, <code>shadowColor</code>, etc.). <code>config.drawLink</code> provides more granular control over Canvas rendering, mutually exclusive with <code>config.styleLink</code>'
},
codeFiles: ['custom-link-style.vue']
},
{
demoId: 'other',
name: {
'zh-CN': '其他用法',
'en-US': 'Other Usage'
},
desc: {
'zh-CN':
"<p>连线相对路径配置详细介绍:</p>\n <ul>\n <li>'0' 表示从起始节点 1 的位置开始</li>\n <li>'r2' 表示向右画两列宽度的连线</li>\n <li>'c' 表示画一个圆角</li>\n <li>'b2' 表示向下画两行高度的连线</li>\n <li>'c' 再画一个圆角</li>\n <li>'l1' 表示向左画一列宽度的连线</li>\n <li>'c' 再画一个圆角</li>\n <li>'t1' 向上画一行高度的连线</li>\n </ul>\n <p>连线配置参数举例:</p>\n <span>const link = { from: '1', to: '2', p: '0 r2 c b2 c l1 c t1', status: 1 }</span>\n ",
'en-US':
"<p>Detailed introduction to configuring relative paths for connections:</p>\n <ul>\n <li>'0' represents starting from node 1 position.</li>\n <li>'r2' represents drawing a line two columns wide to the right.</li>\n <li>'c' represents drawing a circle.</li>\n <li>'b2' represents a downward line of two height units.</li>\n <li>'c' represents drawing a circle again.</li>\n <li>'l1' represents drawing a line one column wide to the left.</li>\n <li>'t1' represents an upward line of one height unit.</li>\n </ul>\n <p>Example of connection configuration parameters:</p>\n <span>const link = { from: '1', to: '2', p: '0 r2 c b2 c l1 c t1', status: 1 }</span>\n "
},
codeFiles: ['other.vue']
}
],
features: [
{
id: 'slots',
name: '插槽定制',
support: {
value: true
},
support: { value: true },
description: '节点支持 icon、label、content 插槽定制内容。',
cloud: {
value: false
},
cloud: { value: false },
apis: ['icon-slot', 'label-slot', 'content-slot'],
demos: ['basic-usage']
demos: ['slots']
},
{
id: 'node-position',
name: '节点位置',
support: {
value: true
},
support: { value: true },
description: '节点使用 row、col 属性进行行列配置。',
cloud: {
value: false
},
cloud: { value: false },
apis: ['row', 'col'],
demos: ['basic-usage']
demos: ['basic-usage', 'branch-flow']
},
{
id: 'link-path',
name: '连线路径',
support: {
value: true
},
support: { value: true },
description: '连线使用 p 属性进行相对路径配置。',
cloud: {
value: false
},
cloud: { value: false },
apis: ['p'],
demos: ['basic-usage']
demos: ['basic-usage', 'branch-flow']
},
{
id: 'link-style',
name: '连线样式',
support: { value: true },
description: '支持 solid、dashed 两种连线样式,可通过 styleLink 自定义。',
cloud: { value: false },
apis: ['style', 'styleLink', 'styleHoverLink'],
demos: ['branch-flow', 'custom-link-style']
},
{
id: 'config',
name: '流程图配置',
support: {
value: true
},
description: '流程图的其它设置通过 config 进行配置,包括 width、height、cols、rows 等属性。',
cloud: {
value: false
},
support: { value: true },
description: '流程图的其它设置通过 config 进行配置,包括 width、height、cols、rows、colors、background 等属性。',
cloud: { value: false },
apis: ['config'],
demos: ['basic-usage']
demos: ['basic-usage', 'custom-style']
},
{
id: 'person-list',
name: '人员列表',
support: { value: true },
description: '节点支持通过 items 属性显示人员列表,可通过 createItem 快速创建。',
cloud: { value: false },
apis: ['items', 'createItem', 'listWidth', 'listThreshold'],
demos: ['person-list']
},
{
id: 'events',
name: '事件交互',
support: { value: true },
description: '支持 click-node、click-link、click-blank 事件。',
cloud: { value: false },
apis: ['click-node', 'click-link', 'click-blank'],
demos: ['event-handling']
},
{
id: 'dynamic-update',
name: '动态更新',
support: { value: true },
description: '支持动态修改节点和连线状态,实时刷新流程图。',
cloud: { value: false },
apis: ['status'],
demos: ['dynamic-update']
},
{
id: 'static-methods',
name: '静态方法',
support: {
value: true
},
support: { value: true },
description: '组件预置了 createItem、createNode、createLink、createConfig 静态方法,以便于快速构建选项。',
cloud: {
value: false
},
cloud: { value: false },
apis: ['createItem', 'createNode', 'createLink', 'createConfig'],
demos: ['basic-usage']
}

View File

@ -0,0 +1,27 @@
<template>
<tiny-fluent-editor v-model="content" :before-link-open="handleBeforeLinkOpen"></tiny-fluent-editor>
<div class="fluent-editor-demo__before-link-open__tip">
点击编辑器中的链接会弹出确认框点击确定将打开链接点击取消将拦截跳转
</div>
</template>
<script setup>
import { ref } from 'vue'
import { TinyFluentEditor, TinyModal } from '@opentiny/vue'
const content = ref(
'{"ops":[{"insert":"点击访问 "},{"attributes":{"link":"https://opentiny.design"},"insert":"OpenTiny 官网"},{"insert":" 了解更多。"}]}'
)
const handleBeforeLinkOpen = ({ url }) => {
return TinyModal.confirm(`即将打开链接:${url},是否允许跳转?`).then((res) => res === 'confirm')
}
</script>
<style scoped>
.fluent-editor-demo__before-link-open__tip {
margin: 16px 0;
color: #999;
font-size: 14px;
}
</style>

View File

@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test'
test('超链接跳转拦截', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('fluent-editor#before-link-open')
const demo = page.locator('#before-link-open')
const confirmModal = page.locator('.tiny-modal').filter({ hasText: '是否允许跳转' })
// 点击编辑器中的超链接,弹出确认框
await demo.getByText('OpenTiny 官网').click()
await expect(confirmModal).toBeVisible()
// 点击取消,拦截跳转,确认框关闭
await confirmModal.getByRole('button', { name: '取消' }).click()
await expect(confirmModal).toBeHidden()
// 再次点击链接,点击确定放行跳转
await demo.getByText('OpenTiny 官网').click()
await expect(confirmModal).toBeVisible()
const popupPromise = page.waitForEvent('popup', { timeout: 5000 })
await confirmModal.getByRole('button', { name: '确定' }).click()
const popup = await popupPromise
await expect(popup).toBeTruthy()
})

View File

@ -0,0 +1,35 @@
<template>
<tiny-fluent-editor v-model="content" :before-link-open="handleBeforeLinkOpen"></tiny-fluent-editor>
<div class="fluent-editor-demo__before-link-open__tip">
点击编辑器中的链接会弹出确认框点击确定将打开链接点击取消将拦截跳转
</div>
</template>
<script>
import { TinyFluentEditor, TinyModal } from '@opentiny/vue'
export default {
components: {
TinyFluentEditor
},
data() {
return {
content:
'{"ops":[{"insert":"点击访问 "},{"attributes":{"link":"https://opentiny.design"},"insert":"OpenTiny 官网"},{"insert":" 了解更多。"}]}'
}
},
methods: {
handleBeforeLinkOpen({ url }) {
return TinyModal.confirm(`即将打开链接:${url},是否允许跳转?`).then((res) => res === 'confirm')
}
}
}
</script>
<style scoped>
.fluent-editor-demo__before-link-open__tip {
margin: 16px 0;
color: #999;
font-size: 14px;
}
</style>

View File

@ -78,6 +78,19 @@ export default {
'en-US': ''
},
codeFiles: ['before-editor-init.vue']
},
{
demoId: 'before-link-open',
name: {
'zh-CN': '超链接跳转拦截',
'en-US': ''
},
desc: {
'zh-CN':
'<p>通过 <code>before-link-open</code> 拦截富文本中超链接的跳转。该属性接收一个回调函数,点击链接时会传入 <code>url</code>、<code>rawUrl</code>、<code>target</code> 等参数。返回 <code>false</code>(或 Promise resolve false可拦截跳转返回 <code>true</code> 或 <code>undefined</code> 继续跳转。<br>本示例使用 <code>TinyModal.confirm</code> 弹出确认框,点击「确定」打开链接,点击「取消」拦截跳转。</p>',
'en-US': ''
},
codeFiles: ['before-link-open.vue']
}
],
features: [
@ -158,6 +171,19 @@ export default {
},
apis: ['before-editor-init'],
demos: ['before-editor-init']
},
{
id: 'before-link-open',
name: '超链接跳转拦截',
support: {
value: true
},
description: '通过 before-link-open 拦截富文本中超链接的跳转支持同步和异步Promise拦截。',
cloud: {
value: false
},
apis: ['before-link-open'],
demos: ['before-link-open']
}
]
}

View File

@ -128,7 +128,7 @@ export default {
'en-US': 'Custom String Length'
},
desc: {
'zh-CN': '<p>通过 <code>rules</code> 的 <code>regular</code> 进行自定义字符串长度3.28.0版本新增)</p>',
'zh-CN': '<p>通过 <code>rules</code> 的 <code>regular</code> 进行自定义字符串长度</p>',
'en-US': '<p>Customize string length using the <code>regular</code> method of <code>rules</code>. </p>'
},
codeFiles: ['custom-validation-string-length.vue']

View File

@ -38,7 +38,7 @@ import { TinyGrid, TinyGridColumn, TinyGridToolbar, TinyModal } from '@opentiny/
const toolbarButtons = ref([
{
code: ' clearSelected',
code: 'clearSelected',
name: '手动清除单元格选中状态'
}
])

View File

@ -45,7 +45,7 @@ export default {
return {
toolbarButtons: [
{
code: ' clearSelected',
code: 'clearSelected',
name: '手动清除单元格选中状态'
}
],

View File

@ -0,0 +1,68 @@
<template>
<div>
<tiny-grid :data="tableData" auto-resize>
<tiny-grid-column type="index" width="60"></tiny-grid-column>
<tiny-grid-column type="operation" title="操作" :operation-config="operationConfig"></tiny-grid-column>
<tiny-grid-column field="name" title="名称"></tiny-grid-column>
<tiny-grid-column field="area" title="所属区域"></tiny-grid-column>
<tiny-grid-column field="address" title="地址"></tiny-grid-column>
<tiny-grid-column field="introduction" title="公司简介" show-overflow></tiny-grid-column>
</tiny-grid>
</div>
</template>
<script setup>
import { TinyGrid, TinyGridColumn, TinyModal } from '@opentiny/vue'
import { IconAreaChart, IconBarChart, IconDotChart, IconLineChart, IconPieChart } from '@opentiny/vue-icon'
import { ref } from 'vue'
function clickHandler(e, { row, buttonConfig }) {
TinyModal.message(`点击按钮 - ${row.name} - ${buttonConfig.name}`)
row.flag = !row.flag
}
function clickHandler2(e, { row, buttonConfig }) {
TinyModal.message(`点击按钮 - ${row.name} - ${buttonConfig.name}`)
}
const operationConfig = ref({
buttons: [
{ name: '操作1', icon: IconAreaChart(), click: clickHandler, hidden: (row) => row.flag === true },
{ name: '操作2', icon: IconBarChart(), click: clickHandler, hidden: (row) => row.flag === false },
{
name: '操作3',
icon: IconDotChart(),
click: clickHandler2,
disabled: false,
class: 'fill-color-icon-active text-color-text-placeholder'
},
{ name: '操作4', icon: IconLineChart(), click: clickHandler2, disabled: () => false },
{ name: '操作5', icon: IconPieChart(), click: clickHandler2, hidden: false }
]
})
const tableData = ref([
{
id: '1',
name: 'GFD科技有限公司',
area: '华东区',
address: '福州',
introduction: '公司技术和研发实力雄厚是国家863项目的参与者并被政府认定为“高新技术企业”。',
flag: true
},
{
id: '2',
name: 'WWWW科技有限公司',
area: '华南区',
address: '深圳福田区',
introduction: '公司技术和研发实力雄厚是国家863项目的参与者并被政府认定为“高新技术企业”。',
flag: true
},
{
id: '3',
name: 'RFV有限责任公司',
area: '华南区',
address: '中山市',
introduction: '公司技术和研发实力雄厚是国家863项目的参与者并被政府认定为“高新技术企业”。',
flag: true
}
])
</script>

Some files were not shown because too many files have changed in this diff Show More