小程序选人控件 - 仿企业微信实现多层级无规则嵌套

栏目: JavaScript · 发布时间: 6年前

内容简介:在很多系统中都有选择联系人的需求,市面上也没什么好的参照,产品经理看企业微信的选人挺好用的,就说参照这个做一个吧。。。算了,还是试着做吧,企业微信的选人的确做的挺好,不得不佩服。先看看效果图吧,多层级无规律的嵌套都能搞定

在很多系统中都有选择联系人的需求,市面上也没什么好的参照,产品经理看企业微信的选人挺好用的,就说参照这个做一个吧。。。

小程序选人控件 - 仿企业微信实现多层级无规则嵌套

算了,还是试着做吧,企业微信的选人的确做的挺好,不得不佩服。

先看看效果图吧,多层级无规律的嵌套都能搞定

小程序选人控件 - 仿企业微信实现多层级无规则嵌套

一、设计解读

小程序选人控件 - 仿企业微信实现多层级无规则嵌套

整个界面分为三部分:

  • 最上面的返回上一层按钮
  • 中间的显示部门、人员的列表
  • 最下面显示和操作已选人员的 footer。

为什么加一个返回上一层按钮呢?

我也觉得比较丑,但小程序无法直接控制左上角返回键(自定义 Title 貌似可以,没试过),点左上角的返回箭头的话就退出选人控件到上个页面了。

我们的需求是点击一个文件夹,通过刷新当前列表进入下一级目录,感觉像是又进了一个页面,但其实并没有,只是列表的数据变化了。由此实现不定层级、无规律的部门和人员嵌套的支持。

比如先点击了首屏数据的第二个 item ,它的 index1 ,就将 1 存入 indexList ;返回上一层时将最后一个元素删除。

当勾选了某个人或部门时,会在底部的框中显示所有已选人员或部门的名字,当文字超过屏幕宽度时可以向右无限滑动,底部 footer 始终保持一行。

最终选择的人以底部 footer 里显示的为准,点击确定时根据业务需要将已选人员数据发送给需要的界面。

二、功能逻辑分析

先看看数据格式

{
  id: TEACHER_ID,
  name: '教师',
  parentId: '',
  checked: false,
  isPeople: false,
  children: [
    {
      id: TEACHER_DEPARTMENT_ID,
      name: '部门',
      parentId: 'teacher',
      checked: false,
      isPeople: false,
      children: []
    },
    {
      id: TEACHER_SUBJECT_ID,
      name: '学科',
      parentId: 'teacher',
      checked: false,
      isPeople: false,
      children: []
    },
    {
      id: TEACHER_GRADECLASS_ID,
      name: '年级班级',
      parentId: 'teacher',
      checked: false,
      isPeople: false,
      children: []
    },
  ]
}
复制代码

所有的数据组成一个数据树,子节点嵌套在父节点下。

id , name 不说了, parentId 指明它的父节点, children 包含它的所有子节点, checked 用来判断勾选状态, isPeople 判断是部门还是人员,因为两者的图标不一样。

注意:

本控件采用了数据分步加载的模式,除了最上层固定的几个分类,其他的每层数据都是点击具体的部门后才去请求服务器加载本部门下的数据的,然后再拼接到原始数据树上。这样可以提高加载速度,提升用户体验。

我也试了一次性把所有数据都拉下来,一是太慢,得三五秒,二是数据量太大的话(我这里应该是超过1000,阈值多少没测过), setData() 的时候就会报错:

小程序选人控件 - 仿企业微信实现多层级无规则嵌套

超过最大长度了。。。所以只能分步加载数据。

当然如果你的数据量小,几十人或几百人,也可以选择一次性加载。

这个控件逻辑上还是比较复杂的,要考虑的细节太多……下面梳理一下主要的逻辑点

主要逻辑点

1. 需要一个数组存储所有被点击的部门在当前列表的索引 index ,这里用 indexList 表示

点击某个部门进入下一层目录时,将被点击部门的 index 索引 pushindexList 中。点击返回上一层按钮时,删除 indexList 中最后一个元素。

2. 要动态的更新当前列表 currentList

