vue+vuex+axios 仿原生app切换效果和路由缓存实践

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

内容简介:之前一直在写微信小程序,想着实验下能不能把小程序的页面切换和缓存效果用到vue项目上来,所以做这个demo来实践下 并且重新熟悉下vue全家桶的使用。写的这个webapp页面滑动效果主要使用了better-scroll 滑动插件, 插件的作者(黄轶老师)在慕课上有一套相关的高级教程,教程地址,教程展示 本例参考了其中风格颜色和部分布局,毕竟重新设计一套UI是很费时间的事情。Demo实现了,仿微信页面切换效果和类似

之前一直在写微信小程序,想着实验下能不能把小程序的页面切换和缓存效果用到vue项目上来,所以做这个demo来实践下 并且重新熟悉下vue全家桶的使用。

写的这个webapp页面滑动效果主要使用了better-scroll 滑动插件, 插件的作者(黄轶老师)在慕课上有一套相关的高级教程,教程地址,教程展示 本例参考了其中风格颜色和部分布局,毕竟重新设计一套UI是很费时间的事情。

Demo实现了,仿微信页面切换效果和类似 keep-alive 的缓存页面机制(前进加载,后退缓存,tabBar页面缓存、下拉刷新、上拉加载等)。并且在 ios safari 浏览器中可以模仿原生 app 的页面前进后退体验(边缘右滑返回功能)。

音乐Demo展示地址

顺便把这个想法做成了一个插件 vue-app-effect 传送门

如果觉得有用的话,记得点个 star 第一次发文,写的不好请见谅。

数据获取

调用的是qq音乐的数据接口,因为很多接口都需要代理请求头才能请求到,所以直接就采用 node+express 搭建了一个代理服务器。因为小程序只能采用 https 的请求,然后花了点时间升级协议。

代理服务搭建

目录结构

├── assets          
│   └── axios.js    // 对 axios 进行 二次封装
│   └── c.y.qq.js   // 接口配置文件
│   └── u.y.qq.js   // 接口配置文件
│   └── config.js   // 公共参数配置文件
├── utils
│   ├── handle.js   // 数据处理文件
│   └── lyric.js    // 歌词处理文件
└── api.js          // 请求处理文件
│- server.js        // 服务器启动文件
复制代码

简单的搭建一个代理服务器,并且实验了一下守护进程和子进程

PS: 子进程的个数取决于电脑cpu的核心数。

server.js

// 引入库
const express = require('express')
const os = require('os')
const cluster = require('cluster')
const process = require('process')
const compression = require('compression')
// 引入api文件
const api = require('./api')
// 先得到系统cpu的核心数量
let cpu = os.cpus()
// 先判断是否有主进程
if (cluster.isMaster) {
  for (let i = 0; i < cpu.length; i++) {
    cluster.fork()
  }
  console.log('主进程端口号:3000')
} else {
  let server = express()
  server.listen(3000)
  console.log('子进程ID: ', process.pid);
  // gzip 压缩
  server.use(compression())
  server.use('/api', api)
}
复制代码

因为qq把数据接口分为了 https://u.y.qq.com/ https://c.y.qq.com/ 针对不同的域名做不同的请求配置。

在本地访问的时候出现跨域问题,顺便简单的处理了下跨域。

api.js

// 引入库
const express = require('express')
// 引入文件
const axios = require('./assets/axios')
// 创建路由
let api = express.Router()
// 允许跨域
api.all('*', function(req, res, next) {
  res.header("Access-Control-Allow-Origin", "*");
  res.header("Access-Control-Allow-Headers", "X-Requested-With");
  res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
  res.header("X-Powered-By",' 3.2.1')
  res.header("Content-Type", "application/json;charset=utf-8");
  next();
});
api.get('/', (req, res) => {
  res.send('请添加需要访问的路由地址' + req.query)
})
// 请求处理
api.get(`/c_common_url`, (req, res) => axios('c',req,res));
api.get(`/u_common_url`, (req, res) => axios('u',req,res));
// 导出路由
module.exports = api;
复制代码

接口请求方式 因为参数传递的方式不同,直接用get方法参数传递的方式进行对应数据请求。

