图库控件设计 🌕

记录了工作中的「图库控件」的设计

产品需求

业务需要一个图库控件——可以查看、编辑当前用户的图片。图片分为三类:待处理、已处理、无用图片。图片可以缩放查看,旋转,下载,复制。也可以上传新的图片;图片都可以编辑,填写对应的表单,打标并分类。设计如图:

设计图

组件设计

  1. UI 图很明显的把组件分成了三个部分:图片列表,图片查看器,编辑图片信息区域。所以,分成三个子组件去设计是很合理的。
  2. 先说图片查看器组件,这个因为业务需要,其他后续页面可能还要用到(含缩放,旋转功能),所以尽量减少与其他两个组件的耦合。
  3. 这个图库控件(父组件)是个弹窗,要支持拖动,缩放。
  4. 根据交互逻辑,组件之间的数据传递还是很频繁的,所以决定使用 VueX 管理数据。

交互设计

前端开发

完整代码地址

技术选型

  • 父组件的拖动、缩放功能,使用插件vue-draggable-resizable
  • 图片查看器功能,使用插件viewerjs
  • 以上两个插件还需要封装一下,便于使用

VueX

所有全局的(超过两个组件,或者两个层级的)数据,都使用 VueX 进行管理。

State

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
const initialState = {
// 图片分类列表
// 编辑图片时,选择图片分类时用到。
imageTypeList: [],
// 已处理图片列表
// 左侧一共三个图片列表,此是其一
processedList: [],
// 待处理图片列表
// 左侧一共三个图片列表,此是其二
processList: [],
// 无用图片列表
// 左侧一共三个图片列表,此是其三
uselessList: [],
// 图库全局加载动画
// 在进行异步操作(标记为无用图片,保存编辑内容)时,使用loading,防止用户误操作(关闭操作除外)
loading: true,
// 图库状态: false: view, true: edit
// 用途是显示/隐藏左侧图片列表和右侧编辑模块。值为view,则显示列表,隐藏编辑;为edit,则反之。
editState: false,
// 图库当前列表 processedList processList uselessList
// 三个列表其实是三个tab。tab name即三个列表名称。默认为待处理列表。
currentListName: "processList",
// 图库当前图片
// 当前在图片查看器展示的图片(在当前列表选中的图片)。默认为null。
// 图库打开后,检测为null时,为自动使用当前列表的第一张图片,若当前列表为空,则不展示图片
currentImg: null,
// 图库当前图片的图库id
// 当前图片的唯一标识,图库当前图片编辑类型为1、保存编辑信息时,回传给服务端。
currentImgID: null,
// 图库当前图片是否可编辑 false: 否 true: 是
// 编辑图片按钮是否显示
currentImgEditable: false,
// 图库当前图片的编辑类型 1:仅编辑分类2:录入病历
// 图片有两种编辑模式
currentImgEditType: 1,
// 图库当前图片编辑类型为1时,需要的初始化数据
currentImgClassifyInfoFormData: {
classifyTypeName: "",
time: ""
},
// 图库当前图片的profileID
// 图库当前图片编辑类型为2、保存编辑信息时,回传给服务端。
currentImgProfileID: "",
// 图库当前图片编辑类型为2时,需要的初始化数据
currentImgMedicalHistoryFormData: {}
};
  • 所有变量的用途和使用都在代码注释中,请仔细查阅(第一行是名称,第二行是用途)。
  • 我声明了initialState变量,会在初始化(初始化、重置)的时候用到,注意不是直接使用,而是_.cloneDeep(initialState)

