前言

一款在线的 Markdown 阅读器,主要用来展示 Markdown 内容。支持 HTML 导出,同时可以方便的添加扩展功能。在这个阅读器的基础又做了一款在线 Github Pages 页面生成器,可以方便的生成不同主题风格的 GitHub Page 页面。

功能

阅读器

  • 支持文件拖拽
  • 兼容移动端
  • Prism.js / Highlight.js 代码高亮
  • 自动生成目录
  • 本地图片显示
  • 导出 Html (包含样式)
  • 扩展功能
    • Toto 列表
    • MathJax
    • 时序图 (Js sequence diagrams)
    • Emoji (Emojify.js)
    • 图表 (ECharts)

Github Page 生成器

在上面的基础上加上了下面的功能

  • 支持多种页面主题

    • Architect
    • Cayman
    • Minimal
    • Modernist
    • Slate
    • Time machine
  • 评论
    • 多说
    • Disqus

地址

阅读器
在线地址  效果预览  源码

生成器
在线地址  效果预览  源码

效果

阅读器

生成器

实现

文件解析

程序使用 marked 将 markdown 格式转为 html 格式,这是一个 js 的库,可以直接在浏览器端使用。下面是一个基本的示例

var htmlContent = marked(mdContent);
$("#content").html(htmlContent);

同时 marked 提供了一些接口,让我们可以方便的定制自己的功能。具体的可以参考它的 说明文件 。在下面我们会介绍我们是如何利用这些接口来实现扩展功能。

文件上传

自定义上传按钮样式

原始的上传按钮太丑了,所以我们需要自定义自己的样式。这里使用的方式是使用在 input 上面覆盖一个 button,用 button 来显示样式。同时我们将 buttonpointer-events 设为 none,就可以阻止 button 的事件响应(具体可以参考这里)。下面是具体的实现代码:
html:

<div class="upload-area" id="upload-area"><input type="file" id="select-file" class="select-file"><button class="select-file-style" id="drop">选择或者拖拽 Markdown 文件到此</button></div>

css

.upload-area {width:auto;height:200px;margin:0 2.6em 0 0.4em;padding:0;position:relative;cursor:pointer;transition:height 0.5s;
}
.upload-area .select-file {border-width:0px;width:100%;height:200px;margin:0;cursor:pointer;
}
.upload-area .select-file-style {background:#F5F7FA;position:absolute;top:0;left:0;width:100%;height:200px;border:0px;pointer-events:none;color:#AAB2BD;font-size:2em;line-height:2em;font-family:"Microsoft YaHei", "Tahoma", arial;
}

下面是效果图

读取文件内容

因为程序完全是运行在浏览器端,所以我们使用 html5 的 FileReader 来读取本地文件。FileReader 提供 4 种读取文件的方式:

  • readAsBinaryString(Blob|File)
  • readAsText(Blob|File, opt_encoding)
  • readAsDataURL(Blob|File)
  • readAsArrayBuffer(Blob|File)

其中 readAsText 用来读取文本文件,readAsDataUrl 可以用来读取图片。具体的介绍可以参考 这里 。FileReader 一般结合文件选择事件或者拖拽事件使用,因为通过这两个事件可以获得源文件。另外 FileReader 是异步读取的,通过 onload 事件可以监听文件是否读取完毕。下面是一个示例, 通过点击 <input type= "file"> 选择文件,然后读取文件内容。