https://www.host.com/api/c_common_url?api=homeMvList
https://www.host.com/api/u_common_url?api=homeFocusImage
复制代码

具体的数据请求处理返回就不再赘述。

前端之前

做这个的目的是想实现类似于微信小程序页面切换和页面的缓存效果,媒体播放的一些逻辑处理流程。

技术栈:主要是基于 Vue 全家桶,Vuex + axios +stylus +better-scroll

构建工具:直接偷懒使用的 vue-cli

初始化:一路往下初始化项目之后启动项目,过程都是比较基础的就不在赘述。

仿小程序页面切换的实现

想法:在vux 的 demo 展示中是已经实现了这种仿小程序页面切换的效果,所以就用现有的基于 vux 的代码进行一下提取,来给自己的domo实现这种切换的效果。

思路:

  1. vux 的源码中是通过 vuexstore 中注册一个模块进行路由切换是前进还是后退的状态管理来动态的给 <transition> 组件添加一个css的过度效果。

  2. 还需要一个存储器来存储当前点击加载路由历史记录,通过历史记录的值来判断当前是前进还是返回,从而实现仿微信页面的切换效果。

注册一个 vuex store模块

// 引入store 和 router
import router from './router'
import store from './store/index' 
// 注册一个 NAV_DIRECTION 模块专门用于切换状态管理
store.registerModule('NAV_DIRECTION', {
  state: {
    // 维护的数据 默认值为 forward
    direction: 'forward'
  },
  mutations: {
    // 提交的方法
    'NAV_DIRECTION_UPDATE' (state, payload) {
      state.direction = payload.direction
    }
  }
})
复制代码

采用 sessionStorage 来记录路由历史

PS: 用 sessionStorage 是因为这个和 localStorage 区别在于关掉页面就会自动清除里面的内容,比较方便

创建了最基本路由历史保存结构,大部分代码是处理ios右滑返回判断的

// ------------- 下面的代码参考于 vux 进行了部分修改 --------------------
// 初始化的时候清空一下确保里面是真的空
window.sessionStorage.clear()
// 设置根路由的值为0
window.sessionStorage.setItem('count', 0)
// 设置一个公共页面的值为最高级 因为在任何路由下都要能打开播放器组件
common && window.sessionStorage.setItem(common, 99999)
// 监听一个滑动结束用时间节点来做为标志判断是否是ios右滑返回
let endTime = Date.now()
document.addEventListener('touchend', () => {
  endTime = Date.now()
})
// 这里同样用来判断是否是ios右滑返回
let isPush = false
let methods = ['push', 'go', 'replace', 'forward', 'back']
methods.forEach(key => {
  let method = router[key].bind(router)
  router[key] = function (...args) {
    isPush = true
    method.apply(null, args)
  }
})
复制代码

全局路由守卫来获取路由

路由守卫分为全局守卫 和独享守卫 和组件守卫

这里采用的是全局路由守卫的两个方法 router.beforeEach() router.afterEach

