HTML5 Canvas 实现K线图的示例代码


Posted in HTML / CSS onDecember 23, 2019

因为公司的项目需求,需要做一个K线图,可以让交易者清楚的看到某一交易品种在各个时间段内的报价,以及当前的实时报价。

我所考虑的有两个方向,一是类似于Highcharts等插件的实现方式 -- svg,一是HTML5的canvas。

SVG 是一种使用 XML 描述 2D 图形的语言。 Canvas 通过 JavaScript 来绘制 2D 图形。 Canvas 是逐像素进行渲染的。

HTML5 Canvas 实现K线图的示例代码 '

经过上面的比较不难发现, SVG 更适用于偏静态,渲染频率不高的场景,所以这种要实现实时报价更新绘制的情况只能选择 canvas

2. 实现哪些需求

历史报价实时报价 绘制图表

支持 拖拽 查看历史时间段的报价图表

支持鼠标 滚轮 和触摸板 双指 操作放大或缩小图表

支持鼠标指针 移动 查看鼠标位置报价

3. 代码实现过程

1. 准备工作

/**
 * K-line - K线图渲染函数
 * Date: 2019.12.18  Author: isnan
 */
const BLOCK_MARGIN = 2; //方块水平间距
const START_PRICE_INDEX = 'open_price'; //开始价格在数据组中的位置
const END_PRICE_INDEX = 'close'; //结束价格在数据组中的位置
const MIN_PRICE_INDEX = 'low'; //最小价格在数据组中的位置
const MAX_PRICE_INDEX = 'high'; //最大价格在数据组中的位置
const TIME_INDEX = 'time'; //时间在数据组中的位置
const LINE_WIDTH = 1; //1px 宽度 (中间线、x轴等)
const BOTTOM_SPACE = 40; //底部空间
const TOP_SPACE = 20; //顶部空间
const RIGHT_SPACE = 60; //右侧空间
let _addEventListener, _removeEventListener, prefix = ''; //addEventListener 浏览器兼容
function RenderKLine (id, /*Optional*/options) {
  if (!id) return;
  options = options || {};
  this.id = id;   //canvas box id
  // detect event model
  if (window.addEventListener) {
    _addEventListener = "addEventListener";
    _removeEventListener = "removeEventListener";
  } else {
    _addEventListener = "attachEvent";
    _removeEventListener = "detachEvent"
    prefix = "on";
  }
  // options params
  this.sharpness = options.sharpness;  // 清晰度 (正整数 太大可能会卡顿,取决于电脑配置 建议在2~5区间)
  this.blockWidth = options.blockWidth; // 方块的宽度 (最小为3,最大49 为了防止中间线出现位置偏差 设定为奇数,若为偶数则向下减1)
  this.buyColor = options.buyColor || '#F05452';  // color 涨
  this.sellColor = options.sellColor || '#25C875';  // color 跌
  this.fontColor = options.fontColor || '#666666';  //文字颜色
  this.lineColor = options.lineColor || '#DDDDDD';  //参考线颜色
  this.digitsPoint = options.digitsPoint || 2; //报价的digits (有几位小数)
  this.horizontalCells = options.horizontalCells || 5; //水平方向切割多少格子 (中间虚线数 = 5 - 1)
  this.crossLineStatus = options.crossLineStatus || true; //鼠标移动十字线显示状态

  //basic params
  this.totalWidth = 0;  //总宽度
  this.movingRange = 0; //横向移动的距离 取正数值,使用时再加负号
  this.minPrice = 9999999;
  this.maxPrice = 0; //绘制的所有数据中 最小/最大数据 用来绘制y轴
  this.diffPrice = 0;  //最大报价与最小报价的差值
  this.perPricePixel = 0; //每一个单位报价占用多少像素
  this.centerSpace = 0; //x轴到顶部的距离 绘图区域
  this.xDateSpace = 6;  //x轴上的时间绘制间隔多少组
  this.fromSpaceNum = 0;  //x轴上的时间绘制从第 (fromSpaceNum%xDateSpace) 组数据开始 
  this.dataArr = [];  //数据
  this.lastDataTimestamp = undefined; //历史报价中第一个时间戳, 用来和实时报价做比较画图
  this.buyColorRGB = {r: 0, g: 0, b: 0};
  this.sellColorRGB = {r: 0, g: 0, b: 0};
  
  this.processParams();
  this.init();
}

