GithubHelp home page GithubHelp logo

zhcxk1998 / school-partners Goto Github PK

View Code? Open in Web Editor NEW
318.0 318.0 79.0 9.34 MB

一个为学生提供在线学习、做题PK、资料查阅、聊天交流等功能的学习小程序以及配套的后台管理系统

JavaScript 18.31% TypeScript 64.33% HTML 0.37% SCSS 17.00%

school-partners's Introduction

Anurag's github stats Top Langs

school-partners's People

Contributors

zhcxk1998 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

school-partners's Issues

调试

只能实现本机调试,手机端扫码为什么会出现资源加载不出来的情况呢

调试

只能实现本机调试,手机端扫码真机调试为什么会出现“资源加载失败,请重试”的情况呢

编译npm run dev:weapp 卡在了"监听文件修改中..."

win10 21H2下, nodejs使用14.1,python使用2.7,tarojs使用2.2.15,编译微信小程序端的时候都挺顺利,就是卡在了“监听文件修改中..."这一步,没有任何的输出,在资源管理器当中看nodejs是有占用cpu的。不过等了很久都没动静

为什么我这里编译失败

运行npm run dev:weapp
出现多个Cannot set property 'filename' of undefined报错
最后指向 ./src/client/app.tsx这个文件
请问这里怎么解决呢

图片存储方式

请问图片以及视频是怎样存储的, 可以发一下小程序使用的图片吗

React + TypeScript + Node.JS巧妙的实现一个后台管理系统的一些经验亮点

目前因学业任务比较重,没有好好的完善,现在比较完善的只有题库管理,新增题库,修改题库以及登录的功能,但搭配小程序使用,主体功能已经实现了

此后台系统是为了搭配我的另一个项目 School-Partners学习伴侣微信小程序而开发的。是一个采用Taro多端框架开发的跨平台的小程序。感兴趣的可以看一下之前的文章

这篇文章主要是分享一下在开发这个东东的时候,遇到的一些问题,以及一些技术的巧妙的方法分享给大家,如果对大家有帮助的话,请给我点赞一下给个star鼓励一下~无比感谢嘿嘿

希望大佬们走过路过可以给个star鼓励一下感激不尽

https://github.com/zhcxk1998/School-Partners

这个是小程序的介绍文章
这是配套的小程序介绍文章,使劲戳!

无图无真相!先上几个图~

运行截图

1. 登录界面

2. 题库管理

3. 修改题库

技术分析

就来说一下项目中自己推敲做出来的几个算是亮点的东西吧

1. 使用Hook封装API访问工具

本项目采用的UI框架是Ant-Design框架
因为这个项目的后台对于表格有着比较大的需求,而表格加载就需要使用到Loading的状态,所以就特地封装一下便于之后使用

首先我们先新建一个文件useService.ts
然后我们先引入axios来作为我们的api访问工具

import axios from 'axios'

const instance = axios.create({
  baseURL: '/api',
  timeout: 10000,
  headers: {
    'Content-Type': "application/json;charset=utf-8",
  },
})

instance.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.common['Authorization'] = token;
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

instance.interceptors.response.use(
  res => {
    let { data, status } = res
    if (status === 200) {
      return data
    }
    return Promise.reject(data)
  },
  error => {
    const { response: { status } } = error
    switch (status) {
      case 401:
        localStorage.removeItem('token')
        window.location.href = './#/login'
        break;
      case 504:
        message.error('代理请求失败')
    }
    return Promise.reject(error)
  }
)

先将axios的拦截器,基本配置这些写好先

接着我们实现一个获取接口信息的方法useServiceCallback

const useServiceCallback = (fetchConfig: FetchConfig) => {
  // 定义状态,包括返回信息,错误信息,加载状态等
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [response, setResponse] = useState<any>(null)
  const [error, setError] = useState<any>(null)
  const { url, method, params = {}, config = {} } = fetchConfig

  const callback = useCallback(
    () => {
      setIsLoading(true)
      setError(null)
      // 调用axios来进行接口访问,并且将传来的参数传进去
      instance(url, {
        method,
        data: params,
        ...config
      })
        .then((response: any) => {
          // 获取成功后,则将loading状态恢复,并且设置返回信息
          setIsLoading(false)
          setResponse(Object.assign({}, response))
        })
        .catch((error: any) => {
          const { response: { data } } = error
          const { data: { msg } } = data
          message.error(msg)
          setIsLoading(false)
          setError(Object.assign({}, error))
        })
    }, [fetchConfig]
  )

  return [callback, { isLoading, error, response }] as const
}

这样就完成了主体部分了,可以利用这个hook来进行接口访问,接下来我们再做一点小工作

const useService = (fetchConfig: FetchConfig) => {
  const preParams = useRef({})
  const [callback, { isLoading, error, response }] = useServiceCallback(fetchConfig)

  useEffect(() => {
    if (preParams.current !== fetchConfig && fetchConfig.url !== '') {
      preParams.current = fetchConfig
      callback()
    }
  })

  return { isLoading, error, response }
}

export default useService

我们定义一个useService的方法,我们通过定义一个useRef来判断前后传过来的参数是否一致,如果不一样且接口访问配置信息的url不为空就可以开始调用useServiceCallback方法来进行接口访问了

具体使用如下:

我们先在组件内render外使用这个钩子,并且定义好返回的信息
接口返回体如下

const { isLoading = false, response } = useService(fetchConfig)
const { data = {} } = response || {}
const { exerciseList = [], total: totalPage = 0 } = data

因为我们这个hook是依赖fetchConfig这个对象的,这里是他的类型

export interface FetchConfig {
  url: string,
  method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  params?: object,
  config?: object
}

所以我们只需要再页面加载时候调用useEffect来进行更新这个fetchConfig就可以触发这个获取数据的hook啦

  const [fetchConfig, setFetchConfig] = useState<FetchConfig>({
    url: '', method: 'GET', params: {}, config: {}
  })
  
  ...
  
  useEffect(() => {
    const fetchConfig: FetchConfig = {
      url: '/exercises',
      method: 'GET',
      params: {},
      config: {}
    }
    setFetchConfig(Object.assign({}, fetchConfig))
  }, [fetchFlag])

这样就大功告成啦!然后我们再到表格组件内传入相关数据就可以啦

<Table
          rowSelection={rowSelection}
          dataSource={exerciseList}
          columns={columns}
          rowKey="exerciseId"
          scroll={{
            y: "calc(100vh - 300px)"
          }}
          loading={{
            spinning: isLoading,
            tip: "加载中...",
            size: "large"
          }}
          pagination={{
            pageSize: 10,
            total: totalPage,
            current: currentPage,
            onChange: (pageNo) => setCurrentPage(pageNo)
          }}
          locale={{
            emptyText: <Empty
              image={Empty.PRESENTED_IMAGE_SIMPLE}
              description="暂无数据" />
          }}
        />

大功告成!!

2. 实现懒加载通用组件

我们这里使用的是react-loadable这个组件,挺好用的嘿嘿,搭配nprogress来进行过渡处理,具体效果参照github网站上的加载效果

我们先封装好一个组件,在components/LoadableComponent内定义如下内容

import React, { useEffect, FC } from 'react'
import Loadable from 'react-loadable'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

const LoadingPage: FC = () => {
  useEffect(() => {
    NProgress.start()
    return () => {
      NProgress.done()
    }
  }, [])
  return (
    <div className="load-component" />
  )
}

const LoadableComponent = (component: () => Promise<any>) => Loadable({
  loader: component,
  loading: () => <LoadingPage />,
})

export default LoadableComponent

我们先定义好一个组件LoadingPage这个是我们再加载中的时候需要展示的页面,在useEffect中使用nprogress的加载条进行显示,组件卸载时候则结束,而下面的div则可以由用户自己定义需要展示的样式效果

下面的LoadableCompoennt就是我们这个的主体,我们需要获取到一个组件,赋值给loader,具体的赋值方法如下,我们可以在项目内的pages部分将所有需要展示的页面引入进来,再导出,这样就可以方便的实现所有页面的懒加载了

// 引入刚刚定义的懒加载组件
import { LoadableComponent } from '@/admin/components'

// 定义组件,传给LoadableCompoennt组件需要的组件信息
const Login = LoadableComponent(() => import('./Login'))
const Register = LoadableComponent(() => import('./Register'))
const Index = LoadableComponent(() => import('./Index/index'))
const ExerciseList = LoadableComponent(() => import('./ExerciseList'))
const ExercisePublish = LoadableComponent(() => import('./ExercisePublish'))
const ExerciseModify = LoadableComponent(() => import('./ExerciseModify'))

// 导出,到时候再从这个pages/index.ts中引入,即可拥有懒加载效果了
export {
  Login,
  Register,
  Index,
  ExerciseList,
  ExercisePublish,
  ExerciseModify
}

大功告成!!!

3. 使用嵌套路由

项目因为涉及到后台信息的管理,所以个人认为导航栏与主题信息栏应该一同显示,如同下图

这样可以清晰的展示出信息以及给用户提供导航效果

我们现在项目的routes/index.tsx定义一个全局通用的路由组件

import React from 'react'
import {
  Switch, Redirect, Route,
} from 'react-router-dom'
// 这个是私有路由,下面会提到
import PrivateRoute from '../components/PrivateRoute'
import { Login, Register } from '../pages'
import Main from '../components/Main/index'