// ------------- 下面的代码参考于 vux 进行了部分修改 --------------------
// 下面菜单栏的四个路由需要手动配置
const tabbar = ['/bar1', '/bar2', '/bar3', '/bar4']
// 路由进入之前
router.beforeEach(function (to, from, next) {
  // 获取保存的路由对应索引值
  const toIndex = Number(history.getItem(to.path))
  const fromIndex = Number(history.getItem(from.path))
  // 进入新路由 判断是否为tabBar
  let find = tabbar.findIndex(item => item === to.path)
  // 去向不是tabBar
  if (find === -1) {
    // 当去的路由在历史记录里面的时候判断是否为返回
    if (toIndex) {
      // 去的索引值比来的索引大判断为不是返回提交动作前进
      if (toIndex > fromIndex) {
        bus.$emit('forward', 'forward') // 这里bus的用法参考下面的页面缓存的实现
        store.commit('NAV_DIRECTION_UPDATE', {direction: 'forward'})
      } else {
        // 判断是否是ios左滑返回 这里为什么是 377 只能问 vux 作者了
        if (!isPush && (Date.now() - endTime) < 377) {
          // 提交的数据不是前进也不是返回是一个空用于特殊判断页面返回是否加载动态效果
          bus.$emit('reverse', '')
          store.commit('NAV_DIRECTION_UPDATE', {direction: ''})
        } else {
          bus.$emit('reverse', 'reverse')
          store.commit('NAV_DIRECTION_UPDATE', { direction: 'reverse' })
        }
      }
    // 当去的路由不在历史记录里面的时候判断为前进
    } else {
      // 当前历史记录值+1
      let count = ++window.sessionStorage.count
      // 设置当前进入的路由索引为当前自增的索引值
      window.sessionStorage.setItem('count', count)
      window.sessionStorage.setItem(to.path, count)
      bus.$emit('forward', 'forward')
      store.commit('NAV_DIRECTION_UPDATE', {direction: 'forward'})
    }
    // 判断是不是外链
    if (/\/http/.test(to.path)) {
      let url = to.path.split('http')[1]
      window.location.href = `http${url}`
    } else {
      next()
    }
  } else {
    // 判断是否是ios右滑返回
    if (!isPush && (Date.now() - endTime) < 377) {
      bus.$emit('reverse', '')
      store.commit('NAV_DIRECTION_UPDATE', {direction: ''})
    } else {
      bus.$emit('reverse', 'reverse')
      store.commit('NAV_DIRECTION_UPDATE', { direction: 'reverse' })
    }
    next()
  }
})
// 重置ios右滑判断
router.afterEach(function () {
  isPush = false
})
复制代码

路由的结构

这样的切换效果采用的路由结构也是有所区别,将tab菜单下的路由都放在根路由的子路由下面。其他要跳转的页面都是属于和根路由同级的一级路由,这样方便后面做动态路由和页面的缓存效果,而且不会影响到导航页面的静态切换效果。

routes: [{
  path: '/',
  component: bar1,
  redirect: '/bar1',
  children: [ {
    path: '/bar1',
    name: '/bar1',
    component: bar1
  }, {
    path: '/bar2',
    name: '/bar2',
    component: bar2
  }, {
    path: '/bar3',
    name: '/bar3',
    component: bar3
  }, {
    path: '/bar4',
    name: '/bar4',
    component: bar4
  }]
}, {
  path: '/player',
  name: '/player',
  component: player
}, {
  path: '/detail1',
  name: '/detail1',
  component: detail1
}, {
  path: '/detail2',
  name: '/detail2',
  component: detail2
}]
复制代码

动态样式的加载

在App.vue 中 css 采用的是vux中的切换样式,改了下切换幅度为70%

<!-- 下面的代码来源于 vux -->
<template>
  <div>
    <transition :name="viewTransition" :css="!!direction"> 
      <router-view class="router-view"></router-view>
    </transition> 
  </div>
</template>
复制代码
import { mapGetters } from 'vuex'

export default {
  computed: {
    ...mapGetters(['direction']),
    viewTransition () {
      if (!this.direction) return ''
      return 'vux-pop-' + (this.direction === 'forward' ? 'in' : 'out')
    }
  }
}
复制代码
.router-view{
  width: 100%;
  height:100%;
}
/*  下面的代码来源于 vux */
.vux-pop-out-enter-active,
.vux-pop-out-leave-active,
.vux-pop-in-enter-active,
.vux-pop-in-leave-active {
  will-change: transform;
  transition: all 500ms cubic-bezier(0.075, 0.82, 0.165, 1)
  height: 100%;
  top: 0;
  position: absolute;
  backface-visibility: hidden;
  perspective: 1000;
}
.vux-pop-out-enter {
  opacity: 0;
  transform: translate3d(-70%, 0, 0);
}
.vux-pop-out-leave-active {
  opacity: 0;
  transform: translate3d(70%, 0, 0);
}
.vux-pop-in-enter {
  opacity: 0;
  transform: translate3d(70%, 0, 0);
}
.vux-pop-in-leave-active {
  opacity: 0;
  transform: translate3d(-70%, 0, 0);
}
复制代码

页面按需缓存的实现

缓存是为了提高用户的体验度,减少不必要的请求次数,降低服务器的压力,这里就模拟一下类似小程序的前进加载,返回缓存,导航页面可一直缓存的状态。

首先将导航页面的数据进行缓存

