Canvas时间轴插件开发:拖动、缩放与点击返回时间点
简介这是一份基于 canvas 实现的时间轴插件源码面向需要在前端项目中实现视频回放时间轴、录像片段管理等交互功能的开发者尤其适合具备一定 JavaScript 与 jQuery 基础的中级前端人员。插件支持拖动时间轴、点击定位返回时间点、动态增加录像块以及放大缩小视图等操作可直接嵌入视频回放类业务场景。压缩包共 6 个文件包含 3 个 js 脚本、2 个 html 示例页面和 1 个 md 说明文档整体约 90KB体积轻量便于快速集成与二次开发。其中 js 文件承载时间轴核心逻辑与交互控制html 页面提供可直接运行的演示入口md 文档则补充使用说明方便读者理解调用方式。目前已有 4077 人学习下载说明该方案在实际项目中具备一定参考价值。读者可借此掌握 canvas 绘制时间轴、事件绑定与视图缩放等实现思路并对照示例快速改造为自己的回放时间轴组件。1. 时间轴插件为什么值得用 Canvas 重写一遍做过录像回放、监控检索、音视频剪辑这类界面的人大概率都碰过同一个问题时间轴用 DOM 堆出来一开始挺顺录像块一多、缩放一拉、拖动一频繁页面就开始卡滚动条抖鼠标事件对不上位置。我最早也是拿 div 拼的几十个录像块还行上千个直接掉帧后来换成 Canvas 重画才算把这块心病去掉。这个标题说的东西很具体一个用 Canvas 写的时间轴插件支持拖动、点击返回时间点、增加录像块、放大缩小。它解决的是「时间轴可视化 交互」这一整类需求适合做安防回放、录屏剪辑、日志时间定位的开发者。核心思路是把时间轴当成一块画布所有刻度、录像块、游标都画上去交互靠坐标换算反推时间。下面我按自己落地的顺序把选型、实现、参数和坑一条条讲清楚。2. 先把时间轴的数据模型和坐标换算立住Canvas 时间轴翻车最多的地方不是画不出来而是「画出来的东西和鼠标点的地方对不上」。根子在于没有把时间、像素、缩放三者的关系定义清楚。这一章先把模型立住后面所有交互都从这里推。2.1 时间轴的三层结构总时长、可视窗口、缩放比我一般把时间轴拆成三个量totalDuration总时长单位毫秒、viewStart可视窗口左边界对应的时间、pxPerMs每毫秒占多少像素也就是缩放比的倒数。可视窗口宽度canvasWidth和缩放比的关系是pxPerMs canvasWidth / visibleDuration visibleDuration canvasWidth / pxPerMs拖动改变的是viewStart缩放改变的是pxPerMs。这两个量一变重画一遍即可。关键约束是viewStart不能小于 0也不能让右边界超过totalDuration否则会出现「拖到空白区」的玄学现象。时间转像素、像素转时间的公式必须成对出现且只写一份// 时间(ms) - 画布x坐标 function timeToX(t, viewStart, pxPerMs) { return (t - viewStart) * pxPerMs; } // 画布x坐标 - 时间(ms) function xToTime(x, viewStart, pxPerMs) { return viewStart x / pxPerMs; }逻辑说明timeToX用于绘制xToTime用于点击和拖动时反推时间。参数viewStart是当前窗口左边界时间pxPerMs是缩放比。这两个函数是全篇的地基任何一处自己手写换算迟早对不上。2.2 录像块的数据结构怎么定录像块本质是一段时间区间我用的结构是const record { id: r_001, start: 1700000000000, // 起始时间戳(ms) end: 1700000060000, // 结束时间戳(ms) color: #3b82f6, // 可选按类型着色 type: normal // 可选用于区分报警/普通 };逻辑说明start/end用绝对时间戳而不是相对偏移好处是新增录像块时不用重算全量直接 push 再排序即可。参数color和type是给业务层留的口子报警录像标红、普通录像标蓝这类需求几乎每个项目都会提。提示录像块数组建议按start升序维护绘制时可以用二分找到可视窗口内的第一块避免每帧遍历上千条数据。2.3 用 requestAnimationFrame 还是按需重绘Canvas 时间轴不需要一直刷。我的做法是「按需重绘」拖动、缩放、数据变更时置一个dirty标记在requestAnimationFrame里统一重画。这样拖动时最多 60fps静止时零开销。let dirty false; function markDirty() { if (dirty) return; dirty true; requestAnimationFrame(() { dirty false; draw(); }); }逻辑说明markDirty做了节流同一帧内多次调用只重画一次。参数上没有额外配置requestAnimationFrame由浏览器调度。相比setInterval定时重绘这种方式在静止时完全不占 CPU笔记本风扇不会一直转。3. 拖动、点击返回时间点、缩放三件套怎么落地模型立住之后交互就是坐标换算的应用题。这一章把三个核心交互逐个拆开每个都给可抄的代码和参数说明。3.1 拖动平移mousedown/mousemove/mouseup 的完整闭环拖动的本质是按下时记录起始 x 和当时的viewStart移动时算出位移对应的时长反推新的viewStart。let dragging false; let dragStartX 0; let dragStartViewStart 0; canvas.addEventListener(mousedown, (e) { dragging true; dragStartX e.offsetX; dragStartViewStart viewStart; }); window.addEventListener(mousemove, (e) { if (!dragging) return; const dx e.offsetX - dragStartX; // 位移像素换算成时间偏移注意方向向右拖窗口左边界时间减小 let next dragStartViewStart - dx / pxPerMs; // 边界钳制 const maxStart Math.max(0, totalDuration - canvasWidth / pxPerMs); viewStart Math.min(Math.max(0, next), maxStart); markDirty(); }); window.addEventListener(mouseup, () { dragging false; });逻辑说明mousemove绑在window而不是canvas上是为了防止鼠标拖出画布后事件丢失这是血泪经验。参数maxStart是右边界钳制值保证窗口不会拖出总时长。方向别搞反向右拖动看到的是更早的时间所以viewStart要减小。3.2 点击返回时间点区分单击和拖动点击返回时间点就是xToTime(e.offsetX)。但拖动结束时也会触发 click所以要判断位移阈值。let downX 0, downY 0; canvas.addEventListener(mousedown, (e) { downX e.offsetX; downY e.offsetY; }); canvas.addEventListener(click, (e) { const moved Math.abs(e.offsetX - downX) Math.abs(e.offsetY - downY); if (moved 4) return; // 超过4px视为拖动不触发点击 const t xToTime(e.offsetX, viewStart, pxPerMs); onTimePick(t); // 业务回调 });逻辑说明moved阈值取 4px是手抖和真实拖动的经验分界。参数onTimePick是业务层回调拿到时间戳后去 seek 播放器。注意xToTime的结果要钳制在[0, totalDuration]内防止点到画布边缘外。3.3 缩放以鼠标位置为锚点别以左边界为锚点缩放最容易翻车的地方是锚点。如果以左边界为锚点鼠标指着某个录像块放大那个块会跑掉体验很差。正确做法是以鼠标位置对应的时间为锚点缩放后让这个时间仍在鼠标下方。canvas.addEventListener(wheel, (e) { e.preventDefault(); const anchorTime xToTime(e.offsetX, viewStart, pxPerMs); const factor e.deltaY 0 ? 1.1 : 1 / 1.1; // 滚轮上滚放大 let nextPxPerMs pxPerMs * factor; // 缩放比钳制 nextPxPerMs Math.min(Math.max(nextPxPerMs, minPxPerMs), maxPxPerMs); // 反推新的 viewStart使 anchorTime 仍在 e.offsetX 处 viewStart anchorTime - e.offsetX / nextPxPerMs; pxPerMs nextPxPerMs; // 边界钳制 const maxStart Math.max(0, totalDuration - canvasWidth / pxPerMs); viewStart Math.min(Math.max(0, viewStart), maxStart); markDirty(); });逻辑说明factor取 1.1 是每格滚轮的缩放步长太大跳变、太小没手感。参数minPxPerMs/maxPxPerMs是缩放上下限一般按「最小能看到 1 秒、最大能看到全部时长」来定。锚点公式viewStart anchorTime - offsetX / nextPxPerMs是核心记住它缩放就不会飘。3.4 增加录像块插入后排序并触发重绘新增录像块就是往数组里塞一条然后排序、标记重绘。如果新块和已有块重叠业务上要么合并要么拒绝我一般交给业务层决定。function addRecord(record) { records.push(record); records.sort((a, b) a.start - b.start); markDirty(); }逻辑说明排序保证绘制时二分查找可用。参数record需满足 2.2 的结构。如果录像块数量上万sort每次全量排会有开销可以改成插入到正确位置但一般千级数据全量排足够快。4. 绘制层刻度、录像块、游标怎么画才不糊交互对了画得糊一样没人用。这一章讲绘制细节重点是高清屏适配和分层绘制。4.1 高清屏适配devicePixelRatio 必须处理Canvas 在 Retina 屏上默认会模糊因为 CSS 像素和物理像素是 1:2 或 1:3。必须按devicePixelRatio放大画布再缩放上下文。function resizeCanvas() { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; canvas.style.width rect.width px; canvas.style.height rect.height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后按CSS像素绘制 canvasWidth rect.width; markDirty(); } window.addEventListener(resize, resizeCanvas);逻辑说明setTransform把绘制坐标系映射回 CSS 像素之后所有绘制代码按 CSS 像素写即可。参数dpr由浏览器提供。注意resize后要重算canvasWidth否则坐标换算会错。4.2 刻度自适应根据 pxPerMs 决定刻度粒度刻度不能固定间隔否则放大后全是重复标签缩小后挤成一团。我按「目标刻度间距约 80px」反推时间粒度。const STEPS [1, 5, 10, 50, 100, 500, 1000, 5000, 10000, 30000, 60000, 300000, 600000]; function pickStep(pxPerMs) { const targetPx 80; const targetMs targetPx / pxPerMs; for (const s of STEPS) { if (s targetMs) return s; } return STEPS[STEPS.length - 1]; }逻辑说明STEPS是候选刻度粒度毫秒从 1ms 到 10 分钟。参数targetPx是期望的刻度像素间距80px 是标签不重叠的经验值。pickStep返回第一个不小于目标粒度的档位保证刻度不会太密。4.3 录像块绘制可视区裁剪 圆角矩形录像块多的时候只画可视窗口内的能省大量绘制开销。function drawRecords() { const viewEnd viewStart canvasWidth / pxPerMs; for (const r of records) { if (r.end viewStart) continue; if (r.start viewEnd) break; // 已排序可提前退出 const x1 timeToX(r.start, viewStart, pxPerMs); const x2 timeToX(r.end, viewStart, pxPerMs); ctx.fillStyle r.color || #3b82f6; roundRect(ctx, x1, 40, Math.max(x2 - x1, 2), 24, 4); ctx.fill(); } }逻辑说明break依赖数组已排序这是 2.2 排序的回报。参数Math.max(x2 - x1, 2)保证极短的录像块也有 2px 可见宽度否则会「消失」。roundRect是自实现的圆角矩形Canvas 原生roundRect兼容性看目标浏览器。4.4 游标和当前时间指示线游标就是一条竖线位置由当前播放时间决定。function drawCursor(currentTime) { const x timeToX(currentTime, viewStart, pxPerMs); if (x 0 || x canvasWidth) return; ctx.strokeStyle #ef4444; ctx.lineWidth 2; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }逻辑说明先判断游标是否在可视区内不在就不画。参数currentTime由播放器提供。lineWidth取 2 是为了在高清屏上也清晰。5. 避坑与排查时间轴插件最常见的 5 个翻车点这一章是我踩过的坑按「现象 → 原因 → 解决」写能帮你省下不少后悔药。5.1 拖动时鼠标移出画布事件丢失现象拖到画布外松手时间轴卡在拖动状态再移动鼠标还在拖。 原因mousemove/mouseup绑在了canvas上鼠标移出后不再触发。 解决把mousemove和mouseup绑到window如 3.1 所示。这是最经典的一个坑。5.2 缩放后点击位置偏移现象放大后点击某个录像块返回的时间点偏了几秒。 原因xToTime用的pxPerMs是缩放前的旧值或者viewStart没同步更新。 解决确保pxPerMs和viewStart在缩放时原子更新且xToTime只读当前值。别在事件里缓存旧值。5.3 录像块数量上千后拖动掉帧现象录像块超过 1000 条拖动明显卡顿。 原因每帧遍历全部录像块且没有可视区裁剪。 解决数组按start排序绘制时用二分找到可视区起点遇到r.start viewEnd直接break。如 4.3。5.4 高清屏上线条发虚现象刻度线和游标在笔记本上模糊像蒙了一层。 原因没处理devicePixelRatio画布被拉伸。 解决按 4.1 设置canvas.width rect.width * dpr并setTransform。注意resize后要重设。5.5 滚轮缩放导致页面滚动现象在时间轴上滚轮页面跟着上下滚。 原因wheel事件默认行为没阻止。 解决在wheel回调里e.preventDefault()。注意如果用了passive: true监听preventDefault无效要显式设{ passive: false }。6. 进阶把时间轴插件做成可复用组件的几个技巧写到能跑只是及格做成能复用的组件才算落地。这一章讲几个我常用的进阶技巧收在具体做法上。第一个技巧是「配置对象化」。把所有可调参数收进一个options外部只传关心的项其余走默认值。这样同一个插件能适配监控回放和剪辑两种场景。const defaultOptions { pxPerMs: 0.01, minPxPerMs: 0.001, maxPxPerMs: 1, trackHeight: 24, trackTop: 40, cursorColor: #ef4444, onTimePick: () {} }; function createTimeline(canvas, records, userOptions) { const opts Object.assign({}, defaultOptions, userOptions); // ... 初始化逻辑 }逻辑说明Object.assign做浅合并够用。参数pxPerMs默认 0.01 表示 1 秒占 10px适合监控场景剪辑场景可以传更大的值。onTimePick是点击回调业务层注入。第二个技巧是「事件解耦」。插件内部只负责把时间点抛出去不直接操作播放器。这样插件不依赖具体播放器换一个播放器只改回调。第三个技巧是「销毁方法」。组件用完要能干净卸载否则window上的mousemove监听会泄漏。function destroy() { window.removeEventListener(mousemove, onMouseMove); window.removeEventListener(mouseup, onMouseUp); window.removeEventListener(resize, resizeCanvas); canvas.removeEventListener(wheel, onWheel); // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height); }逻辑说明把所有绑在window和canvas上的监听逐个移除顺序无所谓但一个都不能漏。参数上没有纯清理逻辑。单页应用里不写destroy路由切换几次后监听会堆叠表现为「点一下触发好几次」这个坑我踩过。最后一个技巧是「用离屏 Canvas 缓存静态层」。刻度和背景网格变化频率低可以画到离屏 Canvas拖动时只重画录像块和游标性能能再提一档。做法是建一个document.createElement(canvas)在viewStart/pxPerMs变化时重画静态层主循环里drawImage贴上去。这个优化在录像块上千、拖动要求 60fps 的场景下值得做数据量小的时候反而增加复杂度按需上。我自己的习惯是先把交互和坐标换算写对再谈性能优化顺序反了会一直在调玄学 bug。时间轴这东西坐标换算对了剩下都是体力活。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Spring Boot租房管理系统全程实战:数据库设计、核心逻辑与部署避坑