const Routes = () => (
  <Switch>
    <Route exact path="/login" component={Login} />
    <Route exact path="/register" component={Register} />
    <PrivateRoute component={Main} path="/admin" />

    <Redirect exact from="/" to="/admin" />
  </Switch>
)

export default Routes

这里的意思就是,登录以及注册页面是独立开来的,而Main这个组件就是负责包裹导航条以及内容部分的组件啦

接下来看看components/Main中的内容吧

import React, { ComponentType } from 'react'
import { Layout } from 'antd';

import HeaderNav from '../HeaderNav'
import ContentMain from '../ContentMain'
import SiderNav from '../SiderNav'

import './index.scss'

const Main = () => (
  <Layout className="index__container">
    // 头部导航栏
    <HeaderNav />
    <Layout>
      // 侧边栏
      <SiderNav />
      <Layout>
        // 主体内容
        <ContentMain />
      </Layout>
    </Layout>
  </Layout>
)

export default Main as ComponentType

接下来重点就是这个ContentMain组件啦

import React, { FC } from 'react'
import { withRouter, Switch, Redirect, RouteComponentProps, Route } from 'react-router-dom'
import { Index, ExerciseList, ExercisePublish, ExerciseModify } from '@/admin/pages'
import './index.scss'

const ContentMain: FC<RouteComponentProps> = () => {
  return (
    <div className="main__container">
      <Switch>
        <Route exact path="/admin" component={Index} />
        <Route exact path="/admin/content/exercise-list" component={ExerciseList} />
        <Route exact path="/admin/content/exercise-publish" component={ExercisePublish} />
        <Route exact path="/admin/content/exercise-modify/:id" component={ExerciseModify} />

        <Redirect exact from="/" to="/admin" />
      </Switch>
    </div>
  )
}

export default withRouter(ContentMain)

这个就是一个嵌套路由啦,在这里面使用withRouter来包裹一下,然后在这里再次定义路由信息,这样就可以只切换主体部分的内容而不改变导航栏啦

大功告成!!!

4. 侧边栏的选中部分动态变化

通过图片我们可以看出,侧边导航栏有一个选中的内容,那么我们该如何判断不同的url页面对应哪一个选中部分呢?

  const [selectedKeys, setSelectedKeys] = useState(['index'])
  const [openedKeys, setOpenedKeys] = useState([''])
  const { location: { pathname } } = props
  const rank = pathname.split('/')

  useEffect(() => {
    switch (rank.length) {
      case 2: // 一级目录
        setSelectedKeys([pathname])
        setOpenedKeys([''])
        break
      case 4: // 二级目录
        setSelectedKeys([pathname])
        setOpenedKeys([rank.slice(0, 3).join('/')])
        break
    }
  }, [pathname])

如果是用React的没有使用到hook,则这里可以使用componentWillReceiveProps() 还有 componentDidMount()搭配使用,意思就是页面加载好之后设置一下这个选中,然后有更新也设置一下

这就是最重要的部分啦,我们通过定义几个状态selectedKeys选中的条目,openedKeys打开的多级导航栏

我们通过在页面加载时候,判断页面url路径,如果是一级目录,例如首页,就直接设置选中的条目即可,如果是二级目录,例如导航栏中内容管理/题库管理这个功能,他的url链接是/admin/content/exercise-list,所以我们的case 4就可以捕获到啦,然后设置当前选中的条目以及打开的多级导航,具体的导航信息请看下面

<Menu
        mode="inline"
        defaultSelectedKeys={['/admin']}
        selectedKeys={selectedKeys}
        openKeys={openedKeys}
        onOpenChange={handleMenuChange}
      >
        <Menu.Item key="/admin">
          <Link to="/admin">
            <Icon type="home" />
            首页
        </Link>
        </Menu.Item>
        <SubMenu
          key="/admin/content"
          title={
            <span>
              <Icon type="profile" />
              内容管理
            </span>
          }
        >
          <Menu.Item key="/admin/content/exercise-list">
            <Link to="/admin/content/exercise-list">题库管理</Link>
          </Menu.Item>
        </SubMenu>
    </Menu>

大功告成!!!

5. 巧妙利用Antd表单来构造自己想要的数据结构

使用过Antd表单的胖友们一定知道this.props.form.validateFields()这个方法吧嘿嘿,他是如果验证成功就返回表单的值给你,不用自己去绑定输入组件的值,很方便,来看看官方的例子

可以看到,最简单的一个登录框,然后我们就可以得到一组数据啦,不过我们可以发现,这些数据就是一个对象中的几个值。

假如我们有很多数据,想用多个对象来构造数据结构,这应该怎么办呢,就例如这样子的数据结构,我们还是举上面这个例子

假如吼,我们提交后台的数据需要是这样子的数据结构,用户名和密码在userInfo这个对象内,然后是否记住密码是在other对象里面,自己得到数据之后再构造又十分麻烦,这可怎么办呢。

在此之前,我们不如看看官方给的另一个例子,一个动态添加表单项的例子,于此我们就可以发挥想象力,然后就可以解决我们上面的问题啦

可以看到这个动态添加表单项的,是以数组形式来存储数据的,他的代码是这样的

{getFieldDecorator(`names[${k}]`, {
  validateTrigger: ['onChange', 'onBlur'],
  rules: [
    {
      required: true,
      whitespace: true,
      message: "Please input passenger's name or delete this field.",
    },
  ],
})(<Input placeholder="passenger name" style={{ width: '60%', marginRight: 8 }} />)}

Antd表单的构造数据关键就在于里面的getFieldDecorator内的第一个参数,也就是我们的propName用来指定数据叫啥,跟之后验证表单传回的值是对应的了。这就给了我们一个很大的提示啦!!

这个propName叫什么,之后生成的数据结构里面就是什么,是a,之后数据就对应a,是b,就对应b

这里通过一个names[$k],就可以让之后得到的数据变成一个数组names:Array(2): ['1', '2']这样子的形式,那么我们稍加改造一下,就可以变成对象的形式啦!下面看看代码,其实也很简单!

<Form.Item label="题目内容" >
{getFieldDecorator(`topicList[${index}].topicContent`, {
  rules: TopicContentRules,
  initialValue: topicList[index].topicContent
})(<Input.TextArea />)}
</Form.Item>

这里我就直接举项目中题库提交的例子啦,topicList是一个列表,里面存的是每一个题目对应的数据对象

这里的propName,我指定成了topicList[$(index)]就代表,这个属于这个列表里面的第几个对象,然后后面的.topicContent就代表这个对象里面的值是什么,最后我们的出的结构就是这样子的啦!

我们如愿得到了想要的数据结构了,这里面有对象,有数组,十分方便,可以灵活根据实际情况进行使用,关键就在于getFieldDecorator()里面的propName,直接以对象的形式命名,就可以啦!就按照下面这种形式就好啦!

<Form.Item label="itemName" >
    {getFieldDecorator(`object.itemName`, {
      initialValue: 'BB小天使'
    })(<Input />)}
</Form.Item>

之后就可以得到对象类型的表单值啦!

大功告成!!!

6. 后台接口获取信息后填充Antd表单

因为有一个题库修改的功能,所以打算获取完接口信息之后,直接将内容通过Antd表单的setFields的方法来直接填充表格中的信息,结果控制台报错了

看了看大致意思就是说emmmm不可以在渲染之前就设置表单的值,嘶~这可难受了,这时候想到他的表单内有一个initialValue的属性,是表单项的默认值,这可好办啦,这样我们先拉取信息,存入对象中,然后再通过这个属性给表单传值,果然不出所料,真的ok了没有报错了哈哈哈,具体看下面

  // 定义选项列表来存储题库的题目列表信息
  const [topicList, setTopicList] = useState<TopicList[]>([{
    topicType: 1,
    topicAnswer: [],
    topicContent: '',
    topicOptions: []
  }])
  // 定义题库基本信息对象
  const [exerciseInfo, setExerciseInfo] = useState<ExerciseInfo>({
    exerciseName: '',
    exerciseContent: '',
    exerciseDifficulty: 1,
    exerciseType: 1,
    isHot: false
  })

  // 首先先拉取信息,这就是题库的信息啦
  const { data } = await http.get(`/exercises/${id}`)
  const {
    exerciseName,
    exerciseContent,
    exerciseDifficulty,
    exerciseType,
    isHot,
    topicList } = data
  topicList.forEach((_: any, index: number) => {
    topicList[index].topicOptions = topicList[index].topicOptions.map((item: any) => item.option)
  })
  
  // 获取信息后,设置状态
  setTopicList([...topicList])
  setExerciseInfo({
    exerciseName,
    exerciseContent,
    exerciseDifficulty,
    exerciseType,
    isHot,
  })

这样我们就得到了题库信息的对象啦,待会我们就可以用来传默认值给表单啦!

// 这里就通过题库名称来做例子,就从刚才设置的信息对象中取值然后设置默认值就可以啦
<Form.Item label="题库名称">
  {getFieldDecorator('exerciseName', {
    rules: ExerciseNameRules,
    initialValue: exerciseInfo.exerciseName
  })(<Input />)}
</Form.Item>

因为题库的题目是有挺多,所以是一个列表,类似下图

所以我们实现设置好topicList这个数组来存储题目的信息,然后我们通过遍历这个列表来实现多题目编辑