定义了一些常量和变量,生成一个 构造函数 ,接收两个参数,一个是id,canvas会在插入到这个id的盒子内,第二个参数是一些配置项,可选。

/**
 *    sharpness {number} 清晰度
 *    buyColor {string} color - 涨
 *    sellColor {string} color - 跌
 *    fontColor {string} 文字颜色
 *    lineColor {string} 参考线颜色
 *    blockWidth {number} 方块的宽度
 *    digitsPoint {number} 报价有几位小数
 *    horizontalCells {number} 水平方向切割几个格子
 *    crossLineStatus {boolean} 鼠标移动十字线显示状态
 */

2. init方法和canvas画布的翻转

RenderKLine.prototype.init = function () {
  let cBox = document.getElementById(this.id);
  // 创建canvas并获得canvas上下文
  this.canvas = document.createElement("canvas");
  if (this.canvas && this.canvas.getContext) {
    this.ctx = this.canvas.getContext("2d");
  }

  this.canvas.innerHTML = '您的当前浏览器不支持HTML5 canvas';
  cBox.appendChild(this.canvas);
  this.actualWidth = cBox.clientWidth;
  this.actualHeight = cBox.clientHeight;
  
  this.enlargeCanvas();
}
// 因为绘制区域超出canvas区域,此方法也用来代替clearRect 清空画布的作用
RenderKLine.prototype.enlargeCanvas = function () {
  this.canvas.width = this.actualWidth * this.sharpness;
  this.canvas.height = this.actualHeight * this.sharpness;
  this.canvas.style.height = this.canvas.height / this.sharpness + 'px';
  this.canvas.style.width = this.canvas.width / this.sharpness + 'px';
  this.centerSpace = this.canvas.height - (BOTTOM_SPACE + TOP_SPACE) * this.sharpness;
  // 将canvas原点坐标转换到右上角
  this.transformOrigin();
  // base settings
  this.ctx.lineWidth = LINE_WIDTH*this.sharpness;
  this.ctx.font = `${12*this.sharpness}px Arial`;
  // 还原之前滚动的距离
  this.ctx.translate(-this.movingRange * this.sharpness, 0);
  // console.log(this.movingRange);
}

init方法初始化了一个canvas,enlargeCanvas是一个替代clearRect的方法,其中需要注意的是 transformOrigin 这个方法,因为正常的canvas原点坐标在坐上角,但是我们需要绘制的图像是从右侧开始绘制的,所以我这里为了方便绘图,把整个canvas做了一次转换,原点坐标转到了右上角位置。

// 切换坐标系走向 (原点在左上角 or 右上角)
RenderKLine.prototype.transformOrigin = function () {
  this.ctx.translate(this.canvas.width, 0);
  this.ctx.scale(-1, 1);
}

这里有一点需要注意的是,虽然翻转过来绘制一些矩形,直线没什么问题,但是绘制文本是不行的,绘制文本需要还原回去,不然文字就是翻转过来的状态。如下图所示:

HTML5 Canvas 实现K线图的示例代码 

3. 移动、拖拽、滚轮事件

//监听鼠标移动
RenderKLine.prototype.addMouseMove = function () {
  this.canvas[_addEventListener](prefix+"mousemove", mosueMoveEvent);
  this.canvas[_addEventListener](prefix+"mouseleave", e => {
    this.event = undefined;
    this.enlargeCanvas();
    this.updateData();
  });
  const _this = this;
  function mosueMoveEvent (e) {
    if (!_this.dataArr.length) return;
    _this.event = e || event;
    _this.enlargeCanvas();
    _this.updateData();
  }
}

//拖拽事件
RenderKLine.prototype.addMouseDrag = function () {
  let pageX, moveX = 0;
  this.canvas[_addEventListener](prefix+'mousedown', e => {
    e = e || event;
    pageX = e.pageX;
    this.canvas[_addEventListener](prefix+'mousemove', dragMouseMoveEvent);
  });
  this.canvas[_addEventListener](prefix+'mouseup', e => {
    this.canvas[_removeEventListener](prefix+'mousemove', dragMouseMoveEvent);
  });
  this.canvas[_addEventListener](prefix+'mouseleave', e => {
    this.canvas[_removeEventListener](prefix+'mousemove', dragMouseMoveEvent);
  });
  
  const _this = this;
  function dragMouseMoveEvent (e) {
    if (!_this.dataArr.length) return;
    e = e || event;
    moveX = e.pageX - pageX;
    pageX = e.pageX;
    _this.translateKLine(moveX);
    // console.log(moveX);
  }
}