Mutation

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
const mutations = {
// 设置「图片分类」列表
[SET_IMAGE_TYPE_LIST](state, payload = []) {
state.imageTypeList = payload;
},
// 设置「已处理」图片列表
[SET_PROCESSED_LIST](state, payload = []) {
state.processedList = _.cloneDeep(payload);
},
// 设置「待处理」图片列表
[SET_PROCESS_LIST](state, payload = []) {
state.processList = _.cloneDeep(payload);
},
// 设置「无用图片」列表
[SET_USELESS_LIST](state, payload = []) {
state.uselessList = _.cloneDeep(payload);
},
// 将图片从「待处理」图片列表移动到「已处理」图片列表
[MOVE_PROCESS_TO_PROCESSED](state, imageKey) {
const index = state.processList
.map(item => item.imageKey)
.indexOf(imageKey);
const img = state.processList.splice(index, 1);
state.processedList = [...img, ...state.processedList];
},
// 将图片从「无用图片」列表移动到「已处理」图片列表
[MOVE_USELESS_TO_PROCESSED](state, imageKey) {
const index = state.uselessList
.map(item => item.imageKey)
.indexOf(imageKey);
const img = state.uselessList.splice(index, 1);
state.processedList = [...img, ...state.processedList];
},
// 将图片从「待处理」图片列表移动到「无用图片」列表
[MOVE_PROCESS_TO_USELESS](state, imageKey) {
const index = state.processList
.map(item => item.imageKey)
.indexOf(imageKey);
const img = state.processList.splice(index, 1);
state.uselessList = [...img, ...state.uselessList];
},
// 将图片从「已处理」图片列表移动到「无用图片」列表
[MOVE_PROCESSED_TO_USELESS](state, imageKey) {
const index = state.processedList
.map(item => item.imageKey)
.indexOf(imageKey);
const img = state.processedList.splice(index, 1);
state.uselessList = [...img, ...state.uselessList];
},
// 设置图库全局加载动画
[SET_LOADING](state, payload) {
state.loading = payload;
},
// 设置图库状态
[SET_EDIT_STATE](state) {
state.editState = !state.editState;
},
// 设置图库当前列表
[SET_CURRENT_LIST_NAME](state, payload) {
state.currentListName = payload;
},
// 设置图库当前图片
[SET_CURRENT_IMG](state, payload) {
state.currentImg = payload;
},
// 设置图库当前图片图库id
[SET_CURRENT_IMG_ID](state, payload) {
state.currentImgID = payload;
},
// 设置图库当前图片是否可编辑
[SET_CURRENT_IMG_EDITABLE](state, payload) {
state.currentImgEditable = payload;
},
// 设置图库当前图片编辑类型
[SET_CURRENT_IMG_EDIT_TYPE](state, payload) {
state.currentImgEditType = payload;
},
// 设置图库当前图片编辑类型为1时,表格数据
[SET_CURRENT_IMG_CLASSIFY_INFO_FORM_DATA](state, payload) {
state.currentImgClassifyInfoFormData = payload;
},
// 设置图库当前图片profileID
[SET_CURRENT_IMG_PROFILE_ID](state, payload) {
state.currentImgProfileID = payload;
},
// 设置图库当前图片编辑类型为2时,表格数据
[SET_CURRENT_IMG_MEDICAL_HISTORY_FORM_DATA](state, payload) {
state.currentImgMedicalHistoryFormData = payload;
},
// 清空/初始化 图库数据
[CLEAR_IMG_LIB](state) {
const initState = _.cloneDeep(initialState);
Object.keys(initState).forEach(i => {
state[i] = initState[i];
});
// 因为用了ESLINT(https://eslint.org/docs/rules/no-param-reassign),以下语法会报错
// 其实可以直接写成
// state = _.cloneDeep(initialState);
}
};
  • 所有 mutation 方法的用途都在代码注释中了,请自行查阅
  • 所有 mutation 方法均为同步操作
  • 对于「参数被重新赋值是否合理?」感兴趣的话,来这里看看

