React Hooks 实现在图片上手动框选矩形区域

342 阅读9分钟

前言

公司项目需求,在图片上手动框选矩形区域,并拿到相对于图片的坐标点,支持回显编辑删除,绑定设备等操作,本文使用 React Hooks写法


一、环境

1、React 16.12.0 2、Antd design 4.20.0

二、效果

live.csdn.net/v/360388

三、实现

1、JSX 文件

import React, { useState, useRef, useEffect } from "react";
import { Button, message, Tooltip, Spin, Switch, Collapse } from "antd";
import { EditOutlined, DeleteOutlined, CheckOutlined, DragOutlined } from "@ant-design/icons";
import { Resizable } from "react-resizable"; //缩放插件
import Draggable from "react-draggable"; //拖拽插件
import "react-resizable/css/styles.css";
import "./index.less";
import List from "@/components/list"; //左侧列表组件
import { uuid } from "@/util"; //生成唯一ID方法
import { cloneDeep, uniqBy } from "lodash"; //深拷贝、数组去重方法
import DeviceBind from "./deviceBind"; //绑定设备弹窗
import hostDao from "./service";  //接口请求axios
import noState from "./noData.svg";  //未连接图

const HostManage = () => {
  const timeInterRef = useRef(null); //轮询ref
  const imgRef = useRef(null); //图片ref
  const boxRef = useRef(null); //画布ref
  const deviceBindRef = useRef(null); //绑定设备ref

  const [deviceList, setDeviceList] = useState([]); //设备列表
  const [list, setList] = useState([]);   // 左侧list列表
  const [listActive, setListActive] = useState(null); //列表选中

  const [loading, setLoading] = useState(false);
  const [imgUrl, setImgUrl] = useState(null); //图片地址
  // 框列表
  const [frameList, setFrameList] = useState([
    // {
    //   frontId: "21291339",
    //   deviceId: 26,
    //   range: {
    //     width: 279,
    //     height: 127,
    //     left: 50,
    //     top: 42,
    //   },
    // },
    // {
    //   frontId: "52429620",
    //   deviceId: 22,
    //   range: {
    //     width: 368,
    //     height: 226,
    //     left: 200,
    //     top: 188,
    //   },
    // },
  ]);

  const [boxStyle, setBoxStyle] = useState({ width: 0, height: 0 }); // 画布style,与img大小永远保持一致
  const [frameStyle, setFrameStyle] = useState(null); //绘制元素 style
  const [itemActive, setItemActive] = useState(null); //选中框

  // 画布与图片大小永远保持一致,因为左侧菜单与列表允许收起,故需监听图片变化
  useEffect(() => {
    const observer = new ResizeObserver((entries) => {
      const lastEntry = entries[entries.length - 1];
      const { width, height } = lastEntry.contentRect;
      setBoxStyle({ width, height }); // 修改画布宽高
    });

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }
    return () => {
      if (imgRef.current) {
        observer.unobserve(imgRef.current);
      }
    };
  }, []);

  // 画布 大小变化后,框的大小及其位置也要等比例改变
  useEffect(() => {
    if (boxRef.current) {
      const { width: boxWidth, height: boxHeight } = boxRef.current.getBoundingClientRect();

      const mapList = frameList.map((v) => {
        const [left, top, width, height] = v.proportion;

        const L = left * boxWidth; //距离左侧距离
        const T = top * boxHeight; //距离顶部距离
        const W = width * boxWidth; //元素宽度
        const H = height * boxHeight; //元素高度

        return {
          ...v,
          range: { left: L, top: T, width: W, height: H },
        };
      });
      setFrameList(mapList);
    }
  }, [boxStyle]);

  useEffect(() => {
    initDeviceList(); //获取设备列表
    getHostList(); //获取主机列表

    return () => {
      if (timeInterRef.current) {
        clearInterval(timeInterRef.current);
      }
    };
  }, []);
  // 轮询查主机状态,判断是已连接或未连接
  useEffect(() => {
    if (!timeInterRef.current && list.length) {
      timeInterRef.current = setInterval(() => {
        forHostList(); //5s循环请求一次
      }, 5000);
    }
  }, [list]);

  // 获取主机列表
  const getHostList = async () => {
    const res = await hostDao.hostList();
    if (res && res.status.code === 200 && res.data.length) {
      const mapList = res.data.map((v) => {
        return {
          ...v,
          label: v.name,
        };
      });
      setList(mapList);
      slideChange(mapList[0]);
    }
  };

  // 轮询查主机列表
  const forHostList = async () => {
    const res = await hostDao.hostList();
    if (res && res.status.code === 200 && res.data.length) {
      const mapList = cloneDeep(list).map((v) => {
        const thisData = res.data.find((h) => h.id === v.id);
        return {
          ...v,
          state: thisData.state,
          connectionState: thisData.connectionState,
        };
      });
      setList(mapList);
    }
  };

  // 主机列表切换
  const slideChange = (data) => {
    setListActive(data.id); //设置左侧列表选中
    setImgUrl(data.pictureUrl); //设置图片地址
    getBoxList(data.id); //查询框列表
  };

  // 获取框列表
  const getBoxList = async (id) => {
    setLoading(true);
    const res = await hostDao.boxList({ pathParams: { hostId: id } });
    if (res && res.status.code === 200) {
      const { width: boxWidth, height: boxHeight } = boxRef.current.getBoundingClientRect();
      const mapList = res.data.map((v) => {
        const [left, top, width, height] = v.coordinate.split(",");
        const L = left * boxWidth; //距离左侧距离
        const T = top * boxHeight; //距离顶部距离
        const W = width * boxWidth; //元素宽度
        const H = height * boxHeight; //元素高度

        return {
          frontId: uuid(8, 10),
          deviceId: v.deviceId,
          range: {
            left: L,
            top: T,
            width: W,
            height: H,
          },
          proportion: v.coordinate.split(","), //存储比例,图片缩放时使用
        };
      });
      setFrameList(mapList);
    }
    setLoading(false);
  };

  // 获取设备列表
  const initDeviceList = async () => {
    const requestData = {
      name: "",
      latitude: "",
      longitude: "",
      pageNum: 0,
      pageSize: 0,
    };
    const res = await hostDao.deviceList({ data: requestData });
    if (res && res.status.code === 200) {
      setDeviceList(res.data.list);
    }
  };

  // 点击开始
  const startDraw = (e) => {
    const { clientX, clientY } = e;
    const { left, top, width, height } = boxRef.current.getBoundingClientRect();
    const range = {
      left: clientX - left,
      top: clientY - top,
      width: 0,
      height: 0,
    };
    // 开始画框
    const mousemove = (evt) => {
      let { clientX: x, clientY: y } = evt;
      if (x < left) {
        x = left;
      }
      if (y < top) {
        y = top;
      }

      if (x > left + width) {
        x = left + width;
      }
      if (y > top + height) {
        y = top + height;
      }

      const distanceX = clientX - x;
      const distanceY = clientY - y;
      range.width = Math.abs(distanceX);
      range.height = Math.abs(distanceY);
      if (distanceX >= 0) {
        range.left = clientX - left - Math.abs(distanceX);
      }
      if (distanceY >= 0) {
        range.top = clientY - top - Math.abs(distanceY);
      }

      // console.log(range);

      setFrameStyle(cloneDeep(range));
    };
    // 松手,结束画框
    const mouseup = (e) => {
      if (range.width >= 60 && range.height >= 60) {
        // 打开绑定设备弹窗
        bindDevice(range);
      } else {
        setFrameStyle(null);
        message.warning("最小绘制宽高为60x60,请拖拽绘制");
      }

      window.removeEventListener("mousemove", mousemove);
      window.removeEventListener("mouseup", mouseup);
    };

    window.addEventListener("mousemove", mousemove);
    window.addEventListener("mouseup", mouseup);
  };
  // 新增绑定设备
  const bindDevice = (data) => {
    const { left, top, width, height } = data;
    const { width: boxWidth, height: boxHeight } = boxRef.current.getBoundingClientRect();

    const addItem = {
      frontId: null,
      deviceId: null,
      range: data,
      proportion: [left / boxWidth, top / boxHeight, width / boxWidth, height / boxHeight],
    };
    setDeviceDisabled(); //设备选择disabled设置
    deviceBindRef.current.getPage(addItem);
  };
  // 编辑框
  const editItem = (e, data) => {
    e.stopPropagation();

    setDeviceDisabled(data.deviceId); //设备选择disabled设置
    deviceBindRef.current.getPage(data);
  };
  //设备只允许绑定一次,如已绑定则设置为disabled
  const setDeviceDisabled = (deviceId) => {
    let disabledData = frameList.map((v) => v.deviceId);

    // 编辑
    if (deviceId) {
      disabledData = disabledData.filter((h) => h !== deviceId);
    }
    const mapList = deviceList.map((v) => ({ ...v, disabled: disabledData.includes(v.id) }));

    setDeviceList(mapList);
  };
  // 新增/编辑 完成绑定设备
  const emitBind = (data) => {
    // 弹窗点确定
    if (data) {
      const { frontId } = data;
      const mapList = cloneDeep(frameList);
      // 新增
      if (!frontId) {
        mapList.push({
          ...data,
          frontId: uuid(8, 10),
        });
      }
      // 编辑
      else {
        const editIndex = mapList.findIndex((v) => v.frontId === frontId);
        mapList[editIndex] = {
          ...mapList[editIndex],
          ...data,
        };
      }
      setFrameList(mapList);
    }
    setFrameStyle(null);
  };
  // 删除框
  const deleteItem = (e, data) => {
    e.stopPropagation();
    const { frontId } = data;

    const mapList = cloneDeep(frameList).filter((v) => v.frontId !== frontId);

    setFrameList(mapList);
  };
  // 框点击
  const itemChange = (v) => {
    setItemActive(v.frontId);
  };
  // 拖拽完毕
  const boxDrag = (e, data, item) => {
    const { width: boxWidth, height: boxHeight } = boxRef.current.getBoundingClientRect();

    const { x, y } = data;
    // console.log("拖位置呢---", x, y);
    const { frontId } = item;
    const dragIndex = frameList.findIndex((v) => v.frontId === frontId);
    const mapList = cloneDeep(frameList);

    mapList[dragIndex] = {
      ...mapList[dragIndex],
      range: {
        ...mapList[dragIndex].range,
        left: x,
        top: y,
      },
      proportion: [x / boxWidth, y / boxHeight, ...mapList[dragIndex].proportion.slice(2)],
    };
    setFrameList(mapList);
  };
  // 框大小正在拖拽
  const onResize = (event, { size }, item) => {
    event.stopPropagation();

    const { width, height } = size;
    const { frontId } = item;

    const { width: boxWidth, height: boxHeight } = boxRef.current.getBoundingClientRect();

    const mapList = cloneDeep(frameList).map((v) => {
      if (v.frontId === frontId) {
        v = {
          ...v,
          range: {
            ...v.range,
            width,
            height,
          },
          proportion: [...v.proportion.slice(0, 2), width / boxWidth, height / boxHeight],
        };
      }
      return v;
    });

    setFrameList(mapList);
  };
  //  保存
  const save = async (e, activeData) => {
    e.stopPropagation();

    if (isOverlap()) {
      return; //框重叠了
    }

    const boxData = assemble();
    // console.log("boxData", boxData);

    const res = await hostDao.saveBox({ data: { hostId: listActive, screens: boxData } });
    if (res && res.status.code === 200) {
      slideChange(activeData); //刷新数据
      message.success("保存成功");
    } else {
      message.error("保存失败,请重试");
    }
  };
  //  启用/禁用
  const enable = async (checked, e) => {
    e.stopPropagation();
    // 是否重叠
    if (isOverlap()) {
      return;
    }
    const boxData = assemble();

    const stateCn = checked ? "启用" : "禁用";

    const res = await hostDao.saveBox({ data: { hostId: listActive, screens: boxData } });
    if (res && res.status.code === 200) {
      const statusRes = await hostDao.enableHost({ data: { id: listActive, state: checked ? 1 : 0 } });
      if (statusRes && statusRes.status.code === 200) {
        // 修改开关状态
        const mapList = cloneDeep(list);
        const checkIndex = mapList.findIndex((v) => v.id === listActive);
        mapList[checkIndex].state = checked;
        setList(mapList);

        slideChange(mapList[checkIndex]); //刷新数据
        message.success(`${stateCn}成功`);
      } else {
        message.error(`${stateCn}失败,请重试`);
      }
    } else {
      message.error(`${stateCn}失败,请重试`);
    }
  };
  // 入参数据组装
  const assemble = () => {
    // console.log("list-------", frameList, v);
    const { width: boxWidth, height: boxHeight } = boxRef.current.getBoundingClientRect();
    const boxData = frameList.map((v) => {
      const {
        deviceId,
        range: { left, top, width, height },
      } = v;

      const L = left / boxWidth; //距离左侧距离
      const T = top / boxHeight; //距离顶部距离
      const W = width / boxWidth; //元素宽度
      const H = height / boxHeight; //元素高度
      const coordinates = [L, T, W, H].join(",");

      return {
        hostId: listActive,
        deviceId,
        coordinate: coordinates,
      };
    });
    return boxData;
  };
  // 框防重叠
  const isOverlap = () => {
    const list = cloneDeep(frameList);
    const overlappingElements = [];

    for (let i = 0; i < list.length - 1; i++) {
      for (let j = i + 1; j < list.length; j++) {
        const rect1 = list[i].range;
        const rect2 = list[j].range;

        if (
          rect1.left < rect2.left + rect2.width &&
          rect1.left + rect1.width > rect2.left &&
          rect1.top < rect2.top + rect2.height &&
          rect1.top + rect1.height > rect2.top
        ) {
          // 发生重叠
          overlappingElements.push(list[i], list[j]);
        }
      }
    }

    const overlap = uniqBy(overlappingElements.map((v) => v.frontId));
    // 如果框之间有位置重叠,则边框标红
    if (overlap.length) {
      const mapList = cloneDeep(frameList).map((v) => ({ ...v, isOverlap: overlap.includes(v.frontId) }));
      setFrameList(mapList);
      message.warning("您绘制的框之间有重叠,已为您标为红色,请编辑后重试");
      return true;
    }
    return false;
  };

  // DOM渲染

  // 左侧列表render
  const listRender = (v) => {
    const disabledCondition = listActive !== v.id || v.connectionState !== 1;
    const connectionStateCn = v.connectionState === 1 ? "已连接" : "未连接";
    const connectionStateColor = v.connectionState === 1 ? "#67c23a" : "#F56C6C";
    return (
      <div className="left-render">
        <div className="item-left">
          <p title={v.label}>{v.label}</p>
        </div>
        <div className="item-right">
          <Tooltip title={connectionStateCn}>
            <span className="state" style={{ backgroundColor: connectionStateColor }}></span>
          </Tooltip>
          <Tooltip title="保存">
            <Button
              type="primary"
              shape="circle"
              icon={<CheckOutlined />}
              disabled={disabledCondition}
              onClick={(e) => {
                save(e, v);
              }}
            />
          </Tooltip>
          <Tooltip title="启用/禁用">
            <Switch size="small" disabled={disabledCondition} checked={v.state} onChange={enable} />
          </Tooltip>
        </div>
      </div>
    );
  };

  // 框style
  const boxItemStyle = (v) => ({
    ...v.range,
    left: 0, //由Draggeable接管位置渲染,故box-item类不需要渲染left和top
    top: 0,
    borderWidth: itemActive === v.frontId ? "2px" : "1px", //边框宽度
    borderColor: v.isOverlap ? "#F56C6C" : "#409eff", //边框色,重叠时将变为红色
    zIndex: itemActive === v.frontId ? 500 : 100,
  });

  return (
    <div className="host-manage">
      <div className="manage-list">
        <List title="主机管理" filterable renderList={listRender} active={listActive} data={list} onChange={slideChange}></List>
      </div>
      <div className="manage-img">
        <Spin spinning={loading} tip="正在加载中...">
          <img src={imgUrl} ref={imgRef} alt="" />
          <div className="canvas" style={boxStyle} ref={boxRef} onMouseDown={startDraw}>
            {/* 绘制div */}
            {frameStyle && (
              <div className="drag-box" style={frameStyle}>
                <div className="border-box"></div>
              </div>
            )}
            {/* 绘制完毕展示的div */}
            {frameList.map((v) => {
              const {
                frontId,
                deviceId,
                range: { width, height, left, top },
              } = v;
              const deviceName = deviceList.find((h) => h.id === deviceId)?.name;
              const isCurrent = itemActive === v.frontId;
              const resizeHandles = isCurrent ? ["s", "w", "e", "sw", "nw", "se", "ne"] : [];

              return (
                <Draggable
                  key={frontId}
                  bounds="parent"
                  handle={".anticon-drag"}
                  position={{ x: left, y: top }}
                  onMouseDown={(e) => e.stopPropagation()}
                  onStop={(e, data) => boxDrag(e, data, v)}
                >
                  <Resizable
                    width={width}
                    height={height}
                    minConstraints={[60, 60]}
                    maxConstraints={[boxStyle.width - left, boxStyle.height - top]} //最大宽度和高度,实时减
                    resizeHandles={resizeHandles}
                    onResize={(event, { size }) => onResize(event, { size }, v)}
                  >
                    <div className="box-item" style={boxItemStyle(v)} onClick={() => itemChange(v)}>
                      <Tooltip placement="topLeft" title={deviceName}>
                        <p className="text">{deviceName && width > deviceName.length * 18 ? deviceName : null}</p>
                      </Tooltip>
                      <div className="dragger" style={{ display: isCurrent ? "flex" : "none" }}>
                        <DragOutlined />
                      </div>
                      <div className="config" style={{ display: isCurrent ? "flex" : "none" }}>
                        <EditOutlined title="编辑" onClick={(e) => editItem(e, v)} />
                        <DeleteOutlined title="删除" onClick={(e) => deleteItem(e, v)} />
                      </div>
                    </div>
                  </Resizable>
                </Draggable>
              );
            })}
          </div>
        </Spin>
        {/* 暂无数据 */}
        {list.length && list.find((h) => h.id === listActive)?.connectionState === 0 && (
          <div className="no-state">
            <img src={noState} alt="" />
            <p>当前主机未连接,请先连接...</p>
          </div>
        )}
      </div>

      {/* 设备绑定 */}
      <DeviceBind ref={deviceBindRef} deviceList={deviceList} emitBind={emitBind}></DeviceBind>
    </div>
  );
};

