【项目】八爪鱼

零代码,拖拽式操作,即可生成可视化应用,无需开发技术,也可灵活构建分析报表。支持快速接入个性化组件,支持无界扩展。

任职【跨越速运】期间负责的项目

DEMO体验

传送门

一、项目背景

鉴于公司大量分析报表的需求,长期以来一直依赖于QuickBI。然而,QuickBI 平台的自定义能力受限,迭代链路较为繁琐,使用成本较高。

  • 功能上(表格无法支持下钻、多级表头、列的展开收起等)
  • 安全上(数据无法支持动态显示或者加密显示)
  • 体验上(无法对接内部大数据产品)
  • 成本上(定制需求迭代周期长,成本高)

二、目标与价值

支持一站式开发能力,解决个性化交互、权限配置、使用成本等问题

  • 组件(支持接入自定义组件)
  • 交互(支持与企业文化统一的交互风格)
  • 操作(保持QuickBl操作界面的同时,简化和降低操作成本)

三、项目架构

功能设计

系统分层

  • 搭建协议规范 - 描述使用的组件以及对应的样式属性、数据源、组件布局等
  • 物料协议规范 - 描述每个组件对应的设置面板有哪些属性
  • 资产包协议规范 - 描述需要引入的组件及对应的基础配置

整体流程

物料板块功能设计

设置器板块功能设计

设置器规则转换引擎

数据规则转换引擎

四、任务触达

五、关键模块实现

布局

在vue的技术栈中首选 Vue Grid Layout 栅格布局系统。react版本请使用 React Grid Layout

需求分析:

  • 可拖拽
  • 可调整大小
  • 响应式
  • 有边界检查
  • 可序列化和还原

效果预览:

物料拖进画布

Vue Grid Layout 解决的是画布内的盒子拖动问题,那么从画布外拖一个元素到画布内如何实现,这里就可以考虑使用原生的 js drag事件,drag事件详解

需求分析:

  • 源对象添加draggable属性,支持拖拽
  • 源对象绑定dragstart事件,触发时给目标对象(画布)添加dragoverdragleavedrop事件
  • 目标对象触发dragover,则生成一个草稿状态的小部件
  • 目标对象触发dragleave,则删除草稿状态的小部件
  • 目标对象触发drop,则生成一个正式状态的小部件

效果预览:

数据字段拖拽

除了与画布有关的拖拽,设置数据字段的时候,如果能拖拽体验也会好一点,在这里采用了 vuedraggable

效果预览:

事件管理

系统中采用的是mitt来做数据的发布订阅,但在某种情况下,会存在数据丢失。例如在访问页面时,小部件的数据请求完了,但小部件还未初始化好,这个时候我们希望发布数据的一方能进行重试,在一定次数内重试成功则停止。所以,八爪鱼新增了一个带有重试功能的发布订阅器。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import mitt from "mitt";

export const events = mitt();

const maxRetryCount = 30;
const timeout = 100;
class EventEmitter {
constructor() {
// 存储事件名称与对应的处理函数
this.events = {};
// 存储事件的重试次数
this.retryCount = {};
}

// 订阅事件
on(eventName, callback) {
// 如果事件不存在,则初始化为一个空数组
if (!this.events[eventName]) {
this.events[eventName] = [];
}
// 将处理函数添加到对应事件的处理函数数组中
this.events[eventName].push(callback);
}

// 发布事件
emit(eventName, ...args) {
// 如果事件存在,则依次调用对应的处理函数
if (this.events[eventName]) {
// 事件成功被处理,停止重试并清零重试次数
delete this.retryCount[eventName];
this.events[eventName].forEach((callback) => {
callback(...args);
});
} else {
// 如果事件不存在,则自动调用重试
this.retryUnsubscribedEvent(eventName, args);
}
}

// 重试未处理的事件
retryUnsubscribedEvent(eventName, args) {
// 如果事件未被重试过,则初始化重试次数为0
if (!this.retryCount[eventName]) {
this.retryCount[eventName] = 0;
}

// 如果重试次数小于3次,则继续重试
if (this.retryCount[eventName] < maxRetryCount) {
// 增加重试次数
this.retryCount[eventName]++;

// 在一定延迟后进行重试
setTimeout(() => {
this.emit(eventName, ...args);
}, timeout); // 延迟时间为1秒,你可以根据实际情况进行调整
} else {
// 重试次数达到3次后,清除重试计数
delete this.retryCount[eventName];
}
}

// 取消订阅事件
off(eventName, callback) {
// 如果事件存在,则在对应事件的处理函数数组中删除指定的处理函数
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(
(cb) => cb !== callback
);
}
}
}