Action

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
const actions = {
// 获取图片分类列表
async getImagesTypeList({ commit }) {
try {
commit(SET_IMAGE_TYPE_LIST, await getImgTypeList());
} catch (e) {
Message.error("获取图片分类列表失败");
return Promise.reject(e);
}
},
// 获取指定患者的图库
async fetchImgList(context, patientId) {
try {
const {
arrangedImageList = [],
notArrangedImageList = [],
uselessImageList = []
} = await showImage(patientId);
context.commit(SET_PROCESSED_LIST, arrangedImageList);
context.commit(SET_PROCESS_LIST, notArrangedImageList);
context.commit(SET_USELESS_LIST, uselessImageList);
} catch (e) {
Message.error("获取患者的图库失败");
return Promise.reject(e);
}
},
// 「待处理」图片设为「无用图片」
async signProcessToUseless({ state, commit }, imageKey) {
try {
await forSake(imageKey);
commit(MOVE_PROCESS_TO_USELESS, imageKey);
} catch (e) {
Message.error("「待处理」图片设为「无用图片」失败");
return Promise.reject(e);
}
},
// 「已处理」图片转为「无用图片」
async signProcessedToUseless({ state, commit }, payload) {
try {
const { imageLibraryId, imageKey } = payload;
await setProcessedToUseless(imageLibraryId);
commit(MOVE_PROCESSED_TO_USELESS, imageKey);
} catch (e) {
Message.error("「已处理」图片转为「无用图片」失败");
return Promise.reject(e);
}
},
// 获取当前图片信息
async getCurrentImgInfo(context, payload) {
try {
const { patientId, imageKey } = payload;
const { canEdit, imageLibraryId } = await getImageTags(
patientId,
imageKey
);
// 更换imageLibraryId
context.commit(SET_CURRENT_IMG_ID, imageLibraryId);
//改变currentImgEditable状态
context.commit(SET_CURRENT_IMG_EDITABLE, !!canEdit);
} catch (e) {
Message.error("获取当前图片信息失败");
return Promise.reject(e);
}
},
// 复制图片
async getImageCopyById({ state, commit, dispatch }, payload) {
try {
// 复制图片
const img = await getCopyImage(state.currentImgID);
// 更改当前图片信息
commit(SET_CURRENT_IMG, img);
// 调用 getCurrentImgInfo action(异步)
await dispatch("getCurrentImgInfo", {
patientId: payload,
imageKey: img.imageKey
});
// 将克隆的图片放入待处理列表,并切换到待处理列表
state.processList = [img, ...state.processList];
commit(SET_CURRENT_LIST_NAME, "processList");
Message.success("复制图片成功");
} catch (e) {
Message.error("复制图片失败");
return Promise.reject(e);
}
},
// 获取当前图片编辑信息
async getEditImageInfo(context, currentImgID) {
try {
const {
editType,
imageLibraryId,
classifyInfo,
profileId
} = await getEditImageClassifyInfo(currentImgID);
context.commit(SET_CURRENT_IMG_EDIT_TYPE, editType);
context.commit(SET_CURRENT_IMG_ID, imageLibraryId);
context.commit(SET_CURRENT_IMG_PROFILE_ID, profileId);
classifyInfo &&
context.commit(SET_CURRENT_IMG_CLASSIFY_INFO_FORM_DATA, classifyInfo);
} catch (e) {
Message.error("获取当前图片编辑信息失败");
return Promise.reject(e);
}
},
// 保存编辑信息 「仅标记图片分类」
async saveImageClassifyInfo({ state, commit }, payload) {
try {
await saveImageClassifyInfo(payload);
/*
* 保存成功后,
* 1. 修改当前图片的信息
* 2. 当前图片移至已处理列表
* 3. 需要从当前列表跳转到已处理列表
* */
// 1
state.currentImg.time = payload.date;
state.currentImg.group = payload.date;
for (const item of state.imageTypeList) {
if (item.typeId === payload.classifyType) {
state.currentImg.imageClassifyName = item.typeName;
break;
}
}
commit(SET_CURRENT_IMG_PROFILE_ID, null);
// 2
if (state.currentListName === "processList") {
commit(MOVE_PROCESS_TO_PROCESSED, state.currentImg.imageKey);
} else if (state.currentListName === "uselessList") {
commit(MOVE_USELESS_TO_PROCESSED, state.currentImg.imageKey);
}
// 3
commit(SET_CURRENT_LIST_NAME, "processedList");
// 已处理的图片不可编辑
commit(SET_EDIT_STATE, false);
} catch (e) {
Message.error("「仅标记图片分类」失败");
return Promise.reject(e);
}
},
// 保存编辑信息 「录入至病历」
async saveForAudit({ state, commit }, payload) {
try {
const { time, imageClassifyName } = await saveForAudit(payload);
/*
* 保存成功后,
* 1. 修改当前图片的信息
* 2. 当前图片移至已处理列表
* 3. 需要从当前列表跳转到已处理列表
* */
// 1
state.currentImg.time = time;
state.currentImg.group = time;
state.currentImg.imageClassifyName = imageClassifyName;
state.currentImg.isMedicalRecord = true;
commit(SET_CURRENT_IMG_EDITABLE, false);
commit(SET_CURRENT_IMG_PROFILE_ID, null);
// 2
if (state.currentListName === "processList") {
commit(MOVE_PROCESS_TO_PROCESSED, state.currentImg.imageKey);
} else if (state.currentListName === "uselessList") {
commit(MOVE_USELESS_TO_PROCESSED, state.currentImg.imageKey);
}
// 3
commit(SET_CURRENT_LIST_NAME, "processedList");
// 已处理的图片不可编辑
commit(SET_EDIT_STATE, false);
} catch (e) {
Message.error("「录入至病历」失败");
return Promise.reject(e);
}
}
};

组件设计

组件创建

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
import Vue from "vue";
// 全局vuex
import store from "src/store";
// route
import router from "src/route";
// 图库模版文件
import Modal from "./index.vue";
// 关闭图库
window._imgLib_component_close = () => {
if (window._imgLib_component) {
window._imgLib_component.$el.parentNode.removeChild(
window._imgLib_component.$el
);
// 销毁当前组件
window._imgLib_component.$destroy();
// 移除引用
window._imgLib_component = null;
}
};
export default function render(args) {
// 关掉已打开的图库
window._imgLib_component_close();
// 之所以挂载在window时因为路由变化(页面跳转)时,需要关闭图库
window._imgLib_component = new Vue({
store,
router,
render: h =>
// 参数作为props传入
h(Modal, {
props: args
})
}).$mount();
document.body.appendChild(window._imgLib_component.$el);
}