这里简单粗暴一点直接使用 keep-alive 组件进行缓存 因为导航在根路由的子路由下,所以只要在根路由的子路由下添加 keep-alive 就能达到这个效果。 这里使用了一个空组件,做为容器。

<template>
  <div>
    <keep-alive>
      <router-view class="tab-router-view"></router-view>
    </keep-alive>
  </div>
</template>
复制代码

接下来解决前进加载,后退缓存的状态。

这里也有一个现成的插件是实现同样的功能 vue-navigation ,但是实现的效果并不是想要的那种,然后只能想着重新写一个来实现。

抽象组件

这个东西的看起来跟组件一样是一对标签,但是它不会渲染出实际的 dom 常用的有两个 <keep-alive> <transition> 内部具体样子大概是这样的

name: '',
abstract: true,
props: {},
data() {
  return {}
},
computed: {},
methods: {},
created () {},
destroyed () {},
render () {}
复制代码

抽象组件也有生命周期函数 但是没有html部分和css部分,而且有一个 render() 方法, 这个方法主要是返回一个处理结果。

VNode基类

关于这个看可以看这篇文章 VNode基类

创建一个抽象组件

将组件单独成一个文件,然后再建立一个index文件

├── abstract          
│   └── index.js      // 入口安装文件
│   └── abstract.js   // 组件文件
复制代码

先建立 index.js