<Form.Item label="新增题目">
    {topicList && topicList.map((_: any, index: number) => {
      return (
        <Fragment key={index}>
          <div className="form__subtitle">
            第{index + 1}题
            <Tooltip title="删除该题目">
              <Icon
                type="delete"
                theme="twoTone"
                twoToneColor="#fa4b2a"
                style={{ marginLeft: 16, display: topicList.length > 1 ? 'inline' : 'none' }}
                onClick={() => handleTopicDeleteClick(index)} />
            </Tooltip>
          </div>
          <Form.Item label="题目内容" >
            {getFieldDecorator(`topicList[${index}].topicContent`, {
              rules: TopicContentRules,
              initialValue: topicList[index].topicContent
            })(<Input.TextArea />)}
          </Form.Item>
          
          ...... 省略一堆~
          
        </Fragment>
      )
    })}
    <Form.Item>
      <Button onClick={handleTopicAddClick}>新增题目</Button>
    </Form.Item>
  </Form.Item>

例如题目内容的话,我们就设置他的initialValuetopicList[index].topicContent即可,别的属性同理,然后点击新增题目按钮,就直接往topicList内添加对象信息即可完成题目列表的增加,点击删除图标,就删除列表中某一项,是不是十分方便!!哈哈哈

大功告成!!!

7. 使用JWTToken来验证用户登录状态以及返回信息

要想使用登录注册功能,还有用户权限的问题,我们就需要使用到这个token啦!为什么我们要使用token呢?而不是用传统的cookies呢,因为使用token可以避免跨域啊还有更多的复杂问题,大大简化我们的开发效率

本项目后台采用nodeJs来进行开发

我们先在后台定义一个工具utils/token.js

// token的秘钥,可以存在数据库中,我偷懒就卸载这里面啦hhh
const secret = "zhcxk1998"

const jwt = require('jsonwebtoken')

// 生成token的方法,注意前面一定要有Bearer ,注意后面有一个空格,我们设置的时间是1天过期
const generateToken = (payload = {}) => (
  'Bearer ' + jwt.sign(payload, secret, { expiresIn: '1d' })
)

// 这里是获取token信息的方法
const getJWTPayload = (token) => (
  jwt.verify(token.split(' ')[1], secret)
)

module.exports = {
  generateToken,
  getJWTPayload
}

这里采用的是jsonwebtoken这个库,来进行token的生成以及验证。

有了这个token啦,我们就可以再登录或者注册的时候给用户返回一个token信息啦

router.post('/login', async (ctx) => {
  const responseBody = {
    code: 0,
    data: {}
  }

  try {
    if (登录成功) {
      responseBody.data.msg = '登陆成功'
      // 在这里就可以返回token信息给前端啦
      responseBody.data.token = generateToken({ username })
      responseBody.code = 200
    } else {
      responseBody.data.msg = '用户名或密码错误'
      responseBody.code = 401
    }
  } catch (e) {
    responseBody.data.msg = '用户名不存在'
    responseBody.code = 404
  } finally {
    ctx.response.status = responseBody.code
    ctx.response.body = responseBody
  }
})

这样前端就可以获取这个token啦,前端部分只需要将token存入localStorage中即可,不用担心localStorage是永久保存,因为我们的token有个过期时间,所以不用担心

  /* 登录成功 */
  if (code === 200) {
    const { msg, token } = data
    // 登录成功后,将token存入localStorage中
    localStorage.setItem('token', token)
    message.success(msg)
    props.history.push('/admin')
  }

好嘞,现在前端获取token也搞定啦,接下来我们就需要在访问接口的时候带上这个token啦,这样才可以让后端知道这个用户的权限如何,是否过期等

需要传tokne给后端,我们可以通过每次接口都传一个字段token,但是这样十分浪费成本,所以我们再封装好的axios中,我们设置请求头信息即可

import axios from 'axios'

const instance = axios.create({
  baseURL: '/api',
  timeout: 10000,
  headers: {
    'Content-Type': "application/json;charset=utf-8",
  },
})