每进入新的一层,或返回上一层,都需要刷新 currentList 来实现页面的更新。知道下一层数据很容易,直接取被点击 itemchildren 赋值给 currentList 即可。

但如何还原上一层的数据呢?

第一点记录的 indexList 就发挥作用了,原始数据树为 originalList ,循环遍历 indexList ,根据索引依次取出每层的 currentList 直到 indexList 的最后一个元素,就得到了返回上一层需要显示的数据。

3. 每一次勾选或取消选中都要更新原始的数据树 originalList

页面是根据每个 itemchecked 属性判断是否选中的,所以每次改变勾选状态都要设置被改变的 itemchecked 属性,然后更新 originalList 。这样即使返回上一层了,再进到当前层级选中状态还会被保留,否则刷新 currentList 后已选状态将丢失。

4. 列表中选择状态的改变与底部 footer 的双联动

我们期望的效果是,选中 currentList 列表的某一项,底部 footer 会自动添加被选人的名字。取消选中,底部 footer 也会自动删除。

也可以通过 footer 来删除已选人,点击 footer 中人名,会将此人从已选列表中删除, currentList 列表中也会自动取消勾选状态。

嗯,这个功能比较耗性能,每一次都需要大量的计算。考虑到性能和速度因素,本次只做了从 footer 删除只更新 currentList 的勾选状态。

什么意思呢?假如有两层,A 和 B,B 是 A 的下一层数据,即 A 是 B 的父节点。在 A 中选中了一个部门 校长室 ,点击下一层到 B,在 B 中又选了两个人 张三李四 ,这时底部 footer 里显示的应该是三个: 校长室张三李四 。此时点击 footer张三footer 会把 张三 删除,中间列表中 张三 会被置为未选中状态,这没问题。但点击 footer校长室 , 在 footer 中是把 校长室 删除了,但再返回到上一层时,中间列表中的 校长室 依然是勾选状态,因为此时没有更新原始数据树 originalList 。如果觉得这是个 bug , 可以加个更新 originalList 的操作。这样就要遍历 originalList 的每个元素判断与本次删除的 id 是否相等,然后改变 checked 值,如果数据量很大,会非常慢。我做了妥协……

关键的逻辑就这四块了,当然还有很多小细节,直接看代码吧,注释写的也比较详细。

三、代码

目录结构:

小程序选人控件 - 仿企业微信实现多层级无规则嵌套

footer 文件夹下是抽离出的 footer 组件, userSelect 是选人控件的主要逻辑。把这几个文件复制过去就可以用了。

userSelect.js 里网络请求的代码替换为你的请求代码,注意数据的字段名是否一致。

userSelect 的代码

userSelect.js
import API from '../../../utils/API.js'
import ArrayUtils from '../../../utils/ArrayUtils.js'
import EventBus from '../../../components/NotificationCenter/WxNotificationCenter.js'

let TEACHER_ID = 'teacher';
let TEACHER_DEPARTMENT_ID = 't_department';
let TEACHER_SUBJECT_ID = 't_subject';
let TEACHER_GRADECLASS_ID = 't_gradeclass';
let STUDENT_ID = 'student';
let PARENT_ID = 'parent'

let TEACHER = {
  id: TEACHER_ID,
  name: '教师',
  parentId: '',
  checked: false,
  isPeople: false,
  children: [
    {
      id: TEACHER_DEPARTMENT_ID,
      name: '部门',
      parentId: 'teacher',
      checked: false,
      isPeople: false,
      children: []
    },
    {
      id: TEACHER_SUBJECT_ID,
      name: '学科',
      parentId: 'teacher',
      checked: false,
      isPeople: false,
      children: []
    },
    {
      id: TEACHER_GRADECLASS_ID,
      name: '年级班级',
      parentId: 'teacher',
      checked: false,
      isPeople: false,
      children: []
    },
  ]
}
let STUDENT = {
  id: STUDENT_ID,
  name: '学生',
  parentId: '',
  checked: false,
  isPeople: false,
  children: []
}
let PARENT = {
  id: PARENT_ID,
  name: '家长',
  parentId: '',
  checked: false,
  isPeople: false,
  children: []
}
let ORIGINAL_DATA = [
  TEACHER, STUDENT, PARENT
]