Spring Boot租房管理系统全程实战:数据库设计、核心逻辑与部署避坑

1. 项目概述:一个租房管理系统,到底要解决什么问题做毕设选“springboot租房管理系统”这个方向,说实在的,是个很稳的选择。它不像电商系统那样业务链路太复杂,也不像纯工具类项目那样技术深度不好展示。它踩在“信息发…

2026/10/10 3:27:17 阅读更多 →
纯HTML/CSS/JS个人博客源码:可调试、可演进的前端最小基座

纯HTML/CSS/JS个人博客源码:可调试、可演进的前端最小基座

简介:这是一套开箱即用的个人博客网页HTML源码,面向前端初学者与轻量级个人网站搭建者,解决从零构建结构清晰、样式美观的静态博客页面的实际需求。资源共30个文件,包含1个核心index.html(定义页面结构与导航&#xff…

2026/10/10 3:27:17 阅读更多 →
B站视频下载工具BilibiliDown全攻略:从安装到批量下载与版权避坑

B站视频下载工具BilibiliDown全攻略:从安装到批量下载与版权避坑

我第一次被B站视频下载需求折腾,是某天深夜发现收藏夹里一个干货教程被UP主一键清空了。那种“当时没缓存、现在没得看”的感觉,相信不少人都体会过。从那以后,我陆续试过在线解析、浏览器插件、命令行工具,最后用得顺手的还是Bil…

2026/10/10 3:27:17 阅读更多 →

最新新闻

KVM虚拟化管理工具全解析:virsh、virt-manager与virt-install实战指南

KVM虚拟化管理工具全解析:virsh、virt-manager与virt-install实战指南

作为一个常年和各种虚拟化技术打交道的老运维,我手上管理着几台物理宿主机,上面跑的虚拟机加起来有几十台。最早的时候我用过 VMware 那套,后来切到开源方案,就在 KVM 这条路上越走越深。说句实话,KVM 本身只是一个内核…

2026/10/10 4:11:38 阅读更多 →
家政服务春节涨价背后:供需失衡与价格中枢长期走势解析

家政服务春节涨价背后:供需失衡与价格中枢长期走势解析

每年春节前,家政服务“涨价”都是一条绕不开的新闻。我身边不少朋友从腊月就开始焦虑:年前保洁约不到、价格翻倍不说,月嫂和住家阿姨更是要提前两三个月抢。朋友圈里家政公司发的调价通知,涨价幅度一个比一个高。今年这个话题又被…

2026/10/10 4:11:38 阅读更多 →
光学知识梳理:从几何光学到波动光学的核心框架与应用解析

光学知识梳理:从几何光学到波动光学的核心框架与应用解析

1. 先搭框架:光学知识的地图,比光学本身更重要看到“光学知识梳理”这个标题,可能有人觉得这是老生常谈。但我见过太多人,包括当年的我自己,在光学面前卡住的真正原因,不是公式背不下来,而是脑子…

2026/10/10 4:11:38 阅读更多 →
大模型融资后技术落地:国产芯片适配与推理部署实战

大模型融资后技术落地:国产芯片适配与推理部署实战

1. 这条消息为什么让技术圈炸了锅那天晚上我正蹲在服务器前调一个推理服务的显存占用,群里突然刷屏——某头部大模型团队拿到了新一轮融资,规模传闻在数百亿级别。第一反应不是"钱真多",而是"这笔钱要花在哪"。因为做大模…

2026/10/10 4:11:38 阅读更多 →
基于RNN的轴承故障检测实战:Python源码与数据集全解析

基于RNN的轴承故障检测实战:Python源码与数据集全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 4:11:38 阅读更多 →
强化学习从动态规划到无模型控制:蒙特卡洛、SARSA与Q-learning详解

强化学习从动态规划到无模型控制:蒙特卡洛、SARSA与Q-learning详解

如果你是从这个系列第一篇跟过来的朋友,对 MDP、值迭代、策略迭代应该还有印象。如果没看过也没有关系,你只需要记住一件事:前面两篇讨论的算法,默认环境转移概率 p(s,r|s,a) 是已知的。真实场景里通常拿不到这个模型,…

2026/10/10 4:10:38 阅读更多 →

日新闻

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

1. 从“卫星轨道分类”这个标题说起:为什么值得花时间搞懂第一次接触“卫星轨道分类”这个概念,很多人会觉得它离自己很远——不就是天上的星星怎么转吗?但如果你正在做航天任务规划、遥感数据接收、星座设计,甚至只是准备一场航天…

2026/10/10 0:00:39 阅读更多 →
Spring AOP 核心原理与实战:从概念到日志切面落地

Spring AOP 核心原理与实战:从概念到日志切面落地

1. 从一个真实痛点说起:为什么你的代码里到处都是重复逻辑刚入行那会儿,我写过一个用户管理模块,注册、登录、改密码、注销四个接口。每个接口里都塞了几乎一样的日志打印、参数校验、事务开启和提交。当时觉得没什么,能跑就行。直…

2026/10/10 0:00:40 阅读更多 →
Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

简介:这是一套面向计算机相关专业学生与项目实战学习者的Python数据采集与分析可视化完整项目,以Boss直聘岗位数据为对象,适合用作毕业设计、课程设计或期末大作业。资源包共38个文件,约246KB,以13个py源码文件为核心&…

2026/10/10 0:00:40 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:32 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 1:36:08 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 10:11:06 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 21:13:17 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 6:17:20 阅读更多 →