instance.interceptors.request.use(
  config => {
    // 请求头带上token信息
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.common['Authorization'] = token;
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)
...

export default instance

如上图所示,我们每次请求接口的时候就会带上这个请求头啦!那么接下来我们就谈谈后端如何获取这个token并且验证吧

有获取token,以及验证部分,那么就需要出动我们的中间件啦!

我们验证token的话,要是用户是访问的登录或者注册接口,那么这个时候token其实是没有作用哒,所以我们需要将它隔离一下,所以我们定义一个中间件,用来跳过某些路由,我们再middleware/verifyToken.js中定义(这里我们采用koa-jwt来验证token)

const koaJwt = require('koa-jwt')

const verifyToken = () => {
  return koaJwt({ secret: 'zhcxk1998' }).unless({
    path: [
      /login/,
      /register/
    ]
  })
}

module.exports = verifyToken

这样就可以忽略这登录注册路由啦,别的路由就验证token

拦截已经成功啦,那么我们该如何捕获,然后进行处理呢?我们再middleware/interceptToken定义一个中间件,来处理捕获的token信息

const interceptToken = async (ctx, next) => {
  return await next().catch((err) => {
    const { status } = err
    if (status === 401) {
      ctx.response.status = 401
      ctx.response.body = {
        code: 401,
        data: {
          msg: '请登录后重试'
        }
      }
    } else {
      throw err
    }
  })
}

module.exports = () => (
  interceptToken
)

由于koa-jwt拦截的token,如果过期,他会自动抛出一个401的异常以表示该token已经过期,所以我们只需要判断这个状态status然后进行处理即可

好嘞,中间件也定义好了,我们就在后端服务中使用起来吧!

const Koa = require('koa')
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser')
const cors = require('koa2-cors');
const routes = require('../routes/routes')

const router = new Router()
const admin = new Koa();

const {
  verifyToken,
  interceptToken
} = require('../middleware')
const {
  login,
  info,
  register,
  exercises
} = require('../routes/admin')

admin.use(cors())
admin.use(bodyParser())
/* 拦截token */
admin.use(interceptToken())
admin.use(verifyToken())
/* 管理端 */
admin.use(routes(router, { login, info, register, exercises }))

module.exports = admin

我们直接使用router.use()的方法就可以使用中间件啦,这里要记住!验证拦截token一定要在路由信息之前,否则是拦截不到的哟(如果在后面,路由都先执行了,还拦截啥嘛!)

大功告成!!!

8. 密码使用加密加盐的方式存储

我们在处理用户的信息的时候,需要存储密码,但是直接存储肯定不安全啦!所以我们需要加密以及加盐的处理,在这里我用到的是crypto这个库

首先我们再utils/encrypt.js中定义一个工具函数用来生成盐值以及获取加密信息

const crypto = require('crypto')

// 获取随机盐值,例如 c6ab1 这样子的字符串
const getRandomSalt = () => {
  const start = Math.floor(Math.random() * 5)
  const count = start + Math.ceil(Math.random() * 5)
  return crypto.randomBytes(10).toString('hex').slice(start, count)
}

// 获取密码转换成md5之后的加密信息
const getEncrypt = (password) => {
  return crypto.createHash('md5').update(password).digest('hex')
}

module.exports = {
  getRandomSalt,
  getEncrypt
}

这样我们就可以通过验证密码与数据库中加密的信息对不对得上,来判断是否登录成功等等

我们现在注册中使用上,当然我们需要两个表进行数据存储,一个是用户信息,一个是用户密码表,这样分开更加安全,例如这样

这样就可以将用户信息还有密码分开存放,更加安全,这里就不重点叙述啦

const { getRandomSalt, getEncrypt } = require('../../utils/encrypt')

// 注册部分
router.post('/register', async (ctx) => {
  const { username, password, phone, email } = ctx.request.body

  // 获取盐值以及加密后的信息
  const salt = getRandomSalt()
  // 数据库存放的密码是由用户输入的密码加上随机盐值,然后再进行加密所得到的的炒鸡加密密码
  const encryptPassword = getEncrypt(password + salt)
  
  // 插入用户信息,以及获取这个的id
  const { insertId: user_id } = await query(INSERT_TABLE('user_info'), { username, phone, email });
  // 插入用户密码信息,user_id与上面对应
  await query(INSERT_TABLE('user_password'), {
    user_id,
    password: encryptPassword,
    salt
  })
  ...
  
  
})

接下来再来看登录部分,登录的话,就需要从用户密码表中取出加密密码,以及盐值,然后进行对比

// 通过用户名,先获取加密密码以及盐值
const { password: verifySign, salt } = await query(`select password, salt from user_password where user_id = '${userId}'`)[0]

// 这个就是用户输入的密码加上盐值一起加密后的密码
const sign = getEncrypt(password + salt)

// 这个加密的密码与数据库中加密的密码对比,如果一样则登陆成功
if (sign === verifySign) {
  responseBody.data.msg = '登陆成功'
  responseBody.data.token = generateToken({ username })
  responseBody.code = 200
} else {
  responseBody.data.msg = '用户名或密码错误'
  responseBody.code = 401
}

大功告成!!!

结语

大部分的内容就大概这样子,这是自己开发中遇到的小问题还有解决方法,希望对大家有所帮助,大家一起成长!现在得看看面试题准备一波春招了,不然大学毕业了都找不到工作啦!有时间再继续更新这个文章!

最后还是顺便求一波star还有点赞!!!

github项目猛戳进来star一下嘿嘿
小程序介绍文章,使劲戳!

使用Taro框架开发微信小程序的一些开发经验

1. 后端部分

  • 数据库部分

    我们将所有的聊天记录存放到一张表上方便管理,因为我们有多个聊天群组,我们该如何区分这些不同的聊天群组呢?答案是,通过room_name来区分,获取聊天记录的时候就直接查询这个群组名即可,这样就不用开很多的表,将不同的群聊记录存放到不同的表中啦!

同时因为我们的聊天记录内需要存储emoji等信息,所以,我们需要将数据库的字符集调整为utf8mb4 -- UTF-8 Unicode,排序规则选择utf8mb4_unicode_ci,这个可以通过自行百度,或者navicat中设置。

然后我们将数据表以及字段类型也设置为utf8mb4,便于存储emoji信息

  • 后端处理聊天记录的方法。
router.get('/chatlog/:to', async (ctx) => {
  const to = ctx.params.to
  const response = []
  const res = await query(`SELECT * FROM chatlog WHERE room_name = '${to}' ORDER BY current_time DESC`);
  res.map((item, index) => {
    const { room_name, user_name, user_avatar, current_time, message } = item
    response[index] = {
      to: room_name,
      userName: user_name,
      userAvatar: user_avatar,
      currentTime: formatTime(current_time),
      message,
      messageId: `msg${current_time}${Math.ceil(Math.random() * 100)}`
    }
  })
  ctx.response.body = parse(response)
})

这是获取指定群聊的后端接口,to代表的是群组名,使用get的方法即可获取到指定群聊的聊天记录啦!

继续聊聊我们如何为所有连接到聊天室的网友们发送信息,这里我们采用的是广播的方式,不同于socket.io内已经封装好广播的方法,小程序规定只能使用websocket,所以我粗略的封装了一下广播(十分丑陋的代码)

let onlineUserSocket = {}
let onlineUserInfo = {}

const handleLogin = (ws, socketMessage) => {
  const { socketId, userName, userAvatar } = socketMessage
  onlineUserSocket[socketId] = ws
  onlineUserInfo[socketId] = { userName, userAvatar }
  ws.socketId = socketId
}

// 广播消息
const broadcast = (message) => {
  const { from, userName } = message
  Object.values(onlineUserSocket).forEach((socket) => {
    socket.send(JSON.stringify({
      ...message,
      isMyself: userName === onlineUserInfo[socket.socketId].userName
    }))
  })
}

我们再登录的时候,就将前端传来的消息存入对象中,以及他的socket对象,然后广播的时候就可以遍历所有的socket对象,为所有在线用户广播消息,其中的isMyself代表的是否为本人,例如我发的消息,自己的socket对象接受广播的时候就是true。别人的就是false,这样做是为了方便区分,自己的聊天消息和被人的聊天消息


2. 前端部分

接下来聊聊前端的聊天室部分

handleSocketMessage(): void {
    const { socketTask } = this
    socketTask.onMessage(async ({ data }) => {
      const messageInfo: ReceiveMessageInfo = JSON.parse(data)
      const { to, messageId, isMyself, userName, userAvatar, currentTime, message } = messageInfo
      const time: string = formatTime(currentTime)

      this.messageList[to].push({
        ...messageInfo,
        currentTime: time
      })
      /* 设置群组最新消息 */
      this.contactsList.filter(contacts => contacts.contactsId === to)[0].latestMessage = {
        userName, message, currentTime: time
      }
      this.scrollViewId = isMyself ? messageId : ''
      await Taro.request({
        url: 'http://localhost:3000/chatlog',
        method: 'PUT',
        data: {
          to,
          userName,
          userAvatar,
          currentTime,
          message,
        }
      })
    })
  }

我们先接受消息,然后先更新指定群组名的聊天群组的聊天记录,然后再使用PUT的方式访问接口添加聊天记录到数据库中。

可以看到我们的聊天记录是分为左边以及右边的,自己发的消息即为右边,我们可以通过简单的flex布局来实现

// 这里是覆盖默认样式,显示自己消息的样式
.myself {
  justify-content: flex-end;

  .avatar {
    order: 1;
  }

  .info {
    display: flex;
    flex-direction: column;
    align-items: flex-end;

    .header {
      justify-content: flex-end;

      .username {
        order: 1;
        margin-right: 0 !important;
        margin-left: .5em;
      }
    }

    .content {
      color: #333 !important;
      border: #e7e7e7 1px solid;
      background: #fff !important;
      box-shadow: 0 8px 20px -8px #d7d7d7;
    }
  }
}

// 以下是默认样式,就是左边的样式
.message-wrap {
  display: flex;
  margin: 20px 0;

  .avatar {
    width: 14vw;
    height: 14vw;
    margin: 10px;
    border-radius: 50%;
    background-image: linear-gradient(120deg, #a1c4fd 0%, #c2e9fb 100%);
  }

  .info {

    .header {
      display: flex;
      align-items: center;
      max-width: 40vw;
      padding: 10px 0;
      color: #666;
      font-size: .8em;

      .username {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        max-width: 40vw;
        margin-right: .5em;
        color: #555;
        font-size: 1.2em;
        font-weight: bold;
      }
    }

    .content {
      display: inline-block;
      max-width: 60vw;
      padding: 10px 20px;
      color: #fff;
      word-break: break-all;
      border-radius: 20px;
      background: #66a6ff;
    }
  }
}

最后我们聊一下websocket的断线重连

  handleSocketClose(): void {
    const { socketTask } = this
    socketTask.onClose((msg) => {
      this.socketTask = null
      this.socketReconnect()
      console.log('onClose: ', msg)
    })
  }

  handleSocketError(): void {
    const { socketTask } = this
    socketTask.onError(() => {
      this.socketTask = null
      this.socketReconnect()
      console.log('Error!')
    })
  }

我们这里先监听一下websocket关闭或者异常的情况,调用重连方法,以及清空socketTask的对象,接下来是重连的方法

  socketConnect() {
    // 生成随机特有的socketId
    this.generateSocketId()

    /* 使用then的方法才能正确触发onOpen的方法,暂时不知道原因 */
    Taro.connectSocket({
      url: 'ws://localhost:3000',
    }).then(task => {
      this.socketTask = task
      this.handleSocketOpen()
      this.handleSocketMessage()
      this.handleSocketClose()
      this.handleSocketError()
    })
  }

  socketReconnect(): void {
    this.isReconnected = true
    clearTimeout(this.timer)

    /* 3s延迟重连,减轻压力 */
    this.timer = setTimeout(() => {
      this.socketConnect()
    }, 3000)
  }

我们每三秒调用一遍socket连接的方法,重新再设置好socketId,以及socketTask,重新监听各种方法。这里有一个奇特的地方,就是Taro的connectSocket方法,不能使用async/await的方法来获取socketTask,也就是说不能这样const socketTask = await Taro.connectSocket({...})来获取socketTask,只能通过then的方法才能获取到,卑微的我暂时不知道如何解决这个问题......

聊天界面中有一个emoji表情的按钮,点击就会弹出emoji栏

实现起来比较简单,首先定义一个变量emojiOpened来判断用户是否点击emoji按钮,若点击则为输入栏新增一个类名来控制弹出的样式

<View className={`chat-input-container ${emojiOpened ? 'emoji-open' : ''}`}>

同时再scss中设置弹出的样式

.emoji-open {
  transform: translateY(-30vh);
  transition: all .2s ease;
}

...

&-input-container {
    position: fixed;
    left: 0;
    bottom: -30vh;
    width: 100vw;
    height: 40vh;
    background: #fff;
    z-index: 1;
    transition: all .2s ease;
    ...
}

因为在下还只是可怜巴巴的大学生,好多大作业有待完成!具体后续请关注一下我的github,将持续更新项目!

猛戳~

✏️Canvas实现图片上标注、缩放、移动和保存历史状态,纯干货(附css3变化公式)

原文地址:https://juejin.im/post/5e717376e51d4526dd1ec2e6

哈哈哈俺又来啦,这次带来的是canvas实现一些画布功能的文章,希望大家喜欢!这个css3变化公式可以适用于平常我们使用的transform属性或者是移动端我们缩放地图啊之类的都可以哟!

前言

因为也是大三了,最近俺也在找实习,之前有一个自己的小项目:

https://github.com/zhcxk1998/School-Partners

面试官说可以往深层次思考一下,或许加一些新的功能来增加项目的难度,他提了几个建议,其中一个就是试卷在线批阅,老师可以在上面对作业进行批注,圈圈点点等俺当天晚上就开始研究这个东东哈哈哈,终于被我研究出来啦!

采用的是canvas绘制画笔,由css3的transform属性来进行平移与缩放,因为呢考虑到如果用canvas的drawImage或者scale等属性进行变化,生成出来的图片也会有影响,想着直接css3变化,canvas用来做画笔等功能。大佬们有何妙招,在评论区指点指点!

希望大家可以留下宝贵的赞与star嘻嘻

效果预览

动图是放cdn的,如果访问不了,可以登录在线尝试尝试:
http://test.algbb.cn/#/admin/content/mark-paper

公式推导

如果不想看公式如何推导,可以直接跳过看后面的具体实现~

1. 坐标转换公式

转换公式介绍

其实一开始也是想在网上找一下有没有相关的资料,但是可惜找不到,所以就自己慢慢的推出来了。我就举一下横坐标的例子吧!

通用公式

这个公式是表示,通过公式来将鼠标按下的坐标转换为画布中的相对坐标,这一点尤为重要

(transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX

参数解释

transformOrigin: transform变化的基点(通过这个属性来控制元素以哪里进行变化)
downX: 鼠标按下的坐标(注意,用的时候需要减去容器左偏移距离,因为我们要的是相对于容器的坐标)
scale: 缩放倍数,默认为1
translateX: 平移的距离

推导过程

这个公式的话,其实就比较通用,可以用在别的利用到transform属性的场景,至于怎么推导的话,我是用的笨办法

具体的测试代码,放在文末,需要自取~

1. 先做出两个相同的元素,然后标记上坐标,并且设置容器属性overflow:hidden来隐藏溢出内容

ok,现在就有两个一样的矩阵啦,我们为他标记上一些红点,然后我们对左边的进行css3的样式变化transform

矩形的宽高是360px * 360px的,我们定义一下他的变化属性,变化基点选择正中心,放大3倍

// css
transform-origin: 180px 180px;
transform: scale(3, 3);

得到如下结果

ok,我们现在对比一下上面的结果,就会发现,放大3倍的时候,恰好是中间黑色方块占据了全部宽度。接下来我们就可以对这些点与原先没有进行变化(右边)的矩形进行对比就可以得到他们坐标的关系啦

2. 开始对两个坐标进行对比,然后推出公式

现在举一个简单的例子吧,例如我们算一下左上角的坐标(现在已经标记为黄色了)

其实我们其实就可以直接心算出来坐标的关系啦
这里左边计算坐标的值是我们鼠标按下的坐标
这里左边计算坐标的值是我们鼠标按下的坐标
这里左边计算坐标的值是我们鼠标按下的坐标

  • 因为宽高是360px,所以分成3等份,每份宽度是120px
  • 因为变化之后容器的宽高是不变的,变化的只有矩形本身
  • 我们可以得出左边的黄色标记坐标是x:120 y:0,右边的黄色标记为x:160 y:120(这个其实肉眼看应该就能看出来了,实在不行可以用纸笔算一算)

这个坐标可能有点特殊,我们再换几个来计算计算(根据特殊推一般)

  • 蓝色标记:左边:x:120 y:120,右边:x: 160 y:160
  • 绿色标记:左边:x: 240 y:240,右边:x: 200: y:200

好了,我们差不多已经可以拿到坐标之间的关系了,我们可以列一个表

还觉得不放心?我们可以换一下,缩放倍数与容器宽高等进行计算

不知道大家有没有感觉呢,然后我们就可以慢慢根据坐标推出通用的公式啦
(transformOrigin - downX) / scale * (scale-1) + down - translateX = point

当然,我们或许还有这个translateX没有尝试,这个就比较简单一点了,脑内模拟一下,就知道我们可以减去位移的距离就ok啦。我们测试一下

我们先修改一下样式,新增一下位移的距离

transform-origin: 180px 180px;
transform: scale(3, 3) translate(-40px,-40px);

还是我们上面的状态,ok,我们现在蓝色跟绿色的标记还是一一对应的,那我们看看现在的坐标情况

  • 蓝色:左边:x:0 y:0,右边:x:160 y:160
  • 绿色:左边:x:120 y:120,右边:x:200 y:200

我们分别运用公式算一下出来的坐标是怎么样的
(以下为经过坐标换算)

  • 蓝色:左边:x:120 y:120,右边:x:160 y:160
  • 绿色:左边:x:160 y:160,右边:x:200 y:200

不难发现,我们其实就相差了与位移距离translateX/translateY的差值,所以,我们只需要减去位移的距离就可以完美的进行坐标转换啦

测试公式

根据上面的公式,我们可以简单测试一下!这个公式到底能不能生效!!!

我们直接沿用上面的demo,测试一下如果元素进行了变化,我们鼠标点下的地方生成一个标记,位置是否显示正确。看起来很ok啊(手动滑稽)

const wrap = document.getElementById('wrap')
wrap.onmousedown = function (e) {
  const downX = e.pageX - wrap.offsetLeft
  const downY = e.pageY - wrap.offsetTop

  const scale = 3
  const translateX = -40
  const translateY = -40
  const transformOriginX = 180
  const transformOriginY = 180

  const dot = document.getElementById('dot')
  dot.style.left = (transformOriginX - downX) / scale * (scale - 1) + downX - translateX + 'px'
  dot.style.top = (transformOriginY - downY) / scale * (scale - 1) + downY - translateY + 'px'
}

可能有人会问,为什么要减去这个offsetLeftoffsetTop呢,因为我们上面反复强调,我们计算的是鼠标点击的坐标,而这个坐标还是相对于我们展示容器的坐标,所以我们要减去容器本身的偏移量才行。

组件设计

既然demo啥的都已经测试了ok了,我们接下来就逐一分析一下这个组件应该咋设计好呢(目前仍为低配版,之后再进行优化完善)

1. 基本的画布构成


我们先简单分析一下这个构成吧,其实主要就是一个画布的容器,右边一个工具栏,仅此而已


大体就这样子啦!

<div className="mark-paper__wrap" ref={wrapRef}>
  <canvas
    ref={canvasRef}
    className="mark-paper__canvas">
    <p>很可惜,这个东东与您的电脑不搭!</p>
  </canvas>
  <div className="mark-paper__sider" />
</div>

我们唯一需要的一点就是,容器需要设置属性overflow: hidden用来隐藏内部canvas画布溢出的内容,也就是说,我们要控制我们可视的区域。同时我们需要动态获取容器宽高来为canvas设置尺寸

2. 初始化canvas画布与填充图片

我们可以弄个方法来初始化并且填充画布,以下截取主要部分,其实就是为canvas画布设置尺寸与填充我们的图片

const fillImage = async () => {
  // 此处省略...
  
  const img: HTMLImageElement = new Image()

  img.src = await getURLBase64(fillImageSrc)
  img.onload = () => {
    canvas.width = img.width
    canvas.height = img.height
    context.drawImage(img, 0, 0)

    // 设置变化基点,为画布容器**
    canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
    // 清除上一次变化的效果
    canvas.style.transform = ''
  }
}

3. 监听canvas画布的各种鼠标事件

这个控制移动的话,我们首先可以弄一个方法来监听画布鼠标的各种事件,可以区分不同的模式来进行不同的事件处理

const handleCanvas = () => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!context || !wrap) return

  // 清除上一次设置的监听,以防获取参数错误
  wrap.onmousedown = null
  wrap.onmousedown = function (event: MouseEvent) {
    const downX: number = event.pageX
    const downY: number = event.pageY

    // 区分我们现在选择的鼠标模式:移动、画笔、橡皮擦
    switch (mouseMode) {
      case MOVE_MODE:
        handleMoveMode(downX, downY)
        break
      case LINE_MODE:
        handleLineMode(downX, downY)
        break
      case ERASER_MODE:
        handleEraserMode(downX, downY)
        break
      default:
        break
    }
  }

4. 实现画布移动

这个就比较好办啦,我们只需要利用鼠标按下的坐标,和我们拖动的距离就可以实现画布的移动啦,因为涉及到每次移动都需要计算最新的位移距离,我们可以定义几个变量来进行计算。

这里监听的是容器的鼠标事件,而不是canvas画布的事件,因为这样子我们可以再移动超过边界的时候也可以进行移动操作

简单的总结一下:

  • 传入鼠标按下的坐标
  • 计算当前位移距离,并更新css变化效果
  • 鼠标抬起时更新最新的位移状态
// 定义一些变量,来保存当前/最新的移动状态
// 当前位移的距离
const translatePointXRef: MutableRefObject<number> = useRef(0)
const translatePointYRef: MutableRefObject<number> = useRef(0)
// 上一次位移结束的位移距离
const fillStartPointXRef: MutableRefObject<number> = useRef(0)
const fillStartPointYRef: MutableRefObject<number> = useRef(0)

// 移动时候的监听函数
const handleMoveMode = (downX: number, downY: number) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const { current: fillStartPointX } = fillStartPointXRef
  const { current: fillStartPointY } = fillStartPointYRef
  if (!canvas || !wrap || mouseMode !== 0) return

  // 为容器添加移动事件,可以在空白处移动图片
  wrap.onmousemove = (event: MouseEvent) => {
    const moveX: number = event.pageX
    const moveY: number = event.pageY

    // 更新现在的位移距离,值为:上一次位移结束的坐标+移动的距离
    translatePointXRef.current = fillStartPointX + (moveX - downX)
    translatePointYRef.current = fillStartPointY + (moveY - downY)

    // 更新画布的css变化
    canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
  }
  
  wrap.onmouseup = (event: MouseEvent) => {
    const upX: number = event.pageX
    const upY: number = event.pageY
    
    // 取消事件监听
    wrap.onmousemove = null
    wrap.onmouseup = null;

    // 鼠标抬起时候,更新“上一次唯一结束的坐标”
    fillStartPointXRef.current = fillStartPointX + (upX - downX)
    fillStartPointYRef.current = fillStartPointY + (upY - downY)
  }
}

5. 实现画布缩放

画布缩放我主要通过右侧的滑动条以及鼠标滚轮来实现,首先我们再监听画布鼠标事件的函数中加一下监听滚轮的事件

总结一下:

  • 监听鼠标滚轮的变化
  • 更新缩放倍数,并改变样式
// 监听鼠标滚轮,更新画布缩放倍数
const handleCanvas = () => {
  const { current: wrap } = wrapRef

  // 省略一万字...

  wrap.onwheel = null
  wrap.onwheel = (e: MouseWheelEvent) => {
    const { deltaY } = e
    // 这里要注意一下,我是0.1来递增递减,但是因为JS使用IEEE 754,来计算,所以精度有问题,我们自己处理一下
    const newScale: number = deltaY > 0
      ? (canvasScale * 10 - 0.1 * 10) / 10
      : (canvasScale * 10 + 0.1 * 10) / 10
    if (newScale < 0.1 || newScale > 2) return
    setCanvasScale(newScale)
  }
}

// 监听滑动条来控制缩放
<Slider
  min={0.1}
  max={2.01}
  step={0.1}
  value={canvasScale}
  tipFormatter={(value) => `${(value).toFixed(2)}x`}
  onChange={handleScaleChange} />
  
const handleScaleChange = (value: number) => {
  setCanvasScale(value)
}

接着我们使用hooks的副作用函数,依赖于画布缩放倍数来进行样式的更新

//监听缩放画布
useEffect(() => {
  const { current: canvas } = canvasRef
  const { current: translatePointX } = translatePointXRef
  const { current: translatePointY } = translatePointYRef
  canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
}, [canvasScale])

6. 实现画笔绘制

这个就需要用到我们之前推导出来的公式啦!因为呢,仔细想一下,如果我们缩放位移之后,我们鼠标按下的位置,他的坐标可能就相对于画布来说会有变化,所以我们需要转换一下才能进行鼠标按下的位置与画布的位置一一对应的效果

稍微总结一下:

  • 传入鼠标按下的坐标
  • 通过公式转换,开始在对应坐标下绘制
  • 鼠标抬起时,取消事件监听
// 利用公式转换一下坐标
const generateLinePoint = (x: number, y: number) => {
  const { current: wrap } = wrapRef
  const { current: translatePointX } = translatePointXRef
  const { current: translatePointY } = translatePointYRef
  const wrapWidth: number = wrap?.offsetWidth || 0
  const wrapHeight: number = wrap?.offsetHeight || 0
  // 缩放位移坐标变化规律
  // (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
  const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
  const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY

  return {
    pointX,
    pointY
  }
}

// 监听鼠标画笔事件
const handleLineMode = (downX: number, downY: number) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !wrap || !context) return

  const offsetLeft: number = canvas.offsetLeft
  const offsetTop: number = canvas.offsetTop
  // 减去画布偏移的距离(以画布为基准进行计算坐标)
  downX = downX - offsetLeft
  downY = downY - offsetTop

  const { pointX, pointY } = generateLinePoint(downX, downY)
  context.globalCompositeOperation = "source-over"
  context.beginPath()
  // 设置画笔起点
  context.moveTo(pointX, pointY)

  canvas.onmousemove = null
  canvas.onmousemove = (event: MouseEvent) => {
    const moveX: number = event.pageX - offsetLeft
    const moveY: number = event.pageY - offsetTop
    const { pointX, pointY } = generateLinePoint(moveX, moveY)
    // 开始绘制画笔线条~
    context.lineTo(pointX, pointY)
    context.stroke()
  }
  canvas.onmouseup = () => {
    context.closePath()
    canvas.onmousemove = null
    canvas.onmouseup = null
  }
}