export default HostManage;


2、deviceBind.jsx文件

import React, { forwardRef, useState, useImperativeHandle } from "react";
import { Modal, Form, Select } from "antd";
const { Option } = Select;

const DeviceBind = (props, ref) => {
  // 使用了forwardRef,事件需抛出给父组件,父组件才可以调用
  useImperativeHandle(ref, () => ({
    getPage,
  }));

  const [open, setOpen] = useState(false);
  const [editData, setEditData] = useState(null);
  const [form] = Form.useForm();

  const getPage = (data) => {
    // console.log("新增或修改哟", data);
    setOpen(true);
    setEditData(data);
    //清空form值
    form.resetFields();
    //回显
    form.setFieldsValue({
      deviceId: data.deviceId,
    });
  };

  // 提交
  const handleSubmit = async () => {
    const formValidateFields = await form.validateFields();
    if (formValidateFields) {
      const emitData = Object.assign(editData, formValidateFields);
      props.emitBind(emitData);
      setOpen(false);
    }
  };
  // 关闭弹窗
  const handleCancel = () => {
    props.emitBind(false);
    setOpen(false);
  };

  return (
    <Modal
      title="绑定设备"
      width="500px"
      maskClosable={false}
      keyboard={false}
      visible={open}
      destroyOnClose
      okText="确定"
      cancelText="取消"
      onOk={handleSubmit}
      onCancel={handleCancel}
    >
      <Form name="add" form={form} labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} autoComplete="off">
        <Form.Item label="设备" name="deviceId" rules={[{ required: true, message: "请选择设备!" }]}>
          <Select allowClear placeholder="请选择">
            {props.deviceList.map((v) => (
              <Option key={v.id} value={v.id} disabled={v.disabled}>
                {v.name}
              </Option>
            ))}
          </Select>
        </Form.Item>
      </Form>
    </Modal>
  );
};
export default forwardRef(DeviceBind);