Page({
  data: {
    currentList: [], //当前展示的列表
    selectList: [],  //已选择的元素列表
    originalList: [], //最原始的数据列表
    indexList: [],  //存储目录层级的数组,用于准确的返回上一层
    selectList: [],  //已选中的人员列表
  },

  onLoad: function (options) {
    wx.setNavigationBarTitle({
      title: '选人控件'
    })
    this.init();
  },

  init(){
    //用户的单位id
    this.unitId = getApp().globalData.userInfo.unitId;
    //用户类型
    this.userType = 0;
    //上次选中的列表,用于判断是不是取消选中了
    this.lastTimeSelect = []

    this.setData({
      currentList: ORIGINAL_DATA, //当前展示的列表
      originalList: ORIGINAL_DATA, //最原始的数据列表
    })
  },

  clickItem(res){
    console.log(res)
    let index = res.currentTarget.id;
    let item = this.data.currentList[index]

    console.log("item", item)

    if (!item.isPeople) {
      //点击教师,下一层数据是写死的,不用请求接口
      if (item.id === TEACHER_ID) {
        this.userType = 2;
        this.setData({
          currentList: item.children
        })
      } else if (item.id === TEACHER_SUBJECT_ID) {
        if (item.children.length === 0){
          this._getTeacherSubjectData()
        }else{
          //children的长度不为0时,更新 currentList
          this.setData({
            currentList: item.children
          })
        }
      } else if (item.id === TEACHER_DEPARTMENT_ID) {
        if (item.children.length === 0) {
          this._getTeacherDepartmentData()
        } else {
          //children的长度不为0时,更新 currentList
          this.setData({
            currentList: item.children
          })
        }
      } else if (item.id === TEACHER_GRADECLASS_ID) {
        if (item.children.length === 0) {
          this._getTeacherGradeClassData()
        } else {
          //children的长度不为0时,更新 currentList
          this.setData({
            currentList: item.children
          })
        }
      } else if (item.id === STUDENT_ID) {
        this.userType = 1;
        if (item.children.length === 0) {
          this._getStudentGradeClassData()
        } else {
          //children的长度不为0时,更新 currentList
          this.setData({
            currentList: item.children
          })
        }
      } else if (item.id === PARENT_ID) {
        this.userType = 3;
        if (item.children.length === 0) {
          this._getParentGradeClassData()
        } else {
          //children的长度不为0时,更新 currentList
          this.setData({
            currentList: item.children
          })
        }
      } else{
        //children的长度为0时,请求服务器
        if(item.children.length === 0){
          this._getUserByGroup(item)
        }else{
          //children的长度不为0时,更新 currentList
          this.setData({
            currentList: item.children
          })
        }
      }

      //将当前的索引存入索引目录中。索引多一个表示目录多一级
      let indexes = this.data.indexList
      indexes.push(index)
      //是目录不是具体的用户
      this.setData({
        indexList: indexes
      })
      //清空上次选中的元素列表,并设置上一层的选中状态给lastTimeSelect
      this.setLastTimeSelectList();
    }
  },


  //返回按钮
  goBack() {
    let indexList = this.data.indexList
    if (indexList.length > 0) {
      //返回时删掉最后一个索引
      indexList.pop()
      if (indexList.length == 0) {
        //indexList长度为0说明回到了最顶层
        this.setData({
          currentList: this.data.originalList,
          indexList: indexList
        })
      } else {
        //循环将当前索引的对应数组赋值给currentList
        let list = this.data.originalList
        for (let i = 0; i < indexList.length; i++) {
          let index = indexList[i]
          list = list[index].children
        }
        this.setData({
          currentList: list,
          indexList: indexList
        })
      }
      //清空上次选中的元素列表,并设置上一层的选中状态给lastTimeSelect
      this.setLastTimeSelectList();
    }
  },

  //清空上次选中的元素列表,并设置上一层的选中状态给lastTimeSelect
  setLastTimeSelectList(){
    this.lastTimeSelect = []
    this.data.currentList.forEach(item => {
      if (item.checked) {
        this.lastTimeSelect.push(item)
      }
    })
  },

  //获取教师部门数据
  _getTeacherDepartmentData() {
    this._commonRequestMethod(2, 'department')
  },

  //请求教师的学科数据
  _getTeacherSubjectData(){
    this._commonRequestMethod(2, 'subject')
  },

  //请求教师的年级班级
  _getTeacherGradeClassData() {
    this._commonRequestMethod(2, 'gradeclass')
  },

  //请求学生的年级班级
  _getStudentGradeClassData() {
    this._commonRequestMethod(1, 'gradeclass')
  },

  //请求家长的年级班级
  _getParentGradeClassData() {
    this._commonRequestMethod(3, 'gradeclass')
  },

  //根据部门查询人
  _getUserByGroup(item){
    let params = {
      userType: this.userType,
      unitId: this.unitId,
      groupType: item.type,
      groupId: item.id
    }
    console.log('params', params)
    getApp().get(API.selectUserByGroup(), params, result => {
      console.log('result', result)
      let list = this.transformData(result.data.data, item.id)
      this.setData({
        currentList: list
      })
      this.addList2DataTree()
      //清空上次选中的元素列表,并设置上一层的选中状态给lastTimeSelect。写在这里防止异步请求时执行顺序问题
      this.setLastTimeSelectList();
    })
  },

  //通用的请求部门方法
  _commonRequestMethod(userType, groupType){
    wx.showLoading({
      title: '',
    })
    let params = {
      userType: userType,
      unitId: this.unitId,
      groupType: groupType
    }
    console.log('params', params)
    getApp().get(API.selectUsersByUserGroupsTree(), params, result => {
      console.log('result', result)
      wx.hideLoading()
      let data = result.data.data
      this.setData({
        currentList: data
      })
      this.addList2DataTree();
      //清空上次选中的元素列表,并设置上一层的选中状态给lastTimeSelect。写在这里防止异步请求时执行顺序问题
      this.setLastTimeSelectList();
    })
  },

  //将请求的数据转化为需要的格式
  transformData(list, parentId){
    //先将数据转化为固定的格式
    let newList = []
    for(let i=0; i<list.length; i++){
      let item = list[i]
      newList.push({
        id: item.id,
        name: item.realName,
        parentId: parentId,
        checked: false,
        isPeople: true,
        userType: item.userType,
        gender: item.gender,
        children: []
      })
    }
    return newList;
  },

  //将当前列表挂载在原数据树上, 目前支持5层目录,如需更多接着往下写就好
  addList2DataTree(){
    let currentList = this.data.currentList;
    let originalList = this.data.originalList;
    let indexes = this.data.indexList
    switch (indexes.length){
      case 1: 
        originalList[indexes[0]].children = currentList
        break;
      case 2:
        originalList[indexes[0]].children[indexes[1]].children = currentList
        break;
      case 3:
        originalList[indexes[0]].children[indexes[1]].children[indexes[2]].children = currentList
        break;
      case 4:
        originalList[indexes[0]].children[indexes[1]].children[indexes[2]].children[indexes[3]].children = currentList
        break;
      case 5:
        originalList[indexes[0]].children[indexes[1]].children[indexes[2]].children[indexes[3]].children[indexes[4]].children = currentList
        break;
    }

    this.setData({
      originalList: originalList
    })
    console.log("originalList", originalList)
  },

  //选框变化回调
  checkChange(res){
    console.log(res)
    let values = res.detail.value
    let selectItems = []
    //将值取出拼接成 id,name 格式
    values.forEach(value => {
      let arrs = value.split(",")
      selectItems.push({id: arrs[0], name: arrs[1]})
    })
    console.log("selectItems", selectItems)
    console.log("lastTimeSelect", this.lastTimeSelect)
    
    //将本次选择的与上次选择的比对,本次比上次多说明新增了,本次比上次少说明删除了,找出被删除的那条数据,在footer中也删除
    if (selectItems.length > this.lastTimeSelect.length){
      //将 selectList 与 selectItems 拼接并去重
      let newList = this.data.selectList.concat(selectItems)
      newList = ArrayUtils.checkRepeat(newList)
      this.setData({
        selectList: newList
      })
    }else{
      //找出取消勾选的item,从selectList中删除
      //比对出取消勾选的是哪个元素
      let diffItem = {}
      this.lastTimeSelect.forEach(item => {
        let flag = false;
        selectItems.forEach(item2 => {
          if(item.id === item2.id){
            flag = true
          }
        })
        if(!flag){
          diffItem = item
          console.log("diff=", item)
        }
      })
      //找出被删除的元素在 selectList 中的位置
      let list = this.data.selectList
      let delIndex = 0;
      for(let i=0; i<list.length; i++){
        if (list[i].id === diffItem.id){
          delIndex = i;
          break;
        }
      }
      //从list中删除这个元素
      list.splice(delIndex, 1)
      this.setData({
        selectList: list
      })
    }
    console.log("selectList", this.data.selectList)
    //更新 currentList 选中状态并重新挂载在数据树上,以保存选择状态
    this.updateCurrentList(this.data.currentList, this.data.selectList)
  },

  //footer点击删除回调
  footerDelete(res){
    console.log(res)
    this.setData({
      selectList: res.detail.selectList
    })

    console.log('selectList', this.data.selectList)
    this.updateCurrentList(this.data.currentList, res.detail.selectList)
  },

  //点击 footer 的确定按钮提交数据
  submitData(res){
    let selectList = this.data.selectList
    //通过 WxNotificationCenter 发送选择的结果通知
    EventBus.postNotificationName("SelectPeopleDone", selectList)
    //将选择结果存入 app.js 的 globalData
    getApp().globalData.selectPeopleList = selectList
    //返回
    wx.navigateBack({
      delta: 1
    })
    console.log("selectdone", selectList)
  },

  //更新 currentList 并将更新后的列表挂载在数据树上
  updateCurrentList(currentList, selectList){
    let newList = []
    currentList.forEach(item => {
      let flag = false;
      selectList.forEach(item2 => {
        if (item.id === item2.id) {
          flag = true
        }
      })
      if (flag) {
        item.checked = true
      } else {
        item.checked = false
      }
      newList.push(item)
    })
    this.setData({
      currentList: newList
    })
    this.addList2DataTree()
    this.setLastTimeSelectList()
  }
})
复制代码
userSelect.wxml
<view class='container'>
  <view class='btn-wrapper'>
    <button bindtap='goBack'>返回上一层</button>
  </view>

  <view class='people-wrapper'>
    <scroll-view scroll-y class='scrollview'>
      <checkbox-group bindchange="checkChange">
        <view class='item' wx:for='{{currentList}}' wx:key='{{item.id}}'>
          <checkbox checked='{{item.checked}}' value='{{item.id + "," + item.name}}'>
          </checkbox>
          <view id='{{index}}' class='item-content' bindtap='clickItem'>
            <image class='img' wx:if='{{!item.isPeople}}' src='../../../assets/file.png'></image>
            <image class='avatar' wx:if='{{item.isPeople}}' src='../../../assets/avatar.png'></image>
            <text class='itemtext'>{{item.name}}</text>
          </view>
        </view>
      </checkbox-group>
      <view class='no-data' wx:if='{{currentList.length===0}}'>暂无数据</view>
    </scroll-view>
  </view>
  <view class='footer'>
    <footer list='{{selectList}}' binddelete='footerDelete' bindsubmit="submitData"/>
  </view>