7. 橡皮擦的实现

橡皮擦目前还有点问题,现在的话是通过将canvas画布的背景图片 + globalCompositeOperation这个属性来模拟橡皮擦的实现,不过,这时候图片生成出来之后,橡皮擦的痕迹会变成白色,而不是透明

此步骤与画笔实现差不多,只有一点点小变动

  • 设置属性context.globalCompositeOperation = "destination-out"
// 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色
const handleEraserMode = (downX: number, downY: number) => {
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !wrap || !context) return

  const offsetLeft: number = canvas.offsetLeft
  const offsetTop: number = canvas.offsetTop
  downX = downX - offsetLeft
  downY = downY - offsetTop

  const { pointX, pointY } = generateLinePoint(downX, downY)

  context.beginPath()
  context.moveTo(pointX, pointY)

  canvas.onmousemove = null
  canvas.onmousemove = (event: MouseEvent) => {
    const moveX: number = event.pageX - offsetLeft
    const moveY: number = event.pageY - offsetTop
    const { pointX, pointY } = generateLinePoint(moveX, moveY)
    context.globalCompositeOperation = "destination-out"
    context.lineWidth = lineWidth
    context.lineTo(pointX, pointY)
    context.stroke()
  }
  canvas.onmouseup = () => {
    context.closePath()
    canvas.onmousemove = null
    canvas.onmouseup = null
  }
}