document.getElementById("file-select").addEventListener("change", function(e) {e.stopPropagation();e.preventDefault();var reader = new FileReader();reader.readAsText(this.files[0]);reader.onload = function (e) {var content = e.target.result;//......};
}, false);

拖拽文件

为了方便用户操作,我们提供了点击和拖拽两种方式来上传文件。现在的主流浏览器都支持文件拖拽功能,下面是拖拽过程中触发的事件

事件 描述
dragstart 用户开始拖动对象时触发。
dragenter 鼠标初次移到目标元素并且正在进行拖动时触发。这个事件的监听器应该之指出这个位置是否允许放置元素。如果没有监听器或者监听器不执行任何操作,默认情况下不允许放置。
dragover 拖动时鼠标移到某个元素上的时候触发。
dragleave 拖动时鼠标离开某个元素的时候触发。
drag 对象被拖拽时每次鼠标移动都会触发。
drop 拖动操作结束,放置元素时触发。
dragend 拖动对象时用户释放鼠标按键的时候触发。

另外在拖拽过程中是不触发鼠标事件的。文件读取完后文件信息会保存在 DataTransfer 对象中。详细的介绍可以参考 这里 。下面是添加事件的示例

fileSelect.addEventListener("dragenter", dragMdEnter, false);
fileSelect.addEventListener("dragleave", dragMdLeave, false);
fileSelect.addEventListener('drop', dropMdFile, false);

读取拖拽的文件

function dropMdFile(e) {// 取消浏览器默认行为e.stopPropagation();e.preventDefault();var reader = new FileReader();reader.readAsText(e.dataTransfer.files[0]);reader.onload = function (e) {var content = e.target.result;//......};
}

本地图片显示

因为没有服务器,所以为了显示本地图片,使用了替换图片 src 的方式。首先读取本地文件,然后将 <img>src 路径替换为图片内容 。如下所示:

<img src="path">
// 替换为
<img src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgI...">

下面是具体的代码实现:

// 读取选择或者拖拽的文件(多个文件)
function processImages(imgFiles) {var index = 0;for (i = 0; i < imgFiles.length; i++) {var file = imgFiles[i];var reader = new FileReader();reader.readAsDataURL(file);(function (reader, file) {reader.onload = function (e) {cacheImages[file.name] = e.target.result;index++;if (index == length) {replaceImage();}}})(reader, file);}
}// 将路径替换为图片内容
function replaceImage() {var images = $("img");var i;for (i = 0; i < images.length; i++) {var imgSrc = images[i].src;var imgName = getImgName(imgSrc);if (cacheImages.hasOwnProperty(imgName)) {images[i].src = cacheImages[imgName];}}
}

如果图片过大,我们可以将图片压缩一下,具体方法就是创建一个 canvas 元素,将图片绘制到 canvas 上,然后将 canvas 转为图片。这种方式对 jpg 文件压缩效果较好,对 png 文件压缩效果不太好。下面是代码实现:

function compressImage(img, format) {var max_width = 862;var canvas = document.createElement('canvas');var width = img.width;var height = img.height;if (format == null || format == "") {format = "image/png";}if (width > max_width) {height = Math.round(height *= max_width / width);width = max_width;}// resize the canvas and draw the image data into itcanvas.width = width;canvas.height = height;var ctx = canvas.getContext("2d");ctx.drawImage(img, 0, 0, width, height);return canvas.toDataURL(format);
}

循环中使用异步回调函数

为了方便使用,我们可以同时上传多个图片,我们使用 for 循环来读取多个文件,但是有个问题是文件的读取是异步的,也就是说在 for 循环执行完之后,图片可能仍在读取中,当图片读取完后,再调用 onload 回调函数进行处理。简单一点就是说如何在 for 循环中正确使用延迟调用的回调函数。看下面的例子:

function print(value, callback) {console.log("value in print", value);setTimeout(callback, 1000);
}for(var i = 0; i < 4; i++) {var value = i;print(value, function() {console.log("value in callback", value);});
}

上面打的代码和我们读取图片文件的逻辑类似,callback 函数会在调用 print 函数1秒后执行,下面是输出结果

value in print 0
value in print 1
value in print 2
value in print 3
value in callback 3
value in callback 3
value in callback 3
value in callback 3

最后在 callbackvalue 值都是3,这是因为在 js 中没有块级作用域,只有函数作用域,也就是说下面的两段代码是等同的:

for(var i = 0; i < 4; i++) {var value = i;// do someting
}
// 等同于
var value;
for(var i = 0; i < 4; i++) {value = i;// do someting
}

因此,为了解决这个问题,我们只需要为循环中的回调函数添加一个单独的作用域即可,我们使用闭包来实现:

for(var i = 0; i < 4; i++) {var value = i;(function(value) {print(value, function() {console.log("value in callback", value);});}(value));
}

代码高亮

我们使用两款代码高亮插件 – highlight.js 和 prism.js,根据喜好可以自由切换。这两款插件对代码块的 html 格式有不同的要求,我们重写了 marked 中解析代码块的方法,根据高亮方式来生成不同的 html 代码:

renderer.code = function (code, lang) {if (Setting.highlight == Constants.highlight) {return "<pre><code class='" + lang + "'>" + code + "</code></pre>";}return "<pre><code class='language-" + lang + "'>" + code + "</code></pre>";
};

然后调用 highlight.js 和 prism.js 的代码高亮方法即可

if (Setting.highlight == Constants.highlight) {$('pre code').each(function (i, block) {hljs.highlightBlock(block);});
} else {// 添加行号支持$("pre").addClass("line-numbers");Prism.highlightAll();
}

目录

为了生成文件的目录,我们需要首先获得目录信息,因此我们重写 markedheading 方法, 将目录信息保存起来,同时为每个标题添加链接图标(仿照 github),下面是代码:

renderer.heading = function (text, level) {var slug = text.toLowerCase().replace(/[\s]+/g, '-');if (tocStr.indexOf(slug) != -1) {slug += "-" + tocDumpIndex;tocDumpIndex++;}tocStr += slug;toc.push({level: level,slug: slug,title: text});return "<h" + level + " id=\"" + slug + "\"><a href=\"#" + slug + "\" class=\"anchor\">" + '' +'<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewBox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>' +'' + "</a>" + text + "</h" + level + ">";
};

同时需要加入下面的 css,以是标题的链接图片正常显示:

h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor {text-decoration:none}h1:hover .anchor .octicon-link, h2:hover .anchor .octicon-link, h3:hover .anchor .octicon-link, h4:hover .anchor .octicon-link, h5:hover .anchor .octicon-link, h6:hover .anchor .octicon-link {visibility:visible}.octicon {display:inline-block;vertical-align:text-top;fill:currentColor;
}.anchor {float:left;padding-right:4px;margin-left:-20px;line-height:1;
}

为了生成目录,我们只需按照保存的目录信息,生成 <ul><li> 标签即可,具体的可以参考源码中的实现。

配置页面锚链接

目录使用的是页内锚链接的方式进行跳转,如下面所示:

<a href="#h1">跳转到 H1</a>
...
<h1 id="h1">我是 H1</h1>
...

默认情况下,页内锚链接跳转之后,目标标签(上面代码中的 <h1> )会移动到页面的最顶部,但是在我们的程序中有一个固定的 header,如果跳转到最顶部,目标标签会被 header 遮挡住,所以我们希望目标标签移动到距离页面顶部 header-height 的地方。为了实现我们的需要,只要加入下面的 css 代码即可。

:target:before {content:"";display:block;height:50px; /* fixed header height*/margin:-50px 0 0; /* negative fixed header height */
}

Todo 列表

Todo 列表实际上就是 checkbox 的列表,完成的工作用选中的 checkbox 表示,未完成的工作用喂选中的列表表示,如下图所示:

一般来说,会将下面形式的 markdown 代码解析为 todo 列表

-[x] 完成-[ ] 未完成-[ ] 未完成

为了实现这个功能,我们重写 marked 中解析列表的方法,加入对 todo 列表的支持。

renderer.listitem = function (text) {if (/^\s*\[[x ]\]\s*/.test(text)) {text = text.replace(/^\s*\[ \]\s*/, '<input type="checkbox" class="task-list-item-checkbox" disabled> ').replace(/^\s*\[x\]\s*/, '<input type="checkbox" class="task-list-item-checkbox" disabled checked> ');return '<li style="list-style: none">' + text + '</li>';} else {return '<li>' + text + '</li>';}
};

同时加入下面的样式:

.task-list-item-checkbox {margin:0 0.2em 0.25em -2.3em;vertical-align:middle;
}[type="checkbox"], [type="radio"] {box-sizing:border-box;padding:0;
}

缓存

现在的浏览器都已经支持 localStorage,可以方便的存储数据。localStorage 就是一个对象。我们存储数据就是直接给它添加一个属性,可以通过 localStoage["a"]=1 或者 localStorage.a = 1 的方式来存储数据,但是看起来总觉的不太优雅,因为一般使用下面的方式来操作 localStorage

localStorage.setItem(key, vlaue);
localStorage.getItem(key);
localStorage.removeItem(key);

另外 localStorage 也有一些局限,使用时需要注意:

  • 存储空间有限制,一般是 5M 左右,和浏览器有关
  • 用户清除浏览器缓存之后有可能丢失本地缓存的数据
  • 不能直接存对象,要先使用 JSON.stringfy 方法将对象进行序列化处理之后再保存。使用时需要使用 JSON.parse 方法将字符串转为对象。

导出文件

通过使用 FileSaver.js,我们可以方便的在浏览器端生成文件,并提供给用户下载。使用方法也很简单:

var blob = new Blob([htmlContent], {type: "text/html;charset=utf-8"});
saveAs(blob, name);

扩展

我们提供了一些扩展功能,用来更好的展示 markdown 内容。在现在的程序中我们可以很方便的添加扩展功能,下面会具体介绍。

自定义扩展

为了添加扩展,我们首先需要确定哪些内容需要作为扩展处理。因为在将 markdown 文件转为 html 的过程中,一般是不处理代码块中的内容的,所以我们使用代码块来存放扩展内容,通过代码块的语言来确定是哪种扩展。以添加序列图扩展为例:

  • 确定时序图的代码标记

  • 修改 marked 中对于代码块的解析函数,添加对于时序图标记的支持

var renderer = new marked.Renderer();
var originalCodeFun = function (code, lang) {if (Setting.highlight == Constants.highlight) {return "<pre><code class='" + lang + "'>" + code + "</code></pre>";}return "<pre><code class='language-" + lang + "'>" + code + "</code></pre>";
};
renderer.code = function (code, language) {if (language == "seq") {return "<div class='diagram' id='diagram'>" + code + "</div>"} else {return originalCodeFun.call(this, code, language);}
};
marked.setOptions({renderer: renderer
});
  • 引入 js-sequence-diagrams 相关文件
<link href="{{ bower directory }}/js-sequence-diagrams/dist/sequence-diagram-min.css" rel="stylesheet" />
<script src="{{ bower directory }}/bower-webfontloader/webfont.js" /><script src="{{ bower directory }}/snap.svg/dist/snap.svg-min.js" />
<script src="{{ bower directory }}/underscore/underscore-min.js" /><script src="{{ bower directory }}/js-sequence-diagrams/dist/sequence-diagram-min.js" />
  • 渲染 Markdown 文件时,调用相关函数
$(".diagram").sequenceDiagram({theme: 'simple'});

添加扩展会影响文件的渲染速度,如果不需要某个扩展可以手动关闭。

Mathjax

使用Mathjax 对数学公式进行支持。关于Mathjax 语法,请参考这里。下面是添加扩展的流程:

  • 引入文件并配置
<script type="text/x-mathjax-config">MathJax.Hub.Config({tex2jax: {inlineMath: [['$','$'], ['\\(','\\)']]},TeX: {equationNumbers: {autoNumber: ["AMS"],useLabelIds: true}},"HTML-CSS": {linebreaks: {automatic: true}},SVG: {linebreaks: {automatic: true}}});</script>
<script type="text/javascript" src="http://cdn.bootcss.com/mathjax/2.7.0/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
  • 将 markdown 文件转为 html 之后,调用 Mathjax 中的方法将对应标记转为数学公式。
// content 是需要处理的 html 标签的 id
MathJax.Hub.Queue(["Typeset", MathJax.Hub, "content"]);

Emoji

使用 emojify.js 来提供对 Emoji 标签的支持。Emoji表情参见 EMOJI CHEAT SHEET。下面是添加扩展的流程

  • 引用文件并配置
<script src="http://cdn.bootcss.com/emojify.js/1.1.0/js/emojify.min.js"></script>
<script type="text/javascript">emojify.setConfig({emojify_tag_type: 'div',           // Only run emojify.js on this elementonly_crawl_id: null,            // Use to restrict where emojify.js appliesimg_dir: 'http://cdn.bootcss.com/emojify.js/1.0/images/basic',  // Directory for emoji imagesignored_tags: {                // Ignore the following tags'SCRIPT': 1,'TEXTAREA': 1,'A': 1,'PRE': 1,'CODE': 1}});</script>
  • 将 markdown 文件转为 html 之后,调用 emojify 中的方法将对应标记转换 emoji 表情。
 emojify.run(document.getElementById('content'))

图表 (ECharts)

使用 ECharts 来提供对图表的支持。ECharts 的语法可以参考 官网的示例。下面是使用方法:

  • 确定 ECharts 在 markdown 中的语法标签

  • 在 code 方法解析中添加对 echarts 的支持

renderer.code = function (code, language) {switch (language) {case "echarts":if (Setting.echarts) {return loadEcharts(code);}return originalCodeFun.call(this, code, language);}
};
function loadEcharts(text) {var width = "100%";var height = "400px";try {var options = eval("(" + text + ")");if (options.hasOwnProperty("width")) {width = options["width"];}if (options.hasOwnProperty("height")) {height = options["height"];}echartIndex++;echartData.push({id: echartIndex,option: options,previousOption: text});return '<div id="echarts-' + echartIndex + '" style="width: ' + width + ';height:' + height + ';"></div>'} catch (e) {console.log(e);return "";}
}
  • 将 markdown 文件转为 html 之后,调用 echarts 中的方法,将对应的 div 转为图表:
var chart;
echartData.forEach(function (data) {if (data.option.theme) {chart = echarts.init(document.getElementById('echarts-' + data.id), data.option.theme);} else {chart = echarts.init(document.getElementById('echarts-' + data.id));}chart.setOption(data.option);
});

评论

在生成Github Page页面时,我们可以选择添加 多说 或者 Disqus 评论,其中多说就是在导出的页面中加入下面的代码

<div class="ds-thread" data-thread-key="" data-title="" data-url=""></div>
<script type="text/javascript">var duoshuoQuery = {short_name: ""};(function() {var ds = document.createElement("script");ds.type = "text/javascript";ds.async = true;ds.src = (document.location.protocol == "https:" ? "https:" : "http:") + "//static.duoshuo.com/embed.js";ds.charset = "UTF-8";(document.getElementsByTagName("head")[0] || document.getElementsByTagName("body")[0]).appendChild(ds);})();</script>

其中 data-thread-key, data-title, data-urlshort_name 是需要我们自定义的东西。而Disqus 需要在导出时插入下面的代码:

<div id="disqus_thread"></div>
<script type="text/javascript">var disqus_shortname = '';var prefix = document.location.protocol == "https:" ? "https:" : "http:"var disqus_config = function() {this.page.url = "";this.page.identifier = ""};(function() {var d = document,s = d.createElement('script');s.src = prefix + '//' + disqus_shortname + '.disqus.com/embed.js';s.setAttribute('data-timestamp', +new Date());(d.head || d.body).appendChild(s);})();</script>

其中 disqus_shortname, page.urlpage.indertifier 是需要我们自定义的东西。这里需要注意的是 page.url 要使用绝对路径。

具体的插入逻辑可参考源码的实现,这里不再赘述。

【应用】Markdown 在线阅读器相关推荐

  1. Cmd Markdown 编辑阅读器

    欢迎使用 Cmd Markdown 编辑阅读器 我们理解您需要更便捷更高效的工具记录思想,整理笔记.知识,并将其中承载的价值传播给他人,Cmd Markdown 是我们给出的答案 -- 我们为记录思想 ...

  2. Markdown 编辑阅读器

    border="0" width="330" height="86" src="//music.163.com/outchain/ ...

  3. Cmd Markdown 编辑阅读器使用教程

    欢迎使用 Cmd Markdown 编辑阅读器 我们理解您需要更便捷更高效的工具记录思想,整理笔记.知识,并将其中承载的价值传播给他人,Cmd Markdown 是我们给出的答案 -- 我们为记录思想 ...

  4. Markdown 编辑阅读器使用方法

    欢迎使用 Cmd Markdown 编辑阅读器 我们理解您需要更便捷更高效的工具记录思想,整理笔记.知识,并将其中承载的价值传播给他人,Cmd Markdown 是我们给出的答案 -- 我们为记录思想 ...

  5. 作业部落 Cmd Markdown 编辑阅读器

    Cmd Markdown 编辑阅读器 Cmd Markdown 编辑阅读器 WindowsMacLinux 全平台客户端 什么是 Markdown 书写一个质能守恒公式1 高亮一段代码2 高效绘制 流 ...

  6. 欢迎使用 Cmd Markdown 编辑阅读器

    欢迎使用 Cmd Markdown 编辑阅读器 我们理解您需要更便捷更高效的工具记录思想,整理笔记.知识,并将其中承载的价值传播给他人,Cmd Markdown 是我们给出的答案 -- 我们为记录思想 ...

  7. Aardio做的一个小说在线阅读器

    这是一片没有diao用的文章,没有任何技术含量. 看小说,因为太多广告,不过路由器拦截了大部分广告,其实体验也还好,不过还是想着自己做一个在线阅读器. 在线阅读器采用的是web.form.以及jq取文 ...

  8. 爬取漫画网址的并生产一个在线阅读器

    文章目录 爬取漫画网址的并生产一个在线阅读器 代码分析 实现代码 爬取漫画网址的并生产一个在线阅读器 代码分析 如果要做一个爬虫的话,一定要会看网页的源代码,看有没有自己想要的数据, http://m ...

  9. pdf在线查看 html代码,PDF网页在线阅读器pdfjs

    pdfjs是PDF网页在线阅读器,直接本地或远程加载PDF,设置输出文本字体.自适应页面显示.自定义头部底部显示. var doc = new pdfjs.Document({ font: asset ...

最新文章

  1. Java里边什么是值传递和引用传递?两个有什么区别
  2. 如何使用 DSL 实现 DDD 的快速落地
  3. Spark累加器(Accumulator)陷阱及解决办法
  4. svc android,在android中,如何使用 Svc WCF服务_android_开发99编程知识库
  5. Java实现阶乘运算
  6. 包邮送50本畅销书,涵盖Python、数据库、机器学习等!
  7. java gettext用法_Java Context.getText方法代码示例
  8. 我对XCode Objective-c Cocoa的简单理解
  9. Crusher Django 学习笔记4 使用Model
  10. 小学生python编程教程-围观~山东省的小学生Python编程入门都学的什么?
  11. ADOBE AIR 技术
  12. python分解质因数例题_python分解质因数
  13. 远程重启h3c路由器_H3C路由器远程登陆命令 -192.168.0.1 路由器怎么设置|192.168.1.1登陆|路由器设置密码-路由器网...
  14. Redis(九)Redis的过期时间操作以及部分常用命令
  15. EXCEL 单元格如何显示输入的首个单引号字符
  16. 前端缓存【web缓存】
  17. node爬取网易云歌曲
  18. 决策树--信息增益、信息增益比、Geni指数的理解
  19. 首届STAC科创联合大会在成都召开 “开悟” AI平台将成为行业生态基建
  20. python语言正确的标识符是__python基础知识:python的标识符和关键字

热门文章

  1. 暴多的教学视频,想要的就快下!
  2. 指法练习软件ECAI使用指南
  3. linux pdf 宋宝华,51CTO博客-专业IT技术博客创作平台-技术成就梦想
  4. 暖风机家用最好的牌子 适合家用大面积的暖风机哪种好
  5. 解决:win10搜狗输入法突然无效
  6. 不可不知的潮流文化、新词汇, 00后聊天词汇
  7. 艾尔米特插值的MATLAB实现,埃尔米特(Hermite)插值
  8. 保研/面试复习-数据结构与算法-万字总结(近三万字)
  9. win7计算机组策略打不开,Win7系统组策略打不开怎么办?组策略被锁住了怎么处理?...
  10. c语言设计垃圾分类答题游戏,小程序案例源码001~垃圾分类+答题小程序效果演示...