//Mac双指行为 & 鼠标滚轮
RenderKLine.prototype.addMouseWheel = function () {
  addWheelListener(this.canvas, wheelEvent);
  const _this = this;
  function wheelEvent (e) {
      if (Math.abs(e.deltaX) !== 0 && Math.abs(e.deltaY) !== 0) return; //没有固定方向,忽略
      if (e.deltaX < 0) return _this.translateKLine(parseInt(-e.deltaX)); //向右
      if (e.deltaX > 0) return _this.translateKLine(parseInt(-e.deltaX)); //向左
      if (e.ctrlKey) {
        if (e.deltaY > 0) return _this.scaleKLine(-1); //向内
        if (e.deltaY < 0) return _this.scaleKLine(1); //向外
      } else {
        if (e.deltaY > 0) return _this.scaleKLine(1); //向上
        if (e.deltaY < 0) return _this.scaleKLine(-1); //向下
      }
  }
}

滚轮事件 上一篇已经说过了,这里就是对不同情况做相应的处理;

鼠标移动事件 把event更新到 this 上,然后调用 updateData 方法,绘制图像即可。会调用下面方法画出十字线。

function drawCrossLine () {
  if (!this.crossLineStatus || !this.event) return;
  let cRect = this.canvas.getBoundingClientRect();
  //layerX 有兼容性问题,使用clientX
  let x = this.canvas.width - (this.event.clientX - cRect.left - this.movingRange) * this.sharpness;
  let y = (this.event.clientY - cRect.top) * this.sharpness;
  // 在报价范围内画线
  if (y < TOP_SPACE*this.sharpness || y > this.canvas.height - BOTTOM_SPACE * this.sharpness) return;
  this.drawDash(this.movingRange * this.sharpness, y, this.canvas.width+this.movingRange * this.sharpness, y, '#999999');
  this.drawDash(x, TOP_SPACE*this.sharpness, x, this.canvas.height - BOTTOM_SPACE*this.sharpness, '#999999');
  //报价
  this.ctx.save();
  this.ctx.translate(this.movingRange * this.sharpness, 0);
  // 填充文字时需要把canvas的转换还原回来,防止文字翻转变形
  let str = (this.maxPrice - (y - TOP_SPACE * this.sharpness) / this.perPricePixel).toFixed(this.digitsPoint);
  this.transformOrigin();
  this.ctx.translate(this.canvas.width - RIGHT_SPACE * this.sharpness, 0);
  this.drawRect(-3*this.sharpness, y-10*this.sharpness, this.ctx.measureText(str).width+6*this.sharpness, 20*this.sharpness, "#ccc");
  this.drawText(str, 0, y, RIGHT_SPACE * this.sharpness)
  this.ctx.restore();
}

拖拽事件pageX 的移动距离传递给 translateKLine 方法来实现横向滚动查看。

/**
 * 缩放图表 
 * @param {int} scaleTimes 缩放倍数
 *  正数为放大,负数为缩小,数值*2 代表蜡烛图width的变化度
 *  eg:  2 >> this.blockWidth + 2*2  
 *      -3 >> this.blockWidth - 3*2
 * 为了保证缩放的效果,
 * 应该以当前可视区域的中心为基准缩放
 * 所以缩放前后两边的长度在总长度中所占比例应该一样
 * 公式:(oldRange+0.5*canvasWidth)/oldTotalLen = (newRange+0.5*canvasWidth)/newTotalLen
 * diffRange = newRange - oldRange
 *           = (oldRange*newTotalLen + 0.5*canvasWidth*newTotalLen - 0.5*canvasWidth*oldTotalLen)/oldTotalLen - oldRange
 */