8. 撤销与恢复的功能实现

这个的话,我们首先需要了解常见的撤销与恢复的功能的逻辑
分几种情况吧

  • 若当前状态处于第一个位置,则不允许撤销
  • 若当前状态处于最后一个位置,则不允许恢复
  • 如果当前撤销了,然而更新了状态,则取当前状态为最新的状态(也就是说不允许恢复了,这个刚更新的状态就是最新的)

画布状态的更新

所以我们需要设置一些变量来存,状态列表,与当前画笔的状态下标

// 定义参数存东东
const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)

我们还需要在初始化canvas的时候,我们就添加入当前的状态存入列表中,作为最先开始的空画布状态

const fillImage = async () => {
  // 省略一万字...

  img.src = await getURLBase64(fillImageSrc)
  img.onload = () => {
    const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
    canvasHistroyListRef.current = []
    canvasHistroyListRef.current.push(imageData)
    setCanvasCurrentHistory(1)
  }
}

然后我们就实现一下,画笔更新时候,我们也需要将当前的状态添加入画笔状态列表,并且更新当前状态对应的下标,还需要处理一下一些细节

总结一下:

  • 鼠标抬起时,获取当前canvas画布状态
  • 添加进状态列表中,并且更新状态下标
  • 如果当前处于撤销状态,若使用画笔更新状态,则将当前的最为最新的状态,原先位置之后的状态全部清空
const handleLineMode = (downX: number, downY: number) => {
  // 省略一万字...
  canvas.onmouseup = () => {
    const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)

    // 如果此时处于撤销状态,此时再使用画笔,则将之后的状态清空,以刚画的作为最新的画布状态
    if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
      canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
    }
    canvasHistroyListRef.current.push(imageData)
    setCanvasCurrentHistory(canvasCurrentHistory + 1)
    context.closePath()
    canvas.onmousemove = null
    canvas.onmouseup = null
  }
}

画布状态的撤销与恢复

ok,其实现在关于画布状态的更新,我们已经完成了。接下来我们需要处理一下状态的撤销与恢复的功能啦

我们先定义一下这个工具栏吧

然后我们设置对应的事件,分别是撤销,恢复,与清空,其实都很容易看懂,最多就是处理一下边界情况。

const handleRollBack = () => {
  const isFirstHistory: boolean = canvasCurrentHistory === 1
  if (isFirstHistory) return
  setCanvasCurrentHistory(canvasCurrentHistory - 1)
}

const handleRollForward = () => {
  const { current: canvasHistroyList } = canvasHistroyListRef
  const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
  if (isLastHistory) return
  setCanvasCurrentHistory(canvasCurrentHistory + 1)
}

const handleClearCanvasClick = () => {
  const { current: canvas } = canvasRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !context || canvasCurrentHistory === 0) return

  // 清空画布历史
  canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
  setCanvasCurrentHistory(1)

  message.success('画布清除成功!')
}

事件设置好之后,我们就可以开始监听一下这个canvasCurrentHistory当前状态下标,使用副作用函数进行处理

useEffect(() => {
  const { current: canvas } = canvasRef
  const { current: canvasHistroyList } = canvasHistroyListRef
  const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
  if (!canvas || !context || canvasCurrentHistory === 0) return
  context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
}, [canvasCurrentHistory])

为canvas画布填充图像信息!

这样就大功告成啦!!!

9. 实现鼠标图标的变化

我们简单的处理一下,画笔模式则是画笔的图标,橡皮擦模式下鼠标是橡皮擦,移动模式下就是普通的移动图标

切换模式时候,设置一下不同的图标