export const emitter = new EventEmitter();

撤销、前进

因为八爪鱼有着编辑器的属性,所以撤销、前进功能必须要有,类似于ps的撤销、前进;

需求分析:

  • 既然有撤销、前进,那么必然存在一个管道,一个游标
  • 管道准备好了,里面就应该放我们执行的每一个命令,每执行一个命令,游标+1,在执行撤销时,则游标-1
  • 哪些命令会进入管道呢(添加、删除、修改、拖拽小部件,修改画布)
  • 由于每个命令都可以被前进、后退;所以每个命令至少应该存在两个方法,一个方法用于记录前进后的状态,一个方法记录后退后的状态
  • 所有基于命令模式下的

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import { events } from "@/utils/events"
import _ from "lodash"
import { onUnmounted } from "vue"

export default function (editorData, callback) {
const state = {
current: -1, // 当前历史记录游标
queue: [], // 任务队列
commands: {}, // 所有命令 - 用于快速执行
commandArray: [], // 所有命令
destroyArray: [], // 副作用函数
}

/**
* 命令注册函数
* @param {*} commanObj
*/
const registry = (commanObj) => {
// 命令收集
state.commandArray.push(commanObj)
// 命令挂载
state.commands[commanObj.name] = (...args) => {
// 返回各个命令的 正向、反向执行方法
const { forward, back } = commanObj.execute(...args)
// 正向执行命令
forward()

// 不需要被收集到任务队列(例如:前进、后退类型)
if (!commanObj.needPushQueue) {
return
}

let { queue, current } = state

// 当操作添加、删除、更新(非前进、后退)命令后,需要刷新队列
// 例如任务队列中有个10个任务,当回退了3个,下标到了7,此时有新任务在执行,任务队列应当被刷新为8
if (queue.length > 0) {
queue = queue.slice(0, current + 1)
state.queue = queue
}
// 将任务推入队列中
queue.push({ forward, back })
// 移动游标
state.current = current + 1
// 执行回调,便于外界获取当前任务队列情况
callback && callback(state)
}
}
// 添加小部件
registry({
name: "addWidget",
needPushQueue: true,
execute(newWidget) {
let state = {
before: editorData.value.widgets,
after: (() => {
let widgets = [...editorData.value.widgets]
let newWidgets = [...widgets, newWidget]
return newWidgets
})(),
selectedIndex: editorData.value.widgetSelectedIndex,
}
return {
forward: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: state.selectedIndex,
widgets: state.after,
}
},
back: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: state.selectedIndex,
widgets: state.before,
}
},
}
},
})
// 删除小部件
registry({
name: "removeWidget",
needPushQueue: true,
execute(targetWeight) {
let state = {
before: _.cloneDeep(editorData.value.widgets),
after: (() => {
let widgets = _.cloneDeep(editorData.value.widgets)
// 排除掉当前删除的对象
let newWidgets = widgets.filter(
(widget) => widget.i !== targetWeight.i
)
return newWidgets
})(),
selectedIndex: editorData.value.widgetSelectedIndex,
}
return {
forward: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: state.selectedIndex,
widgets: state.after,
}
},
back: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: state.selectedIndex,
widgets: state.before,
}
},
}
},
})
// 更新小组件
registry({
name: "updateWidget",
needPushQueue: true,
execute({ newWidget, oldWidget }) {
let state = {
before: editorData.value.widgets,
after: (() => {
let widgets = [...editorData.value.widgets]
const index = editorData.value.widgets.indexOf(oldWidget)
if (index > -1) {
// 执行替换
widgets.splice(index, 1, newWidget)
}
return widgets
})(),
selectedIndex: editorData.value.widgetSelectedIndex,
}
return {
forward: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: state.selectedIndex,
widgets: state.after,
}
},
back: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: state.selectedIndex,
widgets: state.before,
}
},
}
},
})
// 拖拽组件
registry({
name: "drag",
needPushQueue: true,
init() {
this.before = null
const start = () => {
this.before = _.cloneDeep(editorData.value.widgets)
}
const end = () => {
state.commands.drag()
}
// 拖动开始时,执行更新 before 的值,便于回退
events.on("dragstart", start)
// 拖动结束,则执行drag命令的execute方法中的forward方法(请再次阅读注册函数)
events.on("dragend", end)
return () => {
events.off("dragstart", start)
events.off("dragend", end)
}
},
// 该方法仅会在拖动结束的时候才会执行
execute() {
let before = this.before
let after = editorData.value.widgets
let selectedIndex = editorData.value.widgetSelectedIndex
return {
forward: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: selectedIndex,
widgets: after,
}
},
back: () => {
editorData.value = {
...editorData.value,
widgetSelectedIndex: selectedIndex,
widgets: before,
}
},
}
},
})
// 更新画布
registry({
name: "updateCanvas",
needPushQueue: true,
execute(newValue) {
let state = {
before: editorData.value,
after: newValue,
}
return {
forward: () => {
editorData.value = state.after
},
back: () => {
editorData.value = state.before
},
}
},
})
// 前进
registry({
name: "forward",
execute() {
return {
forward() {
let item = state.queue[state.current + 1]
// 存在正向任务
if (item) {
item.forward && item.forward()
// 修改游标
state.current++
// 执行回调(由于该任务没有配置进入队列,故需要单独调用)
callback && callback(state)
setTimeout(() => {
// 由于发生变更,需要通知小部件更新数据(例如新增了刚删除的一个维度字段)
events.emit("command_history", editorData)
}, 0)
}
},
}
},
})
// 撤销
registry({
name: "back",
execute() {
return {
forward() {
if (state.current === -1) return
let item = state.queue[state.current]
// 存在逆向任务
if (item) {
item.back && item.back()
// 修改游标
state.current--
// 执行回调(由于该任务没有配置进入队列,故需要单独调用)
callback && callback(state)
setTimeout(() => {
// 由于发生变更,需要通知小部件更新数据(例如撤销了刚添加的一个维度字段)
events.emit("command_history", editorData)
}, 0)
}
},
}
},
})
// 重置
registry({
name: "reset",
execute() {
return {
forward() {
state.current = -1
state.queue = []
},
}
},
})
~(() => {
// 遍历所有命令,批量执行各命令的初始化工作
state.commandArray.forEach((command) => {
if (!command.init) {
return
}
const result = command.init()
// 收集副作用
state.destroyArray.push(result)
})
})()

onUnmounted(() => {
state.destroyArray.forEach((f) => f())
})

return state
}

效果预览:

预览模式

预览模式下,画布尺寸会变化,由于采用了响应式的栅格系统,每个盒子的大小会自动缩放,但图表实例则需要监听事件手动resize处理;结合css3动画,做一个无关模块请退场的效果吧

需求分析:

  • 只显示画布区域
  • 画布区域小部件不能拖动、不能选中

效果预览:


喜欢这篇文章?打赏一下支持一下作者吧!
【项目】八爪鱼
https://www.cccccl.com/20231117/项目/八爪鱼/
作者
Jeffrey
发布于
2023年11月17日
许可协议