RenderKLine.prototype.scaleKLine = function (scaleTimes) {
  if (!this.dataArr.length) return;
  let oldTotalLen = this.totalWidth;
  this.blockWidth += scaleTimes*2;
  this.processParams();
  this.computeTotalWidth();
  let newRange = (this.movingRange*this.sharpness*this.totalWidth+this.canvas.width/2*this.totalWidth-this.canvas.width/2*oldTotalLen)/oldTotalLen/this.sharpness;
  let diffRange = newRange - this.movingRange;
  // console.log(newRange, this.movingRange, diffRange);
  this.translateKLine(diffRange);
}
// 移动图表
RenderKLine.prototype.translateKLine = function (range) {
  if (!this.dataArr.length) return;
  this.movingRange += parseInt(range);
  let maxMovingRange =  (this.totalWidth - this.canvas.width) / this.sharpness + this.blockWidth;
  if (this.totalWidth <= this.canvas.width || this.movingRange <= 0) {
    this.movingRange = 0;
  } else if (this.movingRange >= maxMovingRange) {
    this.movingRange = maxMovingRange;
  }
  this.enlargeCanvas();
  this.updateData();
}

4. 核心方法 updateData

所有的绘制过程都是在这个方法中完成的,这样无论想要什么操作,都可以通过此方法重绘canvas来实现,需要做的只是改变原型上的一些属性而已,比如想要左右移动,只需要把 this.movingRange 设置好,再调用 updateData 就完成了。

RenderKLine.prototype.updateData = function (isUpdateHistory) {
  if (!this.dataArr.length) return;
  if (isUpdateHistory) {
    this.fromSpaceNum = 0;
  }
  // console.log(data);
  this.computeTotalWidth();
  this.computeSpaceY();
  this.ctx.save();
  // 把原点坐标向下方移动 TOP_SPACE 的距离,开始绘制水平线
  this.ctx.translate(0, TOP_SPACE * this.sharpness);
  this.drawHorizontalLine();
  // 把原点坐标再向左边移动 RIGHT_SPACE 的距离,开始绘制垂直线和蜡烛图
  this.ctx.translate(RIGHT_SPACE * this.sharpness, 0);
  // 开始绘制蜡烛图
  let item, col;
  let lineWidth = LINE_WIDTH * this.sharpness,
      margin = blockMargin = BLOCK_MARGIN*this.sharpness,
      blockWidth = this.blockWidth*this.sharpness;//乘上清晰度系数后的间距、块宽度
  let blockHeight, lineHeight, blockYPoint, lineYPoint; //单一方块、单一中间线的高度、y坐标点
  let realTime, realTimeYPoint; //实时(最后)报价及y坐标点
  for (let i=0; i<this.dataArr.length; i++) {
    item = this.dataArr[i];
    if (item[START_PRICE_INDEX] > item[END_PRICE_INDEX]) {
      //跌了 sell
      col = this.sellColor;
      blockHeight = (item[START_PRICE_INDEX] - item[END_PRICE_INDEX])*this.perPricePixel;
      blockYPoint = (this.maxPrice - item[START_PRICE_INDEX])*this.perPricePixel;
    } else {
      //涨了 buy
      col = this.buyColor;
      blockHeight = (item[END_PRICE_INDEX] - item[START_PRICE_INDEX])*this.perPricePixel;
      blockYPoint = (this.maxPrice - item[END_PRICE_INDEX])*this.perPricePixel;
    }
    lineHeight = (item[MAX_PRICE_INDEX] - item[MIN_PRICE_INDEX])*this.perPricePixel;
    lineYPoint = (this.maxPrice - item[MAX_PRICE_INDEX])*this.perPricePixel;
    // if (i === 0) console.log(lineHeight, blockHeight, lineYPoint, blockYPoint);
    lineHeight = lineHeight > 2*this.sharpness ? lineHeight : 2*this.sharpness;
    blockHeight = blockHeight > 2*this.sharpness ? blockHeight : 2*this.sharpness;
    if (i === 0) {
      realTime = item[END_PRICE_INDEX];
      realTimeYPoint = blockYPoint + (item[START_PRICE_INDEX] > item[END_PRICE_INDEX] ? blockHeight : 0)
    };
    // 绘制垂直方向的参考线、以及x轴的日期时间
    if (i%this.xDateSpace === (this.fromSpaceNum%this.xDateSpace)) {
      this.drawDash(margin+(blockWidth-1*this.sharpness)/2, 0, margin+(blockWidth-1*this.sharpness)/2, this.centerSpace);
      this.ctx.save();
      // 填充文字时需要把canvas的转换还原回来,防止文字翻转变形
      this.transformOrigin();
      // 翻转后将原点移回翻转前的位置
      this.ctx.translate(this.canvas.width, 0);
      this.drawText(processXDate(item[TIME_INDEX], this.dataType), -(margin+(blockWidth-1*this.sharpness)/2), this.centerSpace + 12*this.sharpness, undefined, 'center', 'top');
      
      this.ctx.restore();
    }
    this.drawRect(margin+(blockWidth-1*this.sharpness)/2, lineYPoint, lineWidth, lineHeight, col);
    this.drawRect(margin, blockYPoint, blockWidth, blockHeight, col);
    margin = margin+blockWidth+blockMargin;
  }
  //绘制实时报价线、价格
  this.drawLine((this.movingRange-RIGHT_SPACE) * this.sharpness, realTimeYPoint, (this.movingRange-RIGHT_SPACE) * this.sharpness + this.canvas.width, realTimeYPoint, '#cccccc');
  this.ctx.save();
  this.ctx.translate(-RIGHT_SPACE * this.sharpness, 0);
  this.transformOrigin();
  this.drawRect((17-this.movingRange) * this.sharpness, realTimeYPoint - 10 * this.sharpness, this.ctx.measureText(realTime).width+6*this.sharpness, 20*this.sharpness, "#ccc");
  this.drawText(realTime, (20-this.movingRange) * this.sharpness, realTimeYPoint);
  this.ctx.restore();
  //最后绘制y轴上报价,放在最上层
  this.ctx.translate(-RIGHT_SPACE * this.sharpness, 0);
  this.drawYPrice();
  this.ctx.restore();
  drawCrossLine.call(this);
}