</view>
复制代码
userSelect.wxss
.container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 20rpx;
  overflow-x: hidden;
  box-sizing: border-box;
  background-color: #fff;
}

.btn-wrapper {
  width: 100%;
  padding: 0 20rpx;
  box-sizing: border-box;
}

.btn {
  font-size: 24rpx;
  width: 100%;
}

.people-wrapper {
  width: 100%;
  margin-top: 10rpx;
  margin-bottom: 100rpx;
}

.scrollview {
  width: 100%;
  display: flex;
  flex-direction: column;
}

.item {
  width: 100%;
  display: flex;
  flex-direction: row;
  align-items: center;
  padding: 30rpx 0;
  margin: 0 20rpx;
  border-bottom: 1rpx solid rgba(7, 17, 27, 0.1);
}

.item-content {
  width: 100%;
  display: flex;
  flex-direction: row;
  align-items: center;
  margin-left: 20rpx;
}

.itemtext {
  font-size: 36rpx;
  color: #333;
  margin-left: 20rpx;
  text-align: center;
}

.img {
  width: 50rpx;
  height: 40rpx;
}

.avatar {
  width: 50rpx;
  height: 50rpx;
}

.footer {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
}

.no-data{
  width: 100%;
  font-size: 32rpx;
  text-align: center;
  padding: 40rpx 0;
}
复制代码
userSelect.json
{
  "usingComponents": {
    "footer": "footer/footer"
  }
}
复制代码