3、less文件(注意下各个类名的z-index即可)

.host-manage {
  width: 100%;
  height: 100%;
  -webkit-user-select: none; /* Safari 3.1+ */
  -moz-user-select: none; /* Firefox 2+ */
  -ms-user-select: none; /* IE10+ */
  user-select: none; //不允许页面选中
  display: flex;
  .manage-list {
    height: 100%;
    .left-render {
      width: 100%;
      height: 44px;
      flex: 0 0 auto;
      display: flex;
      justify-content: space-between;
      align-items: center;
      .item-left {
        width: calc(100% - 70px);
        height: 100%;
        display: flex;
        flex-direction: column;
        justify-content: space-evenly;
        p {
          color: #606266;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
        }
        p:first-child {
          font-size: 16px;
          font-weight: bold;
        }
      }
      .item-right {
        width: 70px;
        height: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        .state {
          display: inline-block;
          width: 10px;
          height: 10px;
          border-radius: 50%;
          margin-right: 8px;
        }
        .ant-btn {
          width: 18px;
          height: 18px;
          min-width: 18px;
          margin-right: 4px;
          .antion {
            font-size: 10px;
          }
        }
      }
    }
  }
  .manage-img {
    flex: 1;
    position: relative;
    overflow: hidden auto;
    // 单独滚动条加粗
    &::-webkit-scrollbar {
      width: 8px;
      height: 8px;
    }
    .ant-spin-nested-loading,
    .ant-spin-container {
      width: 100%;
      height: 100%;
    }
    img {
      max-width: 100%;
    }
    .canvas {
      position: absolute;
      left: 0;
      top: 0;
      z-index: 10;
      .drag-box {
        position: absolute;
        z-index: 50;
        .border-box {
          width: 100%;
          height: 100%;
          box-sizing: border-box;
          background-color: rgba(255, 255, 255, 0.2);
          border: 1px solid #409eff;
        }
      }
      .box-item {
        background-color: rgba(255, 255, 255, 0.2);
        border-style: solid;
        position: absolute;
        z-index: 100;

        .text {
          width: 100%;
          height: 100%;
          font-size: 18px;
          font-weight: bold;
          display: flex;
          justify-content: center;
          align-items: center;
        }
        .dragger {
          justify-content: center;
          align-items: center;
          font-size: 18px;
          color: #ffffff;
          position: absolute;
          right: 50%;
          top: -14px;
          z-index: 500;
          display: none;
          .anticon {
            padding: 2px;
            border: 1px solid #409eff;
            cursor: move;
          }
        }
        .config {
          justify-content: flex-end;
          align-items: center;
          font-size: 18px;
          color: #ffffff;
          position: absolute;
          right: -2px;
          top: calc(100% - 24px);
          display: none;
          .anticon {
            padding: 2px;
            border: 2px solid #409eff;
            cursor: pointer;
          }
          .anticon-edit {
            margin-right: 6px;
          }
        }
      }
      // 拖拽大小的角标
      .react-resizable-handle {
        width: 10px;
        height: 10px;
        background-image: none;
        border: 2px solid #ffffff;
      }
      .react-resizable-handle-nw {
        left: -6px;
        top: -6px;
      }
      .react-resizable-handle-w {
        transform: rotate(0);
        left: -6px;
      }
      .react-resizable-handle-ne {
        right: -6px;
        top: -6px;
      }
      .react-resizable-handle-s {
        transform: rotate(0);
        bottom: -6px;
      }
      .react-resizable-handle-e {
        transform: rotate(0);
        right: -6px;
      }
      .react-resizable-handle-sw {
        left: -6px;
        bottom: -6px;
      }
      .react-resizable-handle-se {
        right: -6px;
        bottom: -6px;
      }
    }

    .no-state {
      width: 100%;
      height: 100%;
      position: absolute;
      left: 0;
      top: 0;
      z-index: 100;
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      background-color: #f2f6fc;
      img {
        width: 100px;
      }
      p {
        font-size: 20px;
        margin-top: 10px;
      }
    }
  }
}


总结

1、建议直接复制代码,先看效果,需安装react-draggable和react-resizable(因为需要放大缩小/拖位置),有一些可能你们没用到,有效果后看情况删除即可 2、代码都有通俗的注释,目的是为了大家都看得懂,画框效果使用原生mousedown与mouseup实现 5、如果需要手动拖动绘制矩形并框选区域,并获取坐标点,请移步我另一个文章: React Hooks 使用 fabric.js 手动绘制多边形 6、如有问题,欢迎评论区讨论,大家早下班,打游戏不好吗?