这个方法不难,只是绘制时为了方便计算位置,需要经常变换原点坐标,不要搞错了就好。

还需要注意的是 sharpness 这个变量,代表清晰度,整个canvas的宽高是在原有的基础上乘上了这个系数得到的,所以,计算时需要特别注意带上这个系数。

5. 更新历史&实时报价方法

// 实时报价
RenderKLine.prototype.updateRealTimeQuote = function (quote) {
  if (!quote) return;
  pushQuoteInData.call(this, quote);
}
/**
 * 历史报价
 * @param {Array} data 数据
 * @param {int}   type 报价类型  默认 60(1小时)
 *    (1, 5, 15, 30, 60, 240, 1440, 10080, 43200)
      (1分钟 5分钟 15分钟 30分钟 1小时 4小时 日 周 月)
 */
RenderKLine.prototype.updateHistoryQuote = function (data, type = 60) {
  if (!data instanceof Array || !data.length) return;
  this.dataArr = data;
  this.dataType = type;
  this.updateData(true);
}

6. 调用demo

<div id="myCanvasBox" style="width: 1000px; height: 500px;"></div>

<script>
    let data = [
      {
        "time": 1576648800, 
        "open_price": "1476.94", 
        "high": "1477.44", 
        "low": "1476.76", 
        "close": "1476.96"
      }, 
      //...
    ];
    let options = {
      sharpness: 3,
      blockWidth: 11,
      horizontalCells: 10
    };
    let kLine = new RenderKLine("myCanvasBox", options);
    //更新历史报价
    kLine.updateHistoryQuote(data);
    //模拟实时报价
    let realTime = `{
      "time": 1575858840, 
      "open_price": "1476.96", 
      "high": "1482.12", 
      "low": "1470.96", 
      "close": "1476.96"
    }`;
    setInterval(() => {
      let realTimeCopy = JSON.parse(realTime);
      realTimeCopy.time = parseInt(new Date().getTime()/1000);
      realTimeCopy.close = (1476.96 - (Math.random() * 4 - 2)).toFixed(2);
      kLine.updateRealTimeQuote(realTimeCopy);
     }, parseInt(Math.random() * 1000 + 500))
</script>

7. 效果图

HTML5 Canvas 实现K线图的示例代码 

4. 总结

这个功能还没有做完,还有很多其他功能以及一些细节上需要开发,比如贝塞尔曲线的绘制,首次加载的Loading,更多历史报价加载等等。现在只是简单总结一下这次遇到的问题,以及一些收获,等下一阶段完善后再做详细记录。

这是我第一次使用canvas绘制一个完整的项目,整个过程还是很有收获的,我想以后还要尝试其他不同的东西,比如游戏。

  • canvas性能非常高,其实现动画的过程,就是不停的重绘。
  • 要学会转换坐标系,这对绘制图像很有帮助。
  • 要用好ctx.save 和 ctx.restore
  • 数学很重要...

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