footer 的代码

footer.js

Component({
  /**
   * 组件的属性列表
   */
  properties: {
    list: {
      type: Array
    }
  },

  /**
   * 组件的初始数据
   */
  data: {
    
  },

  /**
   * 组件的方法列表
   */
  methods: {
    delete(res){
      console.log(res)
      let index = res.currentTarget.id
      let list = this.data.list
      list.splice(index,1)
      this.setData({list: list})
      this.triggerEvent("delete", {selectList: list})
    },

    /**
     * 点击确定按钮
     */
    confirm(){
      this.triggerEvent("submit", "")
    }
  }
})
复制代码
footer.wxml
<view class='container'>
  <view class='scroll-wrapper'>
    <scroll-view scroll-x style='scroll'>
      <text id='{{index}}' class='text' wx:for='{{list}}' wx:key='{{index}}' bindtap='delete'>{{item.name}}</text>
    </scroll-view>
  </view>
  <text class='btn' bindtap='confirm'>确定</text>
</view>
复制代码
footer.wxss
.container {
  width: 100%;
  height: 100rpx;
  display: flex;
  flex-direction: row;
  padding: 20rpx;
  box-sizing: border-box;
  background-color: #fff;
  align-items: center;
  overflow-x: hidden;
  white-space: nowrap;
  border-top: 2rpx solid rgba(7, 17, 27, 0.1)
}