const handleMouseModeChange = (event: RadioChangeEvent) => {
  const { target: { value } } = event
  const { current: canvas } = canvasRef
  const { current: wrap } = wrapRef

  setmouseMode(value)

  if (!canvas || !wrap) return
  switch (value) {
    case MOVE_MODE:
      canvas.style.cursor = 'move'
      wrap.style.cursor = 'move'
      break
    case LINE_MODE:
      canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`
      wrap.style.cursor = 'default'
      break
    case ERASER_MODE:
      message.warning('橡皮擦功能尚未完善,保存图片会出现错误')
      canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
      wrap.style.cursor = 'default'
      break
    default:
      canvas.style.cursor = 'default'
      wrap.style.cursor = 'default'
      break
  }
}

10. 切换图片

现在的话只是一个demo状态,通过点击选择框,切换不同的图片

// 重置变换参数,重新绘制图片
useEffect(() => {
  setIsLoading(true)
  translatePointXRef.current = 0
  translatePointYRef.current = 0
  fillStartPointXRef.current = 0
  fillStartPointYRef.current = 0
  setCanvasScale(1)
  fillImage()
}, [fillImageSrc])

const handlePaperChange = (value: string) => {
  const fillImageList = {
    'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
    'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
    'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
  }
  setFillImageSrc(fillImageList[value])
}

注意事项

注意容器的偏移量

我们需要注意一下,因为公式中的downX是相对容器的坐标,也就是说,我们需要减去容器的偏移量,这种情况会出现在使用了margin等参数,或者说上方或者左侧有别的元素的情况

我们输出一下我们红色的元素的offsetLeft等属性,会发现他是已经本身就有50的偏移量了,我们计算鼠标点击的坐标的时候就要减去这一部分的偏移量

window.onload = function () {
  const test = document.getElementById('test')
  console.log(`offsetLeft: ${test.offsetLeft}, offsetHeight: ${test.offsetTop}`)
}

html,
body {
  margin: 0;
  padding: 0;
}

#test {
  width: 50px;
  height: 50px;
  margin-left: 50px;
  background: red;
}

<div class="container">
  <div id="test"></div>
</div>

注意父组件使用relative相对布局的情况

假如我们现在有一种这种的布局,打印红色元素的偏移量,看起来都挺正常的

但是如果我们目标元素的父元素(也就是黄色部分)设置relative相对布局

.wrap {
  position: relative;
  width: 400px;
  height: 300px;
  background: yellow;
}

<div class="container">
  <div class="sider"></div>
  <div class="wrap">
    <div id="test"></div>
  </div>
</div>

这时候我们打印出来的偏移量会是多少呢

两次答案不一样啊,因为我们的偏移量是根据相对位置来计算的,如果父容器使用相对布局,则会影响我们子元素的偏移量

组件代码(低配版)

import React, { FC, ComponentType, useEffect, useRef, RefObject, useState, MutableRefObject } from 'react'
import { CustomBreadcrumb } from '@/admin/components'
import { RouteComponentProps } from 'react-router-dom';
import { FormComponentProps } from 'antd/lib/form';
import {
  Slider, Radio, Button, Tooltip, Icon, Select, Spin, message, Popconfirm
} from 'antd';

import './index.scss'
import { RadioChangeEvent } from 'antd/lib/radio';
import { getURLBase64 } from '@/admin/utils/getURLBase64'

const { Option, OptGroup } = Select;

type MarkPaperProps = RouteComponentProps & FormComponentProps

const MarkPaper: FC<MarkPaperProps> = (props: MarkPaperProps) => {
  const MOVE_MODE: number = 0
  const LINE_MODE: number = 1
  const ERASER_MODE: number = 2
  const canvasRef: RefObject<HTMLCanvasElement> = useRef(null)
  const containerRef: RefObject<HTMLDivElement> = useRef(null)
  const wrapRef: RefObject<HTMLDivElement> = useRef(null)
  const translatePointXRef: MutableRefObject<number> = useRef(0)
  const translatePointYRef: MutableRefObject<number> = useRef(0)
  const fillStartPointXRef: MutableRefObject<number> = useRef(0)
  const fillStartPointYRef: MutableRefObject<number> = useRef(0)
  const canvasHistroyListRef: MutableRefObject<ImageData[]> = useRef([])
  const [lineColor, setLineColor] = useState<string>('#fa4b2a')
  const [fillImageSrc, setFillImageSrc] = useState<string>('')
  const [mouseMode, setmouseMode] = useState<number>(MOVE_MODE)
  const [lineWidth, setLineWidth] = useState<number>(5)
  const [canvasScale, setCanvasScale] = useState<number>(1)
  const [isLoading, setIsLoading] = useState<boolean>(false)
  const [canvasCurrentHistory, setCanvasCurrentHistory] = useState<number>(0)

  useEffect(() => {
    setFillImageSrc('http://cdn.algbb.cn/test/canvasTest.jpg')
  }, [])

  // 重置变换参数,重新绘制图片
  useEffect(() => {
    setIsLoading(true)
    translatePointXRef.current = 0
    translatePointYRef.current = 0
    fillStartPointXRef.current = 0
    fillStartPointYRef.current = 0
    setCanvasScale(1)
    fillImage()
  }, [fillImageSrc])

  // 画布参数变动时,重新监听canvas
  useEffect(() => {
    handleCanvas()
  }, [mouseMode, canvasScale, canvasCurrentHistory])

  // 监听画笔颜色变化
  useEffect(() => {
    const { current: canvas } = canvasRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    if (!context) return

    context.strokeStyle = lineColor
    context.lineWidth = lineWidth
    context.lineJoin = 'round'
    context.lineCap = 'round'
  }, [lineWidth, lineColor])

  //监听缩放画布
  useEffect(() => {
    const { current: canvas } = canvasRef
    const { current: translatePointX } = translatePointXRef
    const { current: translatePointY } = translatePointYRef
    canvas && (canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointX}px,${translatePointY}px)`)
  }, [canvasScale])

  useEffect(() => {
    const { current: canvas } = canvasRef
    const { current: canvasHistroyList } = canvasHistroyListRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    if (!canvas || !context || canvasCurrentHistory === 0) return
    context?.putImageData(canvasHistroyList[canvasCurrentHistory - 1], 0, 0)
  }, [canvasCurrentHistory])

  const fillImage = async () => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    const img: HTMLImageElement = new Image()

    if (!canvas || !wrap || !context) return

    img.src = await getURLBase64(fillImageSrc)
    img.onload = () => {
      // 取中间渲染图片
      // const centerX: number = canvas && canvas.width / 2 - img.width / 2 || 0
      // const centerY: number = canvas && canvas.height / 2 - img.height / 2 || 0
      canvas.width = img.width
      canvas.height = img.height

      // 背景设置为图片,橡皮擦的效果才能出来
      canvas.style.background = `url(${img.src})`
      context.drawImage(img, 0, 0)
      context.strokeStyle = lineColor
      context.lineWidth = lineWidth
      context.lineJoin = 'round'
      context.lineCap = 'round'

      // 设置变化基点,为画布容器**
      canvas.style.transformOrigin = `${wrap?.offsetWidth / 2}px ${wrap?.offsetHeight / 2}px`
      // 清除上一次变化的效果
      canvas.style.transform = ''
      const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
      canvasHistroyListRef.current = []
      canvasHistroyListRef.current.push(imageData)
      // canvasCurrentHistoryRef.current = 1
      setCanvasCurrentHistory(1)
      setTimeout(() => { setIsLoading(false) }, 500)
    }
  }

  const generateLinePoint = (x: number, y: number) => {
    const { current: wrap } = wrapRef
    const { current: translatePointX } = translatePointXRef
    const { current: translatePointY } = translatePointYRef
    const wrapWidth: number = wrap?.offsetWidth || 0
    const wrapHeight: number = wrap?.offsetHeight || 0
    // 缩放位移坐标变化规律
    // (transformOrigin - downX) / scale * (scale-1) + downX - translateX = pointX
    const pointX: number = ((wrapWidth / 2) - x) / canvasScale * (canvasScale - 1) + x - translatePointX
    const pointY: number = ((wrapHeight / 2) - y) / canvasScale * (canvasScale - 1) + y - translatePointY

    return {
      pointX,
      pointY
    }
  }

  const handleLineMode = (downX: number, downY: number) => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    if (!canvas || !wrap || !context) return

    const offsetLeft: number = canvas.offsetLeft
    const offsetTop: number = canvas.offsetTop
    // 减去画布偏移的距离(以画布为基准进行计算坐标)
    downX = downX - offsetLeft
    downY = downY - offsetTop

    const { pointX, pointY } = generateLinePoint(downX, downY)
    context.globalCompositeOperation = "source-over"
    context.beginPath()
    context.moveTo(pointX, pointY)

    canvas.onmousemove = null
    canvas.onmousemove = (event: MouseEvent) => {
      const moveX: number = event.pageX - offsetLeft
      const moveY: number = event.pageY - offsetTop
      const { pointX, pointY } = generateLinePoint(moveX, moveY)
      context.lineTo(pointX, pointY)
      context.stroke()
    }
    canvas.onmouseup = () => {
      const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)

      // 如果此时处于撤销状态,此时再使用画笔,则将之后的状态清空,以刚画的作为最新的画布状态
      if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
        canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
      }
      canvasHistroyListRef.current.push(imageData)
      setCanvasCurrentHistory(canvasCurrentHistory + 1)
      context.closePath()
      canvas.onmousemove = null
      canvas.onmouseup = null
    }
  }

  const handleMoveMode = (downX: number, downY: number) => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef
    const { current: fillStartPointX } = fillStartPointXRef
    const { current: fillStartPointY } = fillStartPointYRef
    if (!canvas || !wrap || mouseMode !== 0) return

    // 为容器添加移动事件,可以在空白处移动图片
    wrap.onmousemove = (event: MouseEvent) => {
      const moveX: number = event.pageX
      const moveY: number = event.pageY

      translatePointXRef.current = fillStartPointX + (moveX - downX)
      translatePointYRef.current = fillStartPointY + (moveY - downY)

      canvas.style.transform = `scale(${canvasScale},${canvasScale}) translate(${translatePointXRef.current}px,${translatePointYRef.current}px)`
    }

    wrap.onmouseup = (event: MouseEvent) => {
      const upX: number = event.pageX
      const upY: number = event.pageY

      wrap.onmousemove = null
      wrap.onmouseup = null;

      fillStartPointXRef.current = fillStartPointX + (upX - downX)
      fillStartPointYRef.current = fillStartPointY + (upY - downY)
    }
  }

  // 目前橡皮擦还有点问题,前端显示正常,保存图片下来,擦除的痕迹会变成白色
  const handleEraserMode = (downX: number, downY: number) => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    if (!canvas || !wrap || !context) return

    const offsetLeft: number = canvas.offsetLeft
    const offsetTop: number = canvas.offsetTop
    downX = downX - offsetLeft
    downY = downY - offsetTop

    const { pointX, pointY } = generateLinePoint(downX, downY)

    context.beginPath()
    context.moveTo(pointX, pointY)

    canvas.onmousemove = null
    canvas.onmousemove = (event: MouseEvent) => {
      const moveX: number = event.pageX - offsetLeft
      const moveY: number = event.pageY - offsetTop
      const { pointX, pointY } = generateLinePoint(moveX, moveY)
      context.globalCompositeOperation = "destination-out"
      context.lineWidth = lineWidth
      context.lineTo(pointX, pointY)
      context.stroke()
    }
    canvas.onmouseup = () => {
      const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height)
      if (canvasCurrentHistory < canvasHistroyListRef.current.length) {
        canvasHistroyListRef.current = canvasHistroyListRef.current.slice(0, canvasCurrentHistory)
      }
      canvasHistroyListRef.current.push(imageData)
      setCanvasCurrentHistory(canvasCurrentHistory + 1)
      context.closePath()
      canvas.onmousemove = null
      canvas.onmouseup = null
    }
  }

  const handleCanvas = () => {
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    if (!context || !wrap) return

    // 清除上一次设置的监听,以防获取参数错误
    wrap.onmousedown = null
    wrap.onmousedown = function (event: MouseEvent) {
      const downX: number = event.pageX
      const downY: number = event.pageY

      switch (mouseMode) {
        case MOVE_MODE:
          handleMoveMode(downX, downY)
          break
        case LINE_MODE:
          handleLineMode(downX, downY)
          break
        case ERASER_MODE:
          handleEraserMode(downX, downY)
          break
        default:
          break
      }
    }

    wrap.onwheel = null
    wrap.onwheel = (e: MouseWheelEvent) => {
      const { deltaY } = e
      const newScale: number = deltaY > 0
        ? (canvasScale * 10 - 0.1 * 10) / 10
        : (canvasScale * 10 + 0.1 * 10) / 10
      if (newScale < 0.1 || newScale > 2) return
      setCanvasScale(newScale)
    }
  }

  const handleScaleChange = (value: number) => {
    setCanvasScale(value)
  }

  const handleLineWidthChange = (value: number) => {
    setLineWidth(value)
  }

  const handleColorChange = (color: string) => {
    setLineColor(color)
  }

  const handleMouseModeChange = (event: RadioChangeEvent) => {
    const { target: { value } } = event
    const { current: canvas } = canvasRef
    const { current: wrap } = wrapRef

    setmouseMode(value)

    if (!canvas || !wrap) return
    switch (value) {
      case MOVE_MODE:
        canvas.style.cursor = 'move'
        wrap.style.cursor = 'move'
        break
      case LINE_MODE:
        canvas.style.cursor = `url('http://cdn.algbb.cn/pencil.ico') 6 26, pointer`
        wrap.style.cursor = 'default'
        break
      case ERASER_MODE:
        message.warning('橡皮擦功能尚未完善,保存图片会出现错误')
        canvas.style.cursor = `url('http://cdn.algbb.cn/eraser.ico') 6 26, pointer`
        wrap.style.cursor = 'default'
        break
      default:
        canvas.style.cursor = 'default'
        wrap.style.cursor = 'default'
        break
    }
  }

  const handleSaveClick = () => {
    const { current: canvas } = canvasRef
    // 可存入数据库或是直接生成图片
    console.log(canvas?.toDataURL())
  }

  const handlePaperChange = (value: string) => {
    const fillImageList = {
      'xueshengjia': 'http://cdn.algbb.cn/test/canvasTest.jpg',
      'xueshengyi': 'http://cdn.algbb.cn/test/canvasTest2.png',
      'xueshengbing': 'http://cdn.algbb.cn/emoji/30.png',
    }
    setFillImageSrc(fillImageList[value])
  }

  const handleRollBack = () => {
    const isFirstHistory: boolean = canvasCurrentHistory === 1
    if (isFirstHistory) return
    setCanvasCurrentHistory(canvasCurrentHistory - 1)
  }

  const handleRollForward = () => {
    const { current: canvasHistroyList } = canvasHistroyListRef
    const isLastHistory: boolean = canvasCurrentHistory === canvasHistroyList.length
    if (isLastHistory) return
    setCanvasCurrentHistory(canvasCurrentHistory + 1)
  }

  const handleClearCanvasClick = () => {
    const { current: canvas } = canvasRef
    const context: CanvasRenderingContext2D | undefined | null = canvas?.getContext('2d')
    if (!canvas || !context || canvasCurrentHistory === 0) return

    // 清空画布历史
    canvasHistroyListRef.current = [canvasHistroyListRef.current[0]]
    setCanvasCurrentHistory(1)

    message.success('画布清除成功!')
  }

  return (
    <div>
      <CustomBreadcrumb list={['内容管理', '批阅作业']} />
      <div className="mark-paper__container" ref={containerRef}>
        <div className="mark-paper__wrap" ref={wrapRef}>
          <div
            className="mark-paper__mask"
            style={{ display: isLoading ? 'flex' : 'none' }}
          >
            <Spin
              tip="图片加载中..."
              indicator={<Icon type="loading" style={{ fontSize: 36 }} spin
              />}
            />
          </div>
          <canvas
            ref={canvasRef}
            className="mark-paper__canvas">
            <p>很可惜,这个东东与您的电脑不搭!</p>
          </canvas>
        </div>
        <div className="mark-paper__sider">
          <div>
            选择作业:
            <Select
              defaultValue="xueshengjia"
              style={{
                width: '100%', margin: '10px 0 20px 0'
              }}
              onChange={handlePaperChange} >
              <OptGroup label="17软件一班">
                <Option value="xueshengjia">学生甲</Option>
                <Option value="xueshengyi">学生乙</Option>
              </OptGroup>
              <OptGroup label="17软件二班">
                <Option value="xueshengbing">学生丙</Option>
              </OptGroup>
            </Select>
          </div>
          <div>
            画布操作:<br />
            <div className="mark-paper__action">
              <Tooltip title="撤销">
                <i
                  className={`icon iconfont icon-chexiao ${canvasCurrentHistory <= 1 && 'disable'}`}
                  onClick={handleRollBack} />
              </Tooltip>
              <Tooltip title="恢复">
                <i
                  className={`icon iconfont icon-fanhui ${canvasCurrentHistory >= canvasHistroyListRef.current.length && 'disable'}`}
                  onClick={handleRollForward} />
              </Tooltip>
              <Popconfirm
                title="确定清空画布吗?"
                onConfirm={handleClearCanvasClick}
                okText="确定"
                cancelText="取消"
              >
                <Tooltip title="清空">
                  <i className="icon iconfont icon-qingchu" />
                </Tooltip>
              </Popconfirm>
            </div>
          </div>
          <div>
            画布缩放:
            <Tooltip placement="top" title='可用鼠标滚轮进行缩放'>
              <Icon type="question-circle" />
            </Tooltip>
            <Slider
              min={0.1}
              max={2.01}
              step={0.1}
              value={canvasScale}
              tipFormatter={(value) => `${(value).toFixed(2)}x`}
              onChange={handleScaleChange} />
          </div>
          <div>
            画笔大小:
            <Slider
              min={1}
              max={9}
              value={lineWidth}
              tipFormatter={(value) => `${value}px`}
              onChange={handleLineWidthChange} />
          </div>
          <div>
            模式选择:
            <Radio.Group
              className="radio-group"
              onChange={handleMouseModeChange}
              value={mouseMode}>
              <Radio value={0}>移动</Radio>
              <Radio value={1}>画笔</Radio>
              <Radio value={2}>橡皮擦</Radio>
            </Radio.Group>
          </div>
          <div>
            颜色选择:
            <div className="color-picker__container">
              {['#fa4b2a', '#ffff00', '#ee00ee', '#1890ff', '#333333', '#ffffff'].map(color => {
                return (
                  <Tooltip placement="top" title={color} key={color}>
                    <div
                      role="button"
                      className={`color-picker__wrap ${color === lineColor && 'color-picker__wrap--active'}`}
                      style={{ background: color }}
                      onClick={() => handleColorChange(color)}
                    />
                  </Tooltip>
                )
              })}
            </div>
          </div>
          <Button onClick={handleSaveClick}>保存图片</Button>
        </div>
      </div>
    </div >
  )
}

export default MarkPaper as ComponentType

结语

如果这篇东东对大家有所帮助,希望大家可以给我点赞一下鼓励一下!

或者给俺的项目点个star支持支持吧!

https://github.com/zhcxk1998/School-Partners

菜鸡分析的不到位,还请各位大佬指出俺的不足!阿里嘎多~

测试账号

你好,系统登录需要账号,可以提供账号密码吗?

报错 Error: listen EADDRNOTAVAIL: address not available 42.192.189.187:8082

? ?wds?: Error: listen EADDRNOTAVAIL: address not available 42.192.189.187:8082
at Server.setupListenHandle [as _listen2] (net.js:1300:21)
at listenInCluster (net.js:1365:12)
at doListen (net.js:1502:7)
at processTicksAndRejections (internal/process/task_queues.js:85:21) {
code: 'EADDRNOTAVAIL',
errno: 'EADDRNOTAVAIL',
syscall: 'listen',
address: '42.192.189.187',
port: 8082
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.