HTML / CSS 相关文章推荐
CSS3 3D立方体效果示例-transform也不过如此
Dec 05 HTML / CSS
纯css3制作网站后台管理面板
Dec 30 HTML / CSS
CSS3实现闪烁动画效果的方法
Feb 09 HTML / CSS
CSS3 display知识详解
Nov 25 HTML / CSS
HTML5中的新元素介绍
Oct 17 HTML / CSS
免费获得微软MCSD证书赶快行动吧!
Nov 13 HTML / CSS
HTML5实现晶莹剔透的雨滴特效
May 14 HTML / CSS
浅谈基于HTML5的在线视频播放方案
Feb 18 HTML / CSS
HTML5响应式(自适应)网页设计的实现
Nov 17 HTML / CSS
详解WebSocket跨域问题解决
Aug 06 HTML / CSS
HTML5实现直播间评论滚动效果的代码
May 27 HTML / CSS
HTML5拖拽文件上传的示例代码
Mar 04 HTML / CSS
html5利用canvas实现颜色容差抠图功能
Dec 23 #HTML / CSS
HTML5 客户端数据库简易使用:IndexedDB
Dec 19 #HTML / CSS
吃透移动端 Html5 响应式布局
Dec 16 #HTML / CSS
HTML文本属性&amp;颜色控制属性的实现
Dec 17 #HTML / CSS
吃透移动端 1px的具体用法
Dec 16 #HTML / CSS
关于html字符串正则判断和匹配的具体使用
Dec 12 #HTML / CSS
处理textarea中的换行和空格
Dec 12 #HTML / CSS
You might like
使用PHP遍历文件夹与子目录的函数代码
2011/09/26 PHP
php制作基于xml的RSS订阅源功能示例
2017/02/08 PHP
php读取XML的常见方法实例总结
2017/04/25 PHP
laravel实现前后台路由分离的方法
2019/10/13 PHP
Javascript学习笔记9 prototype封装继承
2010/01/11 Javascript
获取css样式表内样式的js函数currentStyle(IE),defaultView(FF)
2011/02/14 Javascript
jQuery EasyUI API 中文文档 - NumberBox数字框
2011/10/13 Javascript
javascript ready和load事件的区别示例介绍
2013/08/30 Javascript
JS常用函数使用指南
2014/11/23 Javascript
全面解析Bootstrap中tooltip、popover的使用方法
2016/06/13 Javascript
Omi v1.0.2发布正式支持传递javascript表达式
2017/03/21 Javascript
node.js平台下利用cookie实现记住密码登陆(Express+Ejs+Mysql)
2017/04/26 Javascript
JS实现的ajax和同源策略(实例讲解)
2017/12/01 Javascript
JS实现读取xml内容并输出到div中的方法示例
2018/04/19 Javascript
微信小程序 简易计算器实现代码实例
2019/09/02 Javascript
mpvue微信小程序的接口请求fly全局拦截代码实例
2019/11/13 Javascript
Vue实现随机验证码功能
2020/12/29 Vue.js
[02:05]2014DOTA2西雅图邀请赛 老队长全明星大猜想谁不服就按进显示器
2014/07/08 DOTA
python遍历类中所有成员的方法
2015/03/18 Python
用Python编写一个简单的俄罗斯方块游戏的教程
2015/04/03 Python
通过python+selenium3实现浏览器刷简书文章阅读量
2017/12/26 Python
python实现梯度下降算法
2020/03/24 Python
python判断文件夹内是否存在指定后缀文件的实例
2019/06/10 Python
解决使用Pandas 读取超过65536行的Excel文件问题
2020/11/10 Python
英国女士和男士时尚服装网上购物:Top Labels Online
2018/03/25 全球购物
微软台湾官方网站:Microsoft台湾
2018/08/15 全球购物
俄罗斯隐形眼镜和眼镜在线商店:Cronos
2020/06/02 全球购物
设置器与访问器的定义以及各自特点
2016/01/08 面试题
医院2014国庆节活动策划方案
2014/09/21 职场文书
初三毕业评语
2014/12/26 职场文书
医院保洁员岗位职责
2015/02/13 职场文书
楚门的世界观后感
2015/06/03 职场文书
2015秋季运动会通讯稿
2015/07/18 职场文书
青年干部培训班学习心得体会
2016/01/06 职场文书
合同补充协议书
2016/03/24 职场文书
python使用openpyxl库读写Excel表格的方法(增删改查操作)
2021/05/02 Python