调用组件

1
2
3
4
5
6
7
import imgLib from "src/w/img-lib";
imgLib({
current: String, // 当前图片的imageKey。非必传。默认为空。
patientId: Number, // 患者ID。非必传。默认会从url中获取。
position: String, // 图库初始显示位置。非必传。默认是left。可选值:left、right。
uploadTargetId: String // 放置点元素ID。非必传。默认为空。
});

父组件模版

无可赘述,只列出大致结构。详细点我

1
2
3
4
5
6
7
├── vue-draggable-resizable 控制图库移动及缩放
│ ├── 标题部分
│ ├── 主体部分
│ │ ├── 左侧列表
│ │ ├── 图片查看器
│ │ └── 编辑图片
└── ├── 图片上传弹窗

父组件中一些值得一说的方法

详细点我

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
methods: {
...
handleClose() {
this.$refs.viewerCom.$emit('destroy');
this.clearImgLib();
window._imgLib_component_close();
},
...
// 设置图库z-index
setZIndex(elem = 'el-dialog__wrapper') {
const elems = document.getElementsByClassName(elem);
let highest = this.VDR.z;
for (let i = 0; i < elems.length; i++) {
const zindex = document.defaultView.getComputedStyle(elems[i], null).getPropertyValue('z-index');
if (zindex > highest && zindex !== 'auto') {
highest = zindex;
}
}
this.VDR.z = +highest;
},
}
  • handleClose用以销毁子组件和清空Vuex的数据,然后调用暴露在全局的 close 方法。
  • setZIndex用于设置图库的Index,因为使用的Element UI 框架,有可能在弹窗中调用图库,而Element UI
    弹窗ZIndex是动态变动的,所以要保障比当前已打开的弹窗要高。

编辑模块

无可赘述。详细点我

左侧列表

详细点我

有一个需求是这样的:图库可能是从一个「图片上传组件」内打开的,要支持从图库把图片拖拽到「图片上传组件」,图片数据加入上传列表。
并且,拖拽图片时,要改变「图片上传组件」的样式,让使用者能快速找到放置点。放置完成后,还原样式。

所以在上面「调用组件」要传递的props中,你看到了一个奇怪的字段uploadTargetId。对,他就是那个「图片上传组件」的最外围divid
而传递这个 id 的用途其实就是为了上一段话提到的「拖拽时改变组件样式」。代码如下:

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
// 拖拽某个图片,开始时带上当前图片信息,关联的drop样式设置
handleDragStart(event, img) {
event.dataTransfer.dropEffect = 'copy';
event.dataTransfer.setData('text', JSON.stringify(img));
const id = this.uploadTargetId;
if (id) {
const el = document.getElementById(id);
if (el) {
// style
el.setAttribute('style', 'border: 2px solid #409EFF;border-radius: 0;');
// text
const textNode = document.createElement('div');
textNode.id = 'temp';
textNode.setAttribute(
'style',
'position:absolute;top:0;left:0;z-index:2;width:100%;height:100%;display:flex;justify-content: center;align-items: center;background:white;text-align:center;color: #999; font-size: 14px; line-height: 1.5;padding: 0 1em;box-sizing: border-box;'
);
textNode.textContent = '拖拽到此处上传';
el.appendChild(textNode);
}
}
},
// 拖拽结束,清空drop样式
handleDragEnd() {
const id = this.uploadTargetId;
if (id) {
const el = document.getElementById(id);
if (el) {
el.setAttribute('style', '');
el.removeChild(el.querySelector('#temp'));
}
}
},

「图片上传组件」的代码

1
2
3
4
5
6
7
8
// 制造一个唯一的ID
randomID: `UploadIDForImgLib${Math.random().toString(35).substr(2, 8)}`,

// 接收数据
handleDragEnd(event) {
const data = JSON.parse(event.dataTransfer.getData('text'));
this.$emit('input', [...this.value, data]);
},

图片查看器

无可赘述。详细点我

项目总结

其实,开发一个拓展性好、鲁棒性强的项目,最重要的一开始的设计。而好的设计需要有两个必要的先决条件:对需求的绝对领悟,以及适合的技术选型
前者需要工作经验的积累,以及对产品 PRD 文档、设计交互稿的深度研究和理解,而后者需要作为一个程序开发者的知识深度和知识广度。

我认为,好的架构设计可以成就一个好的项目。所以,一个项目上最应该花费时间的,恰恰就是项目开始前的设计,就是建筑设计师画的图稿的过程,
而写代码,其实真的只是一个搬砖的过程。