.scroll-wrapper {
  flex: 1;
  overflow-x: hidden;
  white-space: nowrap;
}

.scroll {
  width: 100%;

}

.text {
  font-size: 32rpx;
  color: #333;
  padding: 40rpx 20rpx;
  margin-right: 10rpx;
  background-color: #f5f5f5;
}

.btn {
  padding: 10rpx 20rpx;
  background-color: rgb(26, 173, 25);
  border-radius: 10rpx;
  font-size: 32rpx;
  color: #fff;
}
复制代码
footer.json
{
  "component": true,
  "usingComponents": {}
}
复制代码

再补一个用到的 ArrayUtils 的代码

export default{

  /**
     * 给数组去重
     */
  checkRepeat(list) {
    let noRepList = [list[0]]
    for (let i = 0; i < list.length; i++) {
      let repeat = false
      for (let j = 0; j < noRepList.length; j++) {
        if (noRepList[j].id === list[i].id) {
          repeat = true
          break
        }
      }
      if (!repeat) {
        noRepList.push(list[i])
      }
    }
    return noRepList
  },

  //删除list中id为 delId 的元素
  deleteItemById(list, delId){
    for (let i = 0; i < list.length; i++) {
      if (list[i].id == delId) {
        list.splice(i, 1)
        return list;
      }
    }
    return list;
  }

}
复制代码

由于时间紧张,还没有把这个控件单独从项目中抽出来写个 Demo,有时间了会给 github 地址的。

代码还有很多可以优化的地方,比如有几个方法太长了,不符合单一职责原则等等,不想改了,以后再优化吧。。

水平有限,各位大侠请轻喷~

有问题或发现 Bug 请在评论区留言,毕竟刚写完就分享出来了,还没经过严格的测试。不过应该没什么大的问题。。。有些细节可能没注意到。


以上所述就是小编给大家介绍的《小程序选人控件 - 仿企业微信实现多层级无规则嵌套》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Designing Web Navigation

Designing Web Navigation

James Kalbach / O'Reilly Media / 2007-8-15 / USD 49.99

Thoroughly rewritten for today's web environment, this bestselling book offers a fresh look at a fundamental topic of web site development: navigation design. Amid all the changes to the Web in the pa......一起来看看 《Designing Web Navigation》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具