import Abstract from './abstract'
export default {
  install: (Vue, {router, store, tabbar, common } = {}) => {
  // 这里需要用到bus发送事件 结合上面的实现切换效果的bus发送事件
  // 这个创建应该是在上面的之前
  const bus = new Vue()
  // 这里应该是效果实现的代码
  // .....
  // .....
  // .....
  Vue.component('Abstract', Abstract(bus,tabbar))
  Vue.Abstract = Vue.prototype.$abstract = {
    // 添加一个事件监听处理方法并且将参数传递出去
    on: (event, callback) => {
      bus.$on(event, callback)
    }
  }
}
复制代码

然后实现 abstract.js

export default (bus,tabbar) => {
  return {
    name: 'abstract',
    abstract: true,
    props: {},
    data: () {
      return {
        routerLen: 0,
        // 导航路由数组
        tabBar: tabbar,
        // 当前状态前进还是返回
        direction: '',
        // 被检测的
        router: {},
        // 当前跳转
        to: {},
        // 上一个路由
        from: {},
        // 记录路由步骤数组
        paths: []
      }
    },
    computed: {},
    watch: {
      // 检测路由的变化,记录上一个和当前路由并保存路由的全路径做为标识。
      router (to, from) {
        this.to = to
        this.from = from
        let findTo = this.tabBar.findIndex(item => item === this.$route.fullPath)
        if (findTo === -1) {
          // 不是tabbar就保存下来
          this.paths.push(to.fullPath)
          // 去重
          this.paths = [...new Set(this.paths)]
        }
      }
    },
    created () {
      // 保存缓存
      this.cache = {}
      this.routerLen = this.$router.options.routes.length
      // 保存router
      this.router = this.$route
      this.to = this.$route
      // 监听返回事件
      bus.$on('reverse', () => {
        this.direction = 'reverse'
        this.reverse()
      })
      // 监听前进事件
      bus.$on('forward', () => {
        this.direction = 'forward'
      })
    },
    destroyed () {
      // 组件被销毁清除所有缓存
      for (const key in this.cache) {
        const vnode = this.cache[key]
        vnode && vnode.componentInstance.$destroy()
      }
    },
    methods: {
      // 返回操作的时候清除上一个路由的缓存
      reverse () {
        let beforePath = this.paths.pop()
        let findTo = this.tabBar.findIndex(item => item === this.$route.fullPath)
        let findRouterIndex = routes.findIndex(item => item.path === beforePath)
        // 当不是导航路由,并且不是默认配置路由
        if (findTo === -1 && findRouterIndex >= this.routerLen) {
          // 删除路由
          this.$router.options.routes.pop()
          // 清除对应历史记录
          delete window.sessionStorage[beforePath]
          window.sessionStorage.count -= 1
        }
        // 当不是导航的时候 删除上一个缓存
        let key = findTo === -1 ? this.$route.fullPath : ''
        if (this.cache[key]) {
          this.cache[beforePath].componentInstance.$destroy()
          delete this.cache[beforePath]
        }
      }
    },
    render () {
      // 保存路由对象
      this.router = this.$route
      // 得到 vnode
      const vnode = this.$slots.default ? this.$slots.default[0] : null
      // 如果 vnode 存在
      if (vnode) {
        // tabbar判断如果是 直接保存/tab-bar
        let findTo = this.tabBar.findIndex(item => item === this.$route.fullPath)
        let key = findTo === -1 ? this.$route.fullPath : '/tab-bar'
        // 判断是否缓存过了
        if (this.cache[key]) {
          vnode.componentInstance = this.cache[key].componentInstance
        } else {
          this.cache[key] = vnode
        }
        vnode.data.keepAlive = true
      }
      return vnode
    }
  }
}
复制代码

使用

import Abstract from '@/assets/js/abstract/index'
Vue.use(Abstract, {
  router,
  store,
  tabbar: ['/bar1', '/bar2', '/bar3', '/bar4', '/bar5'],
  common: '/player'
})
复制代码

虽然这样基本上已经差不多实现了想要的效果,但是还有一个问题。

场景:在App中商品的详情页是可以重复进行打开的,而在这里却不行,原因是因为处于同一个路由下,再次点击打开路由没有进行跳转,也想到过用 /:id 的方式,实验了一下,在本页打开页面没有加载。 所以采用动态注册路由的方式进行路由跳转实现,不同路由重复打开同一个组件加载不同数据

动态注册路由并跳转

这里直接给出代码,值得说明的是跳转路由那块推荐使用 params 传参方式,不然在保存 this.$route.fullPath 的时候就太长了,而且路由的fullPath 最好以一个唯一标识进行区分,例如id属性。

// 需要继承的路由添加到Router 原型
import Router from 'vue-router'
import Detail from '@/components/Detail/index'
Router.prototype.extends = {
  Detail
}
复制代码

组件继承是为了能配合路由在组件中自己打开自己。

// 创建一个新路由
let newPath = `/singer/${mid}`
let newRoute = [{
  path: newPath,
  name: newPath,
  component: {extends: this.$router.extends.Detail}
}]
// 判断路由是否存在 不存在 添加一个新路由
let find = this.$router.options.routes.findIndex(item => item.path === newPath)
if (find === -1) {
  this.$router.options.routes.push(newRoute[0])
  this.$router.addRoutes(newRoute)
}
// 存在直接跳转到路由
this.$router.push({
  name: newPath,
  params: { mid: mid, name: name }
})
复制代码

到这里基本上需要实现的功能就差不多实现了。

踩的一些小坑

问题1 【配置 stylus 】

因为个人比较喜欢这个css预处理器的极简风格 {} : ; 全可以省略 所以很强迫症的一定要装上。

$ npm install stylus stylus-loader -D
复制代码

安装到开发环境之后,因为之前没有使用 vue-cli 构建项目, 直接自己写的配置文件启动项目,所以并没有注意到关于预处理的一些配置,在 utils.js 中已经对 stylussass , less 进行了相关配置。

return {
  css: generateLoaders(),
  postcss: generateLoaders(),
  less: generateLoaders('less'),
  sass: generateLoaders('sass', { indentedSyntax: true }),
  scss: generateLoaders('sass'),
  stylus: generateLoaders('stylus'),
  styl: generateLoaders('stylus')
}
复制代码

解决方法: 不需要在 webpack.base.conf.js 中配置,配置后会导致解析错误。

{
  test: /\.styl$/,
  use: [
    'vue-style-loader',
    'css-loader',
    {
      loader: 'postcss-loader',
      options: {
        sourceMap: true
      }
    },
    'stylus-loader'
  ]
}
复制代码

问题2 【@路径的修改】

场景:有时候需要添加一些其他的路径变量 如想使用以下方式直接引入文件

require('assets/css/css.styl') 
require('components/Header/Header.vue') 
require('js/base/js.js') 
复制代码

vue-cli 中默认的 路径变量 @ 表示了 当前的 src 目录

webpack.base.conf.js 中 可以修改以下代码添加对应的访问路径

resolve: {
  extensions: ['.js', '.vue', '.json'],
  alias: {
    'vue$': 'vue/dist/vue.esm.js',
    '@': resolve('src'),
    'src': resolve('src'),
    'assets': resolve('src/assets'),
    'js': resolve('src/assets/js')
  }
},
复制代码

问题3 【挂载vue原型方法】

场景:在使用 axios 的时候 请求 api公共配置文件 每次都需要引入, 这样很繁琐也不易于修改。 这个时候就需要把这些方法挂载到 vue 原型中,每次调用的时候 直接使用 this.$xxx.xx 去调用。

解决方法一: 在入口文件 main.js 给原型添加方法

const config = require('@/assets/js');
const api = require('@/assets/api');
Vue.prototype.$config = config;
Vue.prototype.$api = api
复制代码

解决方法二: 比较优雅的使用 Vue.use()

export default {
  install: Vue => {
    Vue.prototype.X = {
      path: {},
      api: {list: `list`},
      song: song => song.id
    }
  }
}
复制代码
import config from '@/assets/js/config'
Vue.use(config)
复制代码

调用

this.X.api.list
复制代码

问题4 【图片懒加载】

场景:在加载图片的时候当图片尚未加载完成,或者图片请求失败,会显示比较难看的框线,为了优化显示,需要在加载前和加载失败后显示一张默认的图片来代替,避免显示效果问题

这里使用的是 vue-lazyload 插件

npm install vue-lazyload -S 
复制代码
import lazyload from 'vue-lazyload'
Vue.use(lazyload)
复制代码

使用: 用指令 v-lazy 替代 :src

<img class='img' v-lazy='{src:pic, error:errorImg, loading:defaultImg}'>
复制代码
data() {
  return {
    errorImg: require('@/assets/images/error.png'),
    defaultImg: require('@/assets/images/default.png')
  }
}
复制代码

当然也可以在data 中定义一个对象来充当参数

<img class='img' v-lazy='lazyData'>
复制代码
data() {
  return {
    lazyData: {
      src: this.pic
      error: require('@/assets/images/error.png'),
      loading: require('@/assets/images/default.png')
    }
  }
}
复制代码

问题5 【自适应图片尺寸】

场景:在获得图片后,每个图片的高度有大概2-3px的差别,这使得 在宽度固定,高度自适应的时候会产生细微的差别,导致在做上拉加载更多的时候,无法正确的获得 scrollTopclient.height 的值,使得上拉加载无效。

解决办法:媒体查询固定图片高度。(暂时想不到更好的办法)

先在浏览器中取得每个屏幕尺寸下的图片高度,添加到媒体查询中。

@media screen and (max-width:480px)
  .img
    height 108px
@media screen and (max-width:375px)
  .img
    height 97px
@media screen and (max-width:320px)
  .img
    height 82px
复制代码

问题6 【单行文本溢出显示...】

这个是css布局问题,我写出一个自己认为比较简单通用的处理方法

CSS 样式 父级 flex 布局 自适应宽度

.songname
  font-size 12px
  line-height 24px
  height 24px
.text-line
  position relative
  .pix
    position absolute
    left 0
    top 0
    right 0
    bottom 0
    overflow hidden
    text-overflow ellipsis
    white-space nowrap
    word-wrap normal
复制代码

template 模板

<div class="songname text-line">
  <div class="pix">{{item.songname}}</div>
</div>
复制代码

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

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

创业无畏

创业无畏

彼得· 戴曼迪斯、史蒂芬· 科特勒 / 贾拥民 / 浙江人民出版社 / 2015-8 / 69.90元

 您是否有最大胆的商业梦想?您是否想把一个好主意快速转化为一家市值几百亿甚至几千亿元的公司?《创业无畏》不仅分享了成功创业家的真知灼见,更为我们绘制了一幅激情创业的行动路线图!  创业缺人手怎么办?如何解决钱的问题?把握指数型大众工具,互联网就是你车间,你的仓库。拥有好的创意,自然有人把钱“白白地送给你用”。当你大海捞针的时候,激励性大奖赛会让针自己跑到你的眼前来!  掌握指数级......一起来看看 《创业无畏》 这本书的介绍吧!

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

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

HEX CMYK 互转工具