瀏覽代碼

v1.0.0-beta 源码正式开源

小莫唐尼 3 年之前
當前提交
636ae7b169
共有 100 個文件被更改,包括 12043 次插入0 次删除
  1. 5 0
      .gitignore
  2. 20 0
      .hbuilderx/launch.json
  3. 77 0
      App.vue
  4. 3 0
      CHANGELOG.md
  5. 19 0
      GIT_README.md
  6. 20 0
      LICENSE
  7. 76 0
      README.md
  8. 86 0
      api/admin/attachment.js
  9. 70 0
      api/admin/category.js
  10. 142 0
      api/admin/comments.js
  11. 61 0
      api/admin/journal.js
  12. 59 0
      api/admin/links.js
  13. 28 0
      api/admin/logs.js
  14. 101 0
      api/admin/photos.js
  15. 121 0
      api/admin/posts.js
  16. 56 0
      api/admin/tags.js
  17. 83 0
      api/admin/user.js
  18. 22 0
      api/archive.js
  19. 25 0
      api/article.js
  20. 15 0
      api/blogger.js
  21. 24 0
      api/category.js
  22. 45 0
      api/comment.js
  23. 77 0
      api/index.js
  24. 74 0
      api/journal.js
  25. 24 0
      api/link.js
  26. 112 0
      api/login.js
  27. 24 0
      api/menu.js
  28. 30 0
      api/option.js
  29. 31 0
      api/photo.js
  30. 82 0
      api/post.js
  31. 67 0
      api/sheet.js
  32. 22 0
      api/statistics.js
  33. 40 0
      api/theme.js
  34. 1 0
      api/url.js
  35. 74 0
      common/filters/index.js
  36. 38 0
      common/http/index.js
  37. 84 0
      common/http/interceptors.js
  38. 29 0
      common/http/request.js
  39. 5 0
      common/icons/halocoloriconfont.css
  40. 4 0
      common/icons/haloiconfont.css
  41. 2 0
      common/icons/mphtmliconfont.css
  42. 23 0
      common/locales/en.js
  43. 23 0
      common/locales/zh.js
  44. 97 0
      common/markdown/markdown.config.js
  45. 155 0
      common/markdown/markdown.scss
  46. 67 0
      common/mixins/index.js
  47. 38 0
      common/styles/app.base.scss
  48. 8 0
      common/styles/app.theme.scss
  49. 4 0
      common/theme/theme.css
  50. 304 0
      components/article-card/article-card.vue
  51. 197 0
      components/article-min-card/article-min-card.vue
  52. 207 0
      components/attachment-select/attachment-select.vue
  53. 194 0
      components/bottom-tool-bar/bottom-tool-bar.vue
  54. 180 0
      components/cache-image/cache-image.vue
  55. 63 0
      components/category-mini-card/category-mini-card.vue
  56. 147 0
      components/comment-item/comment-item.vue
  57. 266 0
      components/comment-list/comment-list.vue
  58. 341 0
      components/e-swiper/e-swiper.scss
  59. 233 0
      components/e-swiper/e-swiper.vue
  60. 177 0
      components/journal-card/journal-card.vue
  61. 250 0
      components/lff-barrage/lff-barrage.vue
  62. 15 0
      components/lff-barrage/markdown.md
  63. 11 0
      components/mp-html/components/mp-html/editable/config.js
  64. 532 0
      components/mp-html/components/mp-html/editable/index.js
  65. 203 0
      components/mp-html/components/mp-html/emoji/index.js
  66. 5 0
      components/mp-html/components/mp-html/highlight/config.js
  67. 96 0
      components/mp-html/components/mp-html/highlight/index.js
  68. 2 0
      components/mp-html/components/mp-html/highlight/prism.min.js
  69. 138 0
      components/mp-html/components/mp-html/img-cache/index.js
  70. 34 0
      components/mp-html/components/mp-html/markdown/index.js
  71. 5 0
      components/mp-html/components/mp-html/markdown/marked.min.js
  72. 579 0
      components/mp-html/components/mp-html/mp-html.vue
  73. 1119 0
      components/mp-html/components/mp-html/node/node.vue
  74. 1342 0
      components/mp-html/components/mp-html/parser.js
  75. 129 0
      components/mp-html/components/mp-html/style/index.js
  76. 175 0
      components/mp-html/components/mp-html/style/parser.js
  77. 0 0
      components/mp-html/static/app-plus/mp-html/js/handler.js
  78. 0 0
      components/mp-html/static/app-plus/mp-html/js/uni.webview.min.js
  79. 0 0
      components/mp-html/static/app-plus/mp-html/local.html
  80. 735 0
      components/r-canvas/r-canvas.js
  81. 26 0
      components/r-canvas/r-canvas.vue
  82. 784 0
      components/t-color-picker/t-color-picker.vue
  83. 89 0
      components/wave/wave.vue
  84. 20 0
      config/ad.config.template.js
  85. 113 0
      config/halo.config.template.js
  86. 7 0
      config/keys.js
  87. 8 0
      config/sheets.config.js
  88. 20 0
      index.html
  89. 72 0
      js_sdk/fy-showModal/index.js
  90. 62 0
      js_sdk/fy-showModal/router-interceptor.js
  91. 176 0
      js_sdk/fy-showModal/showModal.js
  92. 99 0
      js_sdk/luch-request/luch-request/adapters/index.js
  93. 51 0
      js_sdk/luch-request/luch-request/core/InterceptorManager.js
  94. 200 0
      js_sdk/luch-request/luch-request/core/Request.js
  95. 20 0
      js_sdk/luch-request/luch-request/core/buildFullPath.js
  96. 30 0
      js_sdk/luch-request/luch-request/core/defaults.js
  97. 6 0
      js_sdk/luch-request/luch-request/core/dispatchRequest.js
  98. 103 0
      js_sdk/luch-request/luch-request/core/mergeConfig.js
  99. 16 0
      js_sdk/luch-request/luch-request/core/settle.js
  100. 69 0
      js_sdk/luch-request/luch-request/helpers/buildURL.js

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+unpackage/ 
+config/halo.config.js
+config/ad.config.js
+package-lock.json

+ 20 - 0
.hbuilderx/launch.json

@@ -0,0 +1,20 @@
+{ // launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
+  // launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数
+    "version": "0.0",
+    "configurations": [{
+     	"app-plus" : 
+     	{
+     		"launchtype" : "local"
+     	},
+     	"default" : 
+     	{
+     		"launchtype" : "local"
+     	},
+     	"mp-weixin" : 
+     	{
+     		"launchtype" : "local"
+     	},
+     	"type" : "uniCloud"
+     }
+    ]
+}

+ 77 - 0
App.vue

@@ -0,0 +1,77 @@
+<script>
+import HaloConfig from '@/config/halo.config.js';
+import HaloAdConfig from '@/config/ad.config.js';
+
+// app升级检测(搭配:https://ext.dcloud.net.cn/plugin?id=4470 升级中心)
+import CheckAppUpdate from '@/uni_modules/uni-upgrade-center-app/utils/check-update';
+import { CheckWxUpdate } from '@/utils/update.js';
+export default {
+	globalData: {
+		baseApiUrl: HaloConfig.apiUrl,
+		...HaloConfig,
+		haloAdConfig: HaloAdConfig
+	},
+	onLaunch: function() {
+		console.log('App Launch');
+
+		// #ifdef APP-PLUS
+		CheckAppUpdate();
+		// #endif
+
+		// #ifdef MP-WEIXIN
+		CheckWxUpdate();
+		uni.$tm.vx.commit('setWxShare', HaloConfig.wxShareConfig);
+		// #endif
+
+		// 监听中间按钮(暂时没有使用)
+		uni.onTabBarMidButtonTap(() => {
+			console.log('点击中间按钮');
+		});
+
+		// 初始化博主信息
+		uni.$tm.vx.actions('blogger/fnGetBlogger');
+
+		// 临时:检查是否有用户,没有的话添加一个默认的用户
+		uni.$tm.vx.actions('user/checkAndSetDefaultUser');
+
+		// 启动检查app的配置是否已经就绪,若未就绪则设置默认的
+		uni.$tm.vx.actions('setting/checkAndSetDefaultAppSettings');
+	},
+	onShow: function() {
+		console.log('App Show');
+	},
+	onHide: function() {
+		console.log('App Hide');
+	}
+};
+</script>
+
+<style lang="scss">
+// 基础样式
+@import './common/styles/app.theme.scss';
+@import './common/styles/app.base.scss';
+
+// 引入tmUI2.x样式
+@import './tm-vuetify/mian.min.css';
+// 引入tmUI2.x主题包
+@import './tm-vuetify/scss/theme.css';
+// 引入tmUI2.x预置图标
+@import './tm-vuetify/scss/fonts/fontawesome_base64.css';
+
+// 自定义图标
+@import './common/icons/halocoloriconfont.css';
+@import './common/icons/haloiconfont.css';
+@import './common/icons/mphtmliconfont.css';
+
+/* #ifndef MP-WEIXIN */
+@import './common/markdown/markdown.scss'; //引入markdown呈现
+/* #endif */
+
+page {
+	// background-color: #f3f5f7;
+	// background-color: #f7f7f7;
+	// background-color: #ffffff;
+	background-color: #fafafa;
+	// background-color: #f4f5f5;
+}
+</style>

+ 3 - 0
CHANGELOG.md

@@ -0,0 +1,3 @@
+# 更新日志
+
+## 暂无更新记录

+ 19 - 0
GIT_README.md

@@ -0,0 +1,19 @@
+# Git管理同步配置
+
+#### 同时推送Gitee和Github
+找到项目目录下的`.git`文件夹,打开`config`文件,修改相关代码:
+原始代码:
+```bash
+ [remote "origin"]
+	url = https://gitee.com/ialley-workshop-open/uni-halo.git
+	fetch = +refs/heads/*:refs/remotes/origin/*
+``` 
+修改后的代码:
+```bash
+[remote "github"]
+	url = https://github.com/ialley-workshop-open/uni-halo.git
+	fetch = +refs/heads/*:refs/remotes/github/*
+[remote "gitee"]
+	url = https://gitee.com/ialley-workshop-open/uni-halo.git
+	fetch = +refs/heads/*:refs/remotes/gitee/*
+``` 

+ 20 - 0
LICENSE

@@ -0,0 +1,20 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 RuoYi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 76 - 0
README.md

@@ -0,0 +1,76 @@
+<p align="center">
+    <a href="https://uni-halo.925i.cn" target="_blank" rel="noopener noreferrer">
+        <img width="100" src="https://b.925i.cn/uni_halo/uni_halo_logo.png" alt="uni-halo logo" />
+    </a>
+</p>
+
+<p align="center"><b>uni-halo</b> 基于Halo一款现代化的开源博客/CMS系统API开发的多端系统,值得一试。</p>
+ 
+<br />
+<p align="center">
+	<a href="https://b.925i.cn">作者博客</a>
+	<a href="https://uni-halo.925i.cn">文档地址</a>
+	<a href="https://gitee.com/ialley-workshop-open/uni-halo">Gitee仓库</a>
+	<a href="https://github.com/ialley-workshop-open/uni-halo">Github仓库</a>
+</p>
+
+---
+
+如果您觉得这个项目对您有帮助,可以帮作者买杯饮料鼓励鼓励!
+
+<table rules="none" align="center" border="0">
+	<tr>
+		<td>
+			<center>
+				<img src="https://uni-halo.925i.cn/qrcode/zfb.png" width="100%" />
+				<br/>
+				<font color="AAAAAA">支付宝打赏</font>
+			</center>
+		</td>
+			<td>
+			<center>
+				<img src="https://uni-halo.925i.cn/qrcode/wx.png" width="100%" />
+				<br/>
+				<font color="AAAAAA">微信打赏</font>
+			</center>
+		</td>
+			<td>
+			<center>
+				<img src="https://uni-halo.925i.cn/qrcode/qq.png" width="100%" />
+				<br/>
+				<font color="AAAAAA">QQ打赏</font>
+			</center>
+		</td>
+	</tr>
+</table>
+
+
+## 快速开始
+
+详细部署文档请查阅 [uni-halo-doc](https://uni-halo.925i.cn/)
+
+- 1、拉取或下载项目<https://gitee.com/ialley-workshop-open/uni-halo>;
+- 2、通过hbuilderx 导入项目;
+- 3、命令行执行 npm i 安装依赖; 
+- 4、配置运行信息,找到项目根目录config目录,将`halo.config.template.js`修改为 `halo.config.js` 并设置相关信息;
+- 5、点击hbuilderx 工具 右上角预览、或者点击工具栏 运行-内置浏览器运行;
+- 6、项目发行:
+-    发行小程序:点击工具栏 发行 -> 小程序-微信
+-    发行APP:  点击工具栏 发行 -> 原生App-云打包
+
+## 在线体验
+
+- 敬请期待...
+ 
+ 
+## 许可证
+<a href="https://gitee.com/ialley-workshop-open/uni-halo/master/LICENSE"><img src="https://img.shields.io/github/license/mashape/apistatus.svg"></a>
+uni-halo 使用 MIT 协议开源,请遵守开源协议。
+
+## 贡献
+
+贡献代码请查看 [代码贡献规范](https://uni-halo.925i.cn/standard/category.html)
+
+## Halo Api地址
+- 接口文档地址(内容端):<https://api.halo.run/content-api.html> 
+- 接口文档地址(管理端):<https://api.halo.run/admin-api.html> 

+ 86 - 0
api/admin/attachment.js

@@ -0,0 +1,86 @@
+/**
+ * 附件管理
+ * @see https://api.halo.run/admin-api.html#tag/attachment-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 分页获取附件列表
+	 * {
+	 *	"attachmentType": "ALIOSS" "BAIDUBOS" "HUAWEIOBS" "LOCAL" "MINIO" "QINIUOSS" "SMMS" "TENCENTCOS" "UPOSS",
+	 *	"keyword": "string"
+	 *	"mediaType": "string"
+	 *	"page": "string"
+	 *	"size": "string"
+	 *	"sort": "string"
+	 * }
+	 */
+	getAttachmentsByPage: (params) => {
+		return HttpHandler.Get('/api/admin/attachments', params, {})
+	},
+
+	/**
+	 * 获取所有的附件类型
+	 */
+	getAttachmentsMediaTypes: () => {
+		return HttpHandler.Get('/api/admin/attachments/media_types')
+	},
+
+	/**
+	 * 根据附件类型获取所有的附件列表
+	 */
+	getAttachmentsTypes: () => {
+		return HttpHandler.Get('/api/admin/attachments/types')
+	},
+
+	/**
+	 * 根据附件Id获取附件详情
+	 */
+	getAttachmentsById: (attachmentId) => {
+		return HttpHandler.Get(`/api/admin/attachments/${attachmentId}`)
+	},
+
+	/**
+	 * 上传附件-单文件(file)
+	 * {
+	 *	 file:文件对象
+	 * }
+	 */
+	uploadAttachment: (data) => {
+		return HttpHandler.Upload(`/api/admin/attachments/upload`, data)
+	},
+
+	/**
+	 * 上传附件-多文件(files)
+	 * {
+	 *	 files:文件对象集合
+	 * }
+	 */
+	uploadAttachments: (data) => {
+		return HttpHandler.Upload(`/api/admin/attachments/uploads`, data)
+	},
+
+	/**
+	 * 修改一个附件信息
+	 */
+	updateAttachmentById: (attachmentId, name) => {
+		return HttpHandler.Put(`/api/admin/attachments/${attachmentId}`, {
+			name: name
+		})
+	},
+
+	/**
+	 * 批量删除附件(id集合)
+	 */
+	deleteAttachmentByIds: (attachmentIds = []) => {
+		return HttpHandler.Delete(`/api/admin/attachments`, attachmentIds)
+	},
+
+	/**
+	 *  删除单个附件 
+	 */
+	deleteAttachmentById: (attachmentId) => {
+		return HttpHandler.Delete(`/api/admin/attachments/${attachmentId}`)
+	},
+}

+ 70 - 0
api/admin/category.js

@@ -0,0 +1,70 @@
+/**
+ * 文章分类管理
+ * @see https://api.halo.run/admin-api.html#tag/category-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 查询所有的文章分类
+	 * {
+	 *	"sort": ["",""], // 排序
+	 *	"more": "Boolean" ,// 更多参数(回调)
+	 * }
+	 */
+	getCategoryList: (params) => {
+		return HttpHandler.Get('/api/admin/categories', params)
+	},
+
+	/**
+	 * 查询所有的文章分类(树形数据)
+	 * {
+	 *	"sort": ["",""], // 排序
+	 * }
+	 */
+	getCategoryListTree: (params) => {
+		return HttpHandler.Get('/api/admin/categories/tree_view', params)
+	},
+
+	/**
+	 * 查询文章分类详情 
+	 * @param {Number} categoryId 分类ID
+	 */
+	getCategoryDetail: (categoryId) => {
+		return HttpHandler.Get(`/api/admin/categories/${categoryId}`)
+	},
+
+	/**
+	 * 新增文章分类
+	 * {
+	 *	  "description": "string",
+	 *	  "id": 0,
+	 *	  "name": "string",
+	 *	  "parentId": 0,
+	 *	  "password": "string",
+	 *	  "priority": 0,
+	 *	  "slug": "string",
+	 *	  "thumbnail": "string"
+	 * }
+	 */
+	createCategory: (data) => {
+		return HttpHandler.Post(`/api/admin/categories`, data)
+	},
+
+	/**
+	 * 修改文章分类信息
+	 * @param {Number} categoryId 分类id
+	 * @param {Object} data 同新增  
+	 */
+	updateCategoryById: (categoryId, data) => {
+		return HttpHandler.Put(`/api/admin/categories/${categoryId}`, data)
+	},
+
+	/**
+	 * 删除单个文章分类
+	 * @param {Number} categoryId 文章分类id
+	 */
+	deleteCategoryById: (categoryId) => {
+		return HttpHandler.Delete(`/api/admin/categories/${categoryId}`)
+	},
+}

+ 142 - 0
api/admin/comments.js

@@ -0,0 +1,142 @@
+/**
+ * 文章评论管理
+ * @see https://api.halo.run/admin-api.html#tag/journal-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 查询文章评论
+	 * {
+	 *	"keyword":"",    // 关键字
+	 *	"page": 0,       // 分页索引
+	 *	"size": 10,      // 分页大小
+	 *	"sort": ["",""], // 排序
+	 *	"status": "" ,    // 类型  "AUDITING" "PUBLISHED" "RECYCLE"
+	 * }
+	 */
+	getPostsComments: (params) => {
+		return HttpHandler.Get('/api/admin/posts/comments', params)
+	},
+
+	/**
+	 * 回复文章评论
+	 * {
+	 *  "allowNotification": true,
+	 *  "author": "string",
+	 *  "authorUrl": "string",
+	 *  "content": "string",
+	 *  "email": "string",
+	 *  "parentId": 0,
+	 *  "postId": 0
+	 * }
+	 */
+	postPostsComments: (data) => {
+		return HttpHandler.Post('/api/admin/posts/comments', data)
+	},
+	/**
+	 * 更新文章评论状态
+	 * @param {Number} commentId  id
+	 * @param {String} status   "AUDITING" "PUBLISHED" "RECYCLE"
+	 */
+	updatePostsCommentsStatus: (commentId, status) => {
+		return HttpHandler.Put(`/api/admin/posts/comments/${commentId}/status/${status}`)
+	},
+	/**
+	 * 删除文章评论
+	 * @param {Number} commentId  id
+	 */
+	deletePostsCommentsById: (commentId) => {
+		return HttpHandler.Delete(`/api/admin/posts/comments/${commentId}`)
+	},
+
+	/**
+	 * 查询页面评论
+	 * {
+	 *	"keyword":"",    // 关键字
+	 *	"page": 0,       // 分页索引
+	 *	"size": 10,      // 分页大小
+	 *	"sort": ["",""], // 排序
+	 *	"status": "" ,    // 类型  "AUDITING" "PUBLISHED" "RECYCLE"
+	 * }
+	 */
+	getSheetsComments: (params) => {
+		return HttpHandler.Get('/api/admin/sheets/comments', params)
+	},
+
+	/**
+	 * 回复页面评论
+	 * {
+	 *  "allowNotification": true,
+	 *  "author": "string",
+	 *  "authorUrl": "string",
+	 *  "content": "string",
+	 *  "email": "string",
+	 *  "parentId": 0,
+	 *  "postId": 0
+	 * }
+	 */
+	postSheetsComments: (data) => {
+		return HttpHandler.Post('/api/admin/sheets/comments', data)
+	},
+	/**
+	 * 更新页面评论状态
+	 * @param {Number} commentId  id
+	 * @param {String} status   "AUDITING" "PUBLISHED" "RECYCLE"
+	 */
+	updateSheetsCommentsStatus: (commentId, status) => {
+		return HttpHandler.Put(`/api/admin/sheets/comments/${commentId}/status/${status}`)
+	},
+	/**
+	 * 删除页面评论
+	 * @param {Number} commentId  id
+	 */
+	deleteSheetsCommentsById: (commentId) => {
+		return HttpHandler.Delete(`/api/admin/sheets/comments/${commentId}`)
+	},
+
+	/**
+	 * 查询日记评论
+	 * {
+	 *	"keyword":"",    // 关键字
+	 *	"page": 0,       // 分页索引
+	 *	"size": 10,      // 分页大小
+	 *	"sort": ["",""], // 排序
+	 *	"status": "" ,    // 类型  "AUDITING" "PUBLISHED" "RECYCLE"
+	 * }
+	 */
+	getJournalsComments: (params) => {
+		return HttpHandler.Get('/api/admin/journals/comments', params)
+	},
+
+	/**
+	 * 回复日记评论
+	 * {
+	 *  "allowNotification": true,
+	 *  "author": "string",
+	 *  "authorUrl": "string",
+	 *  "content": "string",
+	 *  "email": "string",
+	 *  "parentId": 0,
+	 *  "postId": 0
+	 * }
+	 */
+	postJournalsComments: (data) => {
+		return HttpHandler.Post('/api/admin/journals/comments', data)
+	},
+	/**
+	 * 更新日记评论状态
+	 * @param {Number} commentId  id
+	 * @param {String} status   "AUDITING" "PUBLISHED" "RECYCLE"
+	 */
+	updateJournalsCommentsStatus: (commentId, status) => {
+		return HttpHandler.Put(`/api/admin/journals/comments/${commentId}/status/${status}`)
+	},
+	/**
+	 * 删除日记评论
+	 * @param {Number} commentId  id
+	 */
+	deleteJournalsCommentsById: (commentId) => {
+		return HttpHandler.Delete(`/api/admin/journals/comments/${commentId}`)
+	},
+}

+ 61 - 0
api/admin/journal.js

@@ -0,0 +1,61 @@
+/**
+ * 个人日记管理
+ * @see https://api.halo.run/admin-api.html#tag/journal-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 查询所有的日记列表
+	 * {
+	 *	"keyword":"",    // 关键字
+	 *	"page": 0,       // 分页索引
+	 *	"size": 10,      // 分页大小
+	 *	"sort": ["",""], // 排序
+	 *	"type": "" ,     // 类型 "INTIMATE" "PUBLIC"
+	 * }
+	 */
+	getJournals: (params) => {
+		return HttpHandler.Get('/api/admin/journals', params)
+	},
+
+	/**
+	 * 查询最近的所有的日记列表
+	 * {
+	 *	"top":number, // 数量
+	 * }
+	 */
+	getLatestJournals: (params) => {
+		return HttpHandler.Get('/api/admin/journals/latest', params)
+	},
+
+	/**
+	 * 新增个人日记
+	 * {
+	 *	  "content": "string",
+	 *	  "keepRaw": true,
+	 *	  "sourceContent": "string",
+	 *	  "type": "INTIMATE", 
+	 * }
+	 */
+	createJournals: (data) => {
+		return HttpHandler.Post(`/api/admin/journals`, data)
+	},
+
+	/**
+	 * 修改个人日记信息
+	 * @param {Number} journalsId id
+	 * @param {Object} data 同新增  
+	 */
+	updateJournalsById: (journalsId, data) => {
+		return HttpHandler.Put(`/api/admin/journals/${journalsId}`, data)
+	},
+
+	/**
+	 * 删除个人日记
+	 * @param {Number} journalsId  id
+	 */
+	deleteJournalsById: (journalsId) => {
+		return HttpHandler.Delete(`/api/admin/journals/${journalsId}`)
+	},
+}

+ 59 - 0
api/admin/links.js

@@ -0,0 +1,59 @@
+/**
+ * 友链管理
+ * @see https://api.halo.run/admin-api.html#tag/link-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	// 获取友链列表
+	getLinkList: () => {
+		return HttpHandler.Get('/api/admin/links')
+	},
+	/**
+	 * 获取友链详情
+	 * @params { Number } linkId 友链Id
+	 */
+	getLinkDetail: (linkId) => {
+		return HttpHandler.Get(`/api/admin/links/${linkId}`)
+	},
+	/**
+	 * 新增友链
+	 * {
+	 *	"description": "string",
+	 *	"logo": "string",
+	 *	"name": "string",
+	 *	"priority": 0,
+	 *	"team": "string",
+	 *	"url": "string"
+	 * }
+	 */
+	addLink: (data) => {
+		return HttpHandler.Post('/api/admin/links', data, {})
+	},
+	/**
+	 * 修改友链
+	 * {
+	 *	"description": "string",
+	 *	"logo": "string",
+	 *	"name": "string",
+	 *	"priority": 0,
+	 *	"team": "string",
+	 *	"url": "string"
+	 * }
+	 */
+	updateLink: (linkId, data) => {
+		return HttpHandler.Put(`/api/admin/links/${linkId}`, data, {})
+	},
+	/**
+	 * 删除友链
+	 * @params { Number } linkId 友链Id
+	 */
+	deleteLink: (linkId) => {
+		return HttpHandler.Delete(`/api/admin/links/${linkId}`)
+	},
+
+	// 获取友链分组
+	getLinkTeamList: (data) => {
+		return HttpHandler.Get('/api/admin/links/teams')
+	},
+}

+ 28 - 0
api/admin/logs.js

@@ -0,0 +1,28 @@
+/**
+ * 日志管理
+ * @see https://api.halo.run/admin-api.html#tag/link-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取日志列表列表
+	 * params:{ top: Number}
+	 */
+	getLogsLatestList: (params) => {
+		return HttpHandler.Get('/api/admin/logs/latest', params)
+	},
+	/**
+	 * 获取日志列表列表
+	 * params:{ page:Number,size:Number, sort:String }
+	 */
+	getLogsListByPage: (params) => {
+		return HttpHandler.Get('/api/admin/logs', params)
+	},
+	/**
+	 * 清空日志
+	 */
+	deleteAllLogs: () => {
+		return HttpHandler.Get(`/api/admin/logs/clear`)
+	},
+}

+ 101 - 0
api/admin/photos.js

@@ -0,0 +1,101 @@
+/**
+ * 图库管理
+ * @see https://api.halo.run/admin-api.html#tag/photo-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 查询图片列表
+	 * {
+	 *	"sort": ["",""], // 排序
+	 *	"more": "Boolean" ,// 更多参数(回调)
+	 * }
+	 */
+	getPhotos: (params) => {
+		return HttpHandler.Get('/api/admin/photos', params)
+	},
+
+	/**
+	 * 查询最近的图库列表(树形数据)
+	 * {
+	 *	"sort": ["",""], // 排序
+	 * }
+	 */
+	getLatestPhotos: (params) => {
+		return HttpHandler.Get('/api/admin/photos/latest', params)
+	},
+
+	/**
+	 * 查询所有的图片分组 
+	 */
+	getPhotosTeams: () => {
+		return HttpHandler.Get('/api/admin/photos/teams')
+	},
+
+	/**
+	 * 查询图片详情 
+	 * @param {Number} photoId id
+	 */
+	getPhotosDetail: (photoId) => {
+		return HttpHandler.Get(`/api/admin/photos/${photoId}`)
+	},
+
+	/**
+	 * 新增图片(单图)
+	 *{
+	 *  "description": "string",
+	 *  "id": 0,
+	 *  "location": "string",
+	 *  "name": "string",
+	 *  "takeTime": "2019-08-24T14:15:22Z",
+	 *  "team": "string",
+	 *  "thumbnail": "string",
+	 *  "url": "string"
+	 *}
+	 */
+	createPhotos: (data) => {
+		return HttpHandler.Post(`/api/admin/photos`, data)
+	},
+
+	/**
+	 * 新增图片(批量)
+	 *	{
+	 *	  "description": "string",
+	 *	  "id": 0,
+	 *	  "location": "string",
+	 *	  "name": "string",
+	 *	  "takeTime": "2019-08-24T14:15:22Z",
+	 *	  "team": "string",
+	 *	  "thumbnail": "string",
+	 *	  "url": "string"
+	 *	}
+	 */
+	createPhotosBatch: (data) => {
+		return HttpHandler.Post(`/api/admin/photos/batch`, data)
+	},
+
+	/**
+	 * 修改图片信息
+	 * @param {Number} photoId id
+	 * @param {Object} data 同新增  
+	 */
+	updatePhotosById: (photoId, data) => {
+		return HttpHandler.Put(`/api/admin/photos/${photoId}`, data)
+	},
+
+	/**
+	 * 删除单张图片
+	 * @param {Number} photoId id
+	 */
+	deletePhotosById: (photoId) => {
+		return HttpHandler.Delete(`/api/admin/photos/${photoId}`)
+	},
+	/**
+	 * 批量删除图片
+	 * @param {Number} photoIds id数组
+	 */
+	deletePhotosBatchById: (photoIds) => {
+		return HttpHandler.Delete(`/api/admin/photos/batch`, photoIds)
+	},
+}

+ 121 - 0
api/admin/posts.js

@@ -0,0 +1,121 @@
+/**
+ * 文章管理
+ * @see https://api.halo.run/admin-api.html#tag/post-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+/**
+ * 新建和编辑文章字段
+ */
+const createOrEditModel = {
+	"categoryIds": [
+		0
+	],
+	"content": "string",
+	"createTime": "2019-08-24T14:15:22Z",
+	"disallowComment": true,
+	"editorType": "MARKDOWN",
+	"keepRaw": true,
+	"metaDescription": "string",
+	"metaKeywords": "string",
+	"metas": [{
+		"key": "string",
+		"postId": 0,
+		"value": "string"
+	}],
+	"originalContent": "string",
+	"password": "string",
+	"slug": "string",
+	"status": "DRAFT",
+	"summary": "string",
+	"tagIds": [
+		0
+	],
+	"template": "string",
+	"thumbnail": "string",
+	"title": "string",
+	"topPriority": 0
+}
+
+
+export default {
+	/**
+	 * 查询文章列表
+	 *  @param {Object} params {
+	 * 		categoryId,keyword,page,size,sort,
+	 * 		status:"DRAFT" "INTIMATE" "PUBLISHED" "RECYCLE",statuses,more
+	 * }
+	 */
+	getPostsByPage: (params) => {
+		return HttpHandler.Get('/api/admin/posts', params)
+	},
+
+	/**
+	 * 查询最近的文章列表
+	 * @param {Object} params {top:Number}
+	 */
+	getLatestPosts: (params) => {
+		return HttpHandler.Get('/api/admin/posts/latest', params)
+	},
+	/**
+	 * 根据状态查询文章列表
+	 * @param {String} status:"DRAFT" "INTIMATE" "PUBLISHED" "RECYCLE"
+	 * @param {Object} params:{ page,size,sort,more }
+	 */
+	getPostsPageByStatus: (status, params) => {
+		return HttpHandler.Get(`/api/admin/posts/status/${status}`, params)
+	},
+
+	/**
+	 * 根据文章id获取文章
+	 * @param {Number} postsId 文章id 
+	 */
+	getPostsById: (postsId) => {
+		return HttpHandler.Get(`/api/admin/posts/${postsId}`)
+	},
+
+	/**
+	 * 新增文章 
+	 * @param {Object} data 同新增  
+	 * @param {Boolean} isAutoSave 是否来源于自动保存  
+	 */
+	createPosts: (data, isAutoSave = false) => {
+		return HttpHandler.Post(`/api/admin/posts?autoSave=${isAutoSave}`, data)
+	},
+
+	/**
+	 * 修改文章
+	 * @param {Number} postsId id
+	 * @param {Object} data 同新增  
+	 * @param {Boolean} isAutoSave 是否来源于自动保存  
+	 */
+	updatePostsById: (postsId, data, isAutoSave = false) => {
+		return HttpHandler.Put(`/api/admin/posts/${postsId}?autoSave=${isAutoSave}`, data)
+	},
+
+	/**
+	 * 修改文章(草稿)
+	 * @param {Number} postsId id
+	 * @param {Object} data { content,keepRaw,originalContent }  
+	 */
+	updatePostsDraftById: (postsId, data) => {
+		return HttpHandler.Put(`/api/admin/posts/${postsId}/status/draft/content`, data)
+	},
+
+	/**
+	 * 修改文章状态
+	 * @param {Number} postsId id
+	 * @param {String} status  "DRAFT" "INTIMATE" "PUBLISHED" "RECYCLE"  
+	 */
+	updatePostsDraftById: (postsId, status) => {
+		return HttpHandler.Put(`/api/admin/posts/${postsId}/status/${status}`)
+	},
+
+	/**
+	 * 删除文章(批量)
+	 * @param {Array} postsIds  ids
+	 */
+	deletePostsByIds: (postsIds) => {
+		return HttpHandler.Delete(`/api/admin/posts`, postsIds)
+	},
+}

+ 56 - 0
api/admin/tags.js

@@ -0,0 +1,56 @@
+/**
+ * 标签管理
+ * @see https://api.halo.run/admin-api.html#tag/tag-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 查询文章标签列表
+	 * {
+	 *	"sort": ["",""], // 排序
+	 *	"more": "Boolean" ,// 更多参数(回调)
+	 * }
+	 */
+	getTagsList: (params) => {
+		return HttpHandler.Get('/api/admin/tags', params)
+	},
+
+	/**
+	 * 查询文章标签详情 
+	 * @param {Number} tagId id
+	 */
+	getTagsDetail: (tagId) => {
+		return HttpHandler.Get(`/api/admin/tags/${tagId}`)
+	},
+
+	/**
+	 * 新增文章标签
+	 *  {
+	 *    "color": "#e23d66", // 颜色选择器
+	 *    "name": "string",
+	 *    "slug": "string",
+	 *    "thumbnail": "string"
+	 *  }
+	 */
+	createTags: (data) => {
+		return HttpHandler.Post(`/api/admin/tags`, data)
+	},
+
+	/**
+	 * 修改文章标签信息
+	 * @param {Number} tagId id
+	 * @param {Object} data 同新增  
+	 */
+	updateTagsById: (tagId, data) => {
+		return HttpHandler.Put(`/api/admin/tags/${tagId}`, data)
+	},
+
+	/**
+	 * 删除文章标签
+	 * @param {Number} tagId id
+	 */
+	deleteTagsById: (tagId) => {
+		return HttpHandler.Delete(`/api/admin/tags/${tagId}`)
+	},
+}

+ 83 - 0
api/admin/user.js

@@ -0,0 +1,83 @@
+/**
+ * 登录管理
+ * @see https://api.halo.run/admin-api.html#tag/admin-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	// 登录前检查
+	loginPreCheck: (data) => {
+		return HttpHandler.Post('/api/admin/login/precheck', data, {})
+	},
+	// 登录
+	login: (data) => {
+		return HttpHandler.Post('/api/admin/login', data, {})
+	},
+	// 刷新token
+	refreshToken: (refreshToken) => {
+		return HttpHandler.Post($`/api/admin/refresh/${refreshToken}`, {}, {})
+	},
+
+	// 退出登录
+	logout: () => {
+		return HttpHandler.Post('/api/admin/logout')
+	},
+
+	/**
+	 * 获取修改密码的验证码
+	 * {
+	 *	"email": "string",
+	 *	"username": "string"
+	 * }
+	 */
+	getResetPasswordCode: () => {
+		return HttpHandler.Post('/api/admin/password/code')
+	},
+
+	/**
+	 * 重置密码
+	 * {
+	 *	"code": "string",
+	 *   "email": "string",
+	 *   "password": "stringst",
+	 *   "username": "string"
+	 * }
+	 */
+	resetPasswordByCode: (data) => {
+		return HttpHandler.Put('/api/admin/password/reset', data)
+	},
+
+	/**
+	 * 获取个人信息(当前登录的管理员)
+	 */
+	getAdminProfile: () => {
+		return HttpHandler.Get('/api/admin/users/profiles')
+	},
+
+	/**
+	 * 修改个人信息(当前登录的管理员)
+	 *	 {
+	 *	   "avatar": "string",
+	 *	   "description": "string",
+	 *	   "email": "string",
+	 *	   "nickname": "string",
+	 *	   "password": "stringst",
+	 *	   "username": "string"
+	 *	 }
+	 */
+	updateAdminProfile: (data) => {
+		return HttpHandler.Put('/api/admin/users/profiles', data)
+	},
+	/**
+	 * 修改密码
+	 * {
+	 *	 "confirmPassword": "string",
+	 *   "newPassword": "string",
+	 *   "oldPassword": "strings" 
+	 * }
+	 */
+	modifyAdminPassword: (data) => {
+		return HttpHandler.Put('/api/admin/users/profiles/password', data)
+	},
+
+}

+ 22 - 0
api/archive.js

@@ -0,0 +1,22 @@
+/**
+ * 归档接口
+ * @see https://api.halo.run/content-api.html#tag/archive-controlle
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取归档列表(按月)
+	 */
+	getMonthArchives: () => {
+		return HttpHandler.Get(`/api/content/archives/months`)
+	},
+
+	/**
+	 * 获取归档列表(按年)
+	 */
+	getYearArchives: () => {
+		return HttpHandler.Get(`/api/content/archives/years`)
+	},
+}

+ 25 - 0
api/article.js

@@ -0,0 +1,25 @@
+/**
+ *   文章接口
+ */
+
+import HttpHandler from '@/common/http/request.js'
+export default {
+	/**
+	 * 获取文章列表
+	 * @param {Object} params  查询参数
+	 */
+	getArticleList: (params) => {
+		return HttpHandler.Get('/api/content/posts', params)
+	},
+
+	/**
+	 * 获取文章详情
+	 * @param {String} articleId  文章id
+	 */
+	getArticleDetail: (articleId) => {
+		return HttpHandler.Get(`/api/content/posts/${articleId}`, {
+			formatDisabled: false,
+			sourceDisabled: true
+		})
+	},
+}

+ 15 - 0
api/blogger.js

@@ -0,0 +1,15 @@
+/**
+ * 博主信息
+ * @see https://api.halo.run/content-api.html#tag/user-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取博主信息
+	 */
+	getBloggerInfo: () => {
+		return HttpHandler.Get(`/api/content/users/profile`)
+	},
+}

+ 24 - 0
api/category.js

@@ -0,0 +1,24 @@
+/**
+ * 分类接口
+ * @see https://api.halo.run/content-api.html#tag/category-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+export default {
+	/**
+	 * 查询分类列表
+	 * @param {Object} params  查询参数 
+	 */
+	getCategoryList: (params) => {
+		return HttpHandler.Get('/api/content/categories', params)
+	},
+
+	/**
+	 * 查询分类下的文章
+	 * @param {String} slug  分类名称
+	 * @param {Object} params  查询参数 
+	 */
+	getCategoryPostList: (slug, params) => {
+		return HttpHandler.Get(`/api/content/categories/${slug}/posts`, params)
+	},
+}

+ 45 - 0
api/comment.js

@@ -0,0 +1,45 @@
+/**
+ * 评论接口
+ *  @see https://api.halo.run/content-api.html#tag/post-controller
+ */
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取评论列表接口(树形数据)
+	 * @param {String} postId  文章id 
+	 * @param {Object} params  查询参数
+	 */
+	getPostCommentTree: (postId, params) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}/comments/tree_view`, params)
+	},
+
+	/**
+	 * 获取评论列表接口(列表数据)
+	 * @param {String} postId  文章id 
+	 * @param {Object} params  查询参数
+	 */
+	getPostCommentList: (postId, params) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}/comments/list_view`, params)
+	},
+
+	/**
+	 * 获取置顶评论
+	 * @param {String} postId  文章id 
+	 * @param {Object} params  查询参数
+	 */
+	getPostTopCommentList: (postId, params) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}/comments/top_view`, params)
+	},
+
+
+	/**
+	 * 获取评论的子评论列表
+	 * @param {String} postId  文章id 
+	 * @param {String} commentParentId  要获取的评论id
+	 * @param {Object} params  查询参数
+	 */
+	getPostChildrenCommentList: (postId, commentParentId, params) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}/comments/${commentParentId}/children`, params)
+	},
+}

+ 77 - 0
api/index.js

@@ -0,0 +1,77 @@
+/**
+ *  功能:全局API管理
+ *  作者:小莫唐尼
+ *  邮箱:studio@925i.cn
+ *  时间:2022年07月21日 19:14:44
+ *  版本:v0.1.0
+ *  修改记录:
+ *  修改内容:
+ *  修改人员:
+ *  修改时间:
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+import archive from './archive.js'
+import article from './article.js'
+import blogger from './blogger.js'
+import category from './category.js'
+import comment from './comment.js'
+import journal from './journal.js'
+import link from './link.js'
+import menu from './menu.js'
+import option from './option.js'
+import photo from './photo.js'
+import post from './post.js'
+import sheet from './sheet.js'
+import statistics from './statistics.js'
+import theme from './theme.js'
+
+// 管理端
+import admin_login from './admin/user.js'
+import admin_links from './admin/links.js'
+import admin_attachment from './admin/attachment.js'
+import admin_category from './admin/category.js'
+import admin_journal from './admin/journal.js'
+import admin_photos from './admin/photos.js'
+import admin_tags from './admin/tags.js'
+import admin_comments from './admin/comments.js'
+import admin_posts from './admin/posts.js'
+import admin_logs from './admin/logs.js'
+
+const ApiManager = {
+	...archive,
+	...article,
+	...blogger,
+	...category,
+	...comment,
+	...journal,
+	...link,
+	...option,
+	...photo,
+	...post,
+	...sheet,
+	...statistics,
+	...theme,
+	// 管理端的api
+	admin: {
+		...admin_login,
+		...admin_links,
+		...admin_attachment,
+		...admin_category,
+		...admin_journal,
+		...admin_photos,
+		...admin_tags,
+		...admin_comments,
+		...admin_posts,
+		...admin_logs
+	}
+};
+
+const install = (Vue) => {
+	Vue.prototype.$httpApi = ApiManager
+}
+
+export default {
+	install
+}

+ 74 - 0
api/journal.js

@@ -0,0 +1,74 @@
+/**
+ * 日记接口
+ * @see https://api.halo.run/content-api.html#tag/journal-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取日记列表
+	 * @param {String} journalId 日记id  
+	 */
+	getJournals: () => {
+		return HttpHandler.Get(`/api/content/journals`)
+	},
+
+	/**
+	 * 获取日记详情
+	 * @param {String} journalId 日记id  
+	 */
+	getJournalDetail: (journalId) => {
+		return HttpHandler.Get(`/api/content/journals/${journalId}`)
+	},
+
+	/**
+	 * 获取日记置顶评论列表
+	 * @param {String} journalId 日记id  
+	 */
+	getJournalTopComments: (journalId) => {
+		return HttpHandler.Get(`/api/content/journals/${journalId}/comments/top_view`)
+	},
+
+	/**
+	 * 获取日记评论列表(列表形式)
+	 * @param {String} journalId 日记id  
+	 */
+	getJournalCommentList: (journalId) => {
+		return HttpHandler.Get(`/api/content/journals/${journalId}/comments/list_view`)
+	},
+
+	/**
+	 * 获取日记评论列表(树形式)
+	 * @param {String} journalId 日记id  
+	 */
+	getJournalCommentTree: (journalId) => {
+		return HttpHandler.Get(`/api/content/journals/${journalId}/comments/tree_view`)
+	},
+
+	/**
+	 * 获取日记评论列表(树形式)
+	 * @param {String} journalId 日记id  
+	 * @param {String} commentParentId 评论id  
+	 */
+	getJournalCommentChildren: (journalId, commentParentId) => {
+		return HttpHandler.Get(
+			`/api/content/journals/${journalId}/comments/${commentParentId}/children`)
+	},
+
+	/**
+	 * 发表日记评论
+	 * @param {Object} data 评论数据  
+	 */
+	postJournalComments: (data) => {
+		return HttpHandler.Post(`/api/content/journals/comments`, data)
+	},
+
+	/**
+	 * 给日记点赞
+	 * @param {String} journalId 日记id  
+	 */
+	postJournalLikes: (journalId) => {
+		return HttpHandler.Post(`/api/content/journals/${journalId}/likes`)
+	},
+}

+ 24 - 0
api/link.js

@@ -0,0 +1,24 @@
+/**
+ * 友链接口
+ * @see https://api.halo.run/content-api.html#tag/link-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取友链列表
+	 * @param {Object} params 参数
+	 */
+	getLinkList: (params) => {
+		return HttpHandler.Get(`/api/content/links`, params)
+	},
+
+	/**
+	 * 获取分组的友链列表
+	 * @param {Object} params 参数
+	 */
+	getLinkListByTeam: (params) => {
+		return HttpHandler.Get(`/api/content/links/team_view`, params)
+	},
+}

+ 112 - 0
api/login.js

@@ -0,0 +1,112 @@
+/**
+ * 普通用户登录
+ */
+
+// 获取用户信息
+export function getUserInfo() {
+	return new Promise((resolve, reject) => {
+		uni.getUserProfile({
+			lang: 'zh_CN',
+			desc: '用户登录', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,
+			success: (res) => {
+				console.log(res, 'resss')
+				resolve(res.userInfo)
+			},
+			fail: (err) => {
+				reject(err)
+			}
+		})
+	})
+}
+
+export function getLogin() {
+	return new Promise((resolve, reject) => {
+		uni.login({
+			success(res) {
+				console.log('----------getLogin ---------')
+				console.log(res)
+				resolve(res)
+			},
+			fail: (err) => {
+				console.log(err, 'logoer')
+				reject(err)
+			}
+		})
+	})
+}
+
+export function wxLogin() {
+	uni.getProvider({
+		service: 'oauth',
+		success: function(res) {
+			//支持微信、qq和微博等
+			if (~res.provider.indexOf('weixin')) {
+				console.log(res, 'ress')
+				let _userInfo = getUserInfo();
+				let _loginRes = getLogin();
+				Promise.all([_userInfo, _loginRes]).then((res) => {
+					let userInfo = res[0];
+					let loginRes = res[1];
+					if (loginRes.errMsg == 'login:ok') {
+						uni.$tm.vx.commit('user/setWxLoginInfo', {
+							avatarUrl: userInfo.avatarUrl,
+							nickName: userInfo.nickName,
+							email: '',
+							url: ''
+						});
+						uni.showToast({
+							icon: 'none',
+							title: '登录成功!'
+						})
+					} else {
+						uni.showToast({
+							icon: 'none',
+							title: '登录失败,请重试!'
+						})
+					}
+				}).catch(err => {
+					uni.showToast({
+						icon: 'none',
+						title: '登录失败,请重试!'
+					})
+				})
+			}
+		},
+		fail: function(err) {
+			uni.showToast({
+				icon: 'none',
+				title: '登录失败,请重试!'
+			})
+		}
+	})
+}
+
+export function appWxLogin() {
+	uni.login({
+		provider: 'weixin',
+		success: function(loginRes) {
+			// 获取用户信息
+			uni.getUserInfo({
+				provider: 'weixin',
+				success: function(infoRes) {
+					uni.$tm.vx.commit('user/setWxLoginInfo', {
+						avatarUrl: infoRes.userInfo.avatarUrl,
+						nickName: infoRes.userInfo.nickName,
+						email: '',
+						url: ''
+					});
+					uni.showToast({
+						icon: 'none',
+						title: '登录成功!'
+					})
+				},
+				fail: function(err) {
+					uni.showToast({
+						icon: 'none',
+						title: '登录失败,请重试!'
+					})
+				}
+			});
+		}
+	});
+}

+ 24 - 0
api/menu.js

@@ -0,0 +1,24 @@
+/**
+ * 菜单接口
+ * @see https://api.halo.run/content-api.html#tag/menu-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取菜单列表(列表)
+	 * @param {Object} params 参数
+	 */
+	getMenuList: (params) => {
+		return HttpHandler.Get(`/api/content/menus`, params)
+	},
+
+	/**
+	 * 获取菜单列表(树形)
+	 * @param {Object} params 参数
+	 */
+	getMenuTree: (params) => {
+		return HttpHandler.Get(`/api/content/menus/tree_view`, params)
+	},
+}

+ 30 - 0
api/option.js

@@ -0,0 +1,30 @@
+/**
+ * 配置接口
+ * @see https://api.halo.run/content-api.html#tag/option-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 根据key获取配置
+	 * @param {String} key 配置的key
+	 */
+	getOptionByKey: (key) => {
+		return HttpHandler.Get(`/api/content/options/keys/${key}`)
+	},
+
+	/**
+	 * 获取配置列表(列表)
+	 */
+	getOptionList: () => {
+		return HttpHandler.Get(`/api/content/options/list_view`)
+	},
+
+	/**
+	 * 获取配置列表(键值对)
+	 */
+	getOptionMap: () => {
+		return HttpHandler.Get(`/api/content/options/map_view`)
+	},
+}

+ 31 - 0
api/photo.js

@@ -0,0 +1,31 @@
+/**
+ * 图库接口
+ * @see https://api.halo.run/content-api.html#tag/photo-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取图库列表(分页查询)
+	 * @param {Object} params 参数
+	 */
+	getPhotoListByPage: (params) => {
+		return HttpHandler.Get(`/api/content/photos`, params)
+	},
+
+	/**
+	 * 获取图库列表(最新)
+	 * @param {Object} params 参数
+	 */
+	getPhotoList: (params) => {
+		return HttpHandler.Get(`/api/content/photos/latest`, params)
+	},
+
+	/**
+	 * 获取图库分组
+	 */
+	getPhotoTeams: () => {
+		return HttpHandler.Get(`/api/content/photos/teams`)
+	},
+}

+ 82 - 0
api/post.js

@@ -0,0 +1,82 @@
+/**
+ * 文章接口
+ * @see https://api.halo.run/content-api.html#tag/post-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取文章列表
+	 * @param {Object} params 参数 
+	 */
+	getPostList: (params) => {
+		return HttpHandler.Get(`/api/content/posts`, params)
+	},
+
+	/**
+	 * 评论文章
+	 * @param {Object} data 数据 
+	 * {
+	 *		  "allowNotification": true,
+	 *		  "author": "string",
+	 *		  "authorUrl": "string",
+	 *		  "content": "string",
+	 *		  "email": "string",
+	 *		  "parentId": 0,
+	 *		  "postId": 0
+	 * }
+	 */
+	postCommentPost: (data) => {
+		return HttpHandler.Post(`/api/content/posts/comments`, data)
+	},
+
+	/**
+	 * 搜索文章
+	 * @param {Object} data 数据 
+	 */
+	getPostListByKeyword: (data) => {
+		return HttpHandler.Post(`/api/content/posts/search`, data)
+	},
+
+	/**
+	 * 根据分类获取文章
+	 * @param {Object} params 参数 
+	 */
+	getPostDetailBySlug: (params) => {
+		return HttpHandler.Get(`/api/content/posts/slug`, params)
+	},
+
+	/**
+	 * 根据文章id获取文章
+	 * @param {Object} params 参数 
+	 */
+	getPostDetailByPostId: (postId, params) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}`, params)
+	},
+
+	/**
+	 * 给文章点赞
+	 * @param {Object} postId 文章id 
+	 */
+	postLikePost: (postId) => {
+		return HttpHandler.Post(`/api/content/posts/${postId}/likes`)
+	},
+
+	/**
+	 * 根据当前文章id获取前一篇文章
+	 * @param {Object} postId 文章id 
+	 */
+	getPrevByCurrentPostId: (postId) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}/prev`)
+	},
+
+	/**
+	 * 根据当前文章id获取下一篇文章
+	 * @param {Object} postId 文章id 
+	 */
+	getNextByCurrentPostId: (postId) => {
+		return HttpHandler.Get(`/api/content/posts/${postId}/next`)
+	},
+
+}

+ 67 - 0
api/sheet.js

@@ -0,0 +1,67 @@
+/**
+ * 自定义页面模板
+ * @see https://api.halo.run/content-api.html#tag/sheet-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取页面列表
+	 * {
+	 *	 page:
+	 *	 size:
+	 *	 sort:
+	 * }
+	 */
+	getSheetsList: (params) => {
+		return HttpHandler.Get(`/api/content/sheets`, params)
+	},
+
+	/**
+	 * 根据分类获取页面数据
+	 */
+	getSheetsListBySlug: (params) => {
+		return HttpHandler.Get(`/api/content/sheets/slug`, params)
+	},
+
+	/**
+	 * 获取页面评论(列表)
+	 */
+	getSheetsCommentsListBySheetId: (sheetId, params) => {
+		return HttpHandler.Get(`/api/content/sheets/${sheetId}/comments/list_view`, params)
+	},
+	/**
+	 * 获取页面评论(树形)
+	 */
+	getSheetsCommentsTreeBySheetId: (sheetId, params) => {
+		return HttpHandler.Get(`/api/content/sheets/${sheetId}/comments/tree_view`, params)
+	},
+
+
+	/**
+	 * 获取评论的子评论列表
+	 * @param {String} sheetId  页面id 
+	 * @param {String} commentParentId  要获取的评论id
+	 * @param {Object} params  查询参数
+	 */
+	getSheetsChildrenCommentList: (sheetId, commentParentId, params) => {
+		return HttpHandler.Get(`/api/content/sheets/${sheetId}/comments/${commentParentId}/children`, params)
+	},
+
+	/**
+	 * 给页面添加一个评论 
+	 * {
+	 *	  "allowNotification": true,
+	 *	  "author": "string",
+	 *	  "authorUrl": "string",
+	 *	  "content": "string",
+	 *	  "email": "string",
+	 *	  "parentId": 0,
+	 *	  "postId": 0
+	 *	}
+	 */
+	postSheetsComments: (data) => {
+		return HttpHandler.Post(`/api/content/sheets/comments`, data)
+	},
+}

+ 22 - 0
api/statistics.js

@@ -0,0 +1,22 @@
+/**
+ * 博客统计信息
+ * @see https://api.halo.run/content-api.html#tag/statistic-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取博客统计信息
+	 */
+	getBlogStatistics: () => {
+		return HttpHandler.Get(`/api/content/statistics`)
+	},
+
+	/**
+	 * 获取博客统计信息和用户信息
+	 */
+	getBlogStatisticsWithUser: () => {
+		return HttpHandler.Get(`/api/content/statistics/user`)
+	},
+}

+ 40 - 0
api/theme.js

@@ -0,0 +1,40 @@
+/**
+ * 主题设置
+ * @see https://api.halo.run/content-api.html#tag/theme-controller
+ */
+
+import HttpHandler from '@/common/http/request.js'
+
+export default {
+	/**
+	 * 获取激活主题的信息
+	 * @param {Object} params 参数
+	 */
+	geActivationThemeList: (params) => {
+		return HttpHandler.Get(`/api/content/themes/activation`, params)
+	},
+
+	/**
+	 * 获取激活的主题设置
+	 * @param {Object} params 参数
+	 */
+	getActivationThemeSettings: (params) => {
+		return HttpHandler.Get(`/api/content/themes/activation/settings`, params)
+	},
+
+	/**
+	 * 根据主题ID列出主题设置
+	 * @param {Object} params 参数
+	 */
+	getThemeSettingsByThemeId: (themeId) => {
+		return HttpHandler.Get(`/api/content/themes/${themeId}/settings`)
+	},
+
+	/**
+	 * 通过主题ID获取主题属性
+	 * @param {Object} params 参数
+	 */
+	getThemePropertyByThemeId: (themeId) => {
+		return HttpHandler.Get(`/api/content/themes/${themeId}`)
+	},
+}

+ 1 - 0
api/url.js

@@ -0,0 +1 @@
+

+ 74 - 0
common/filters/index.js

@@ -0,0 +1,74 @@
+/**
+ *  功能:全局过滤器
+ *  作者:小莫唐尼
+ *  邮箱:studio@925i.cn
+ *  时间:2022年07月21日 17:39:04
+ *  版本:v0.1.0
+ *  修改记录:
+ *  修改内容:
+ *  修改人员:
+ *  修改时间:
+ */
+
+export default {
+	/**
+	 * 功能描述:时间格式化,将指定的时间戳(或正常的日期)转换为带格式的日期
+	 * 
+	 * 参数说明:
+	 * 		1.支持格式化 yyyy年MM月dd日 HH点mm分ss秒 星期w q季
+	 * 		2.对象形式传入 { d:'2021-06-04',f:'yyyy年' }  d是必传项,f可不传(默认yyyy-MM-dd HH:mm:ss)
+	 * 使用示例:
+	 * 		1:<view>{{ dateTimeParamName | formatTime }}</view>
+	 * 		2:<view>{{ { d: '2021-06-04', f: 'yyyy' } | formatTime }}</view>
+	 * 		3:<view>{{ { d: dateTimeParamName, f: 'yyyy年MM月dd日 HH点mm分ss秒 星期w q季' } | formatTime }}</view>
+	 * 特别说明: 由于uniapp中的filter 不支持多参数,但是允许传入对象的形式,故以此方式实现!
+	 */
+	formatTime: function(data) {
+		let _dateTime = new Date(data);
+		let _fmt = 'yyyy-MM-dd HH:mm:ss';
+		if (_dateTime == 'Invalid Date') {
+			if (data.d == undefined || data.d == null || data.d == "") {
+				console.error('日期参数不正确,传入的参数列表:', data);
+				return ''
+			};
+			_dateTime = new Date(data.d);
+			if (_dateTime == 'Invalid Date') {
+				console.error('日期参数不正确,传入的参数列表:', data);
+				return '111'
+			}
+			if (data.hasOwnProperty('f')) {
+				_fmt = data.f
+			}
+		}
+		const _weekDays = ["日", "一", "二", "三", "四", "五", "六"];
+		const _seasons = ["冬", "春", "夏", "秋"];
+		const o = {
+			"M+": _dateTime.getMonth() + 1, //月份
+			"d+": _dateTime.getDate(), //日
+			"H+": _dateTime.getHours(), //小时
+			"m+": _dateTime.getMinutes(), //分
+			"s+": _dateTime.getSeconds(), //秒
+			"w+": _weekDays[_dateTime.getDay()], // 星期几
+			"q+": _seasons[Math.floor((_dateTime.getMonth() + 3) / 3)], //季度
+			S: _dateTime.getMilliseconds(), //毫秒
+		};
+		if (/(y+)/.test(_fmt)) {
+			_fmt = _fmt.replace(
+				RegExp.$1,
+				(_dateTime.getFullYear() + "").substr(4 - RegExp.$1.length)
+			);
+		}
+		for (let k in o) {
+			if (new RegExp("(" + k + ")").test(_fmt)) {
+				_fmt = _fmt.replace(
+					RegExp.$1,
+					RegExp.$1.length == 1 ?
+					o[k] :
+					("00" + o[k]).substr(("" + o[k]).length)
+				);
+			}
+		}
+		return _fmt;
+	},
+
+};

+ 38 - 0
common/http/index.js

@@ -0,0 +1,38 @@
+/**
+ *  功能:请求工具 
+ *  作者:小莫唐尼
+ *  邮箱:studio@925i.cn
+ *  时间:2022年07月21日 18:58:03
+ *  版本:v0.1.0
+ *  修改记录:
+ *  修改内容:
+ *  修改人员:
+ *  修改时间:
+ */
+
+import HaloConfig from '@/config/halo.config.js'
+import {
+	setInterceptors
+} from "./interceptors.js";
+import Request from "@/js_sdk/luch-request/luch-request";
+
+const http = new Request()
+/* 设置全局配置 */
+http.setConfig((config) => {
+
+	// 如果是在外部浏览器调试或者编译为h5,请注释该行代码
+	config.baseURL = HaloConfig.apiUrl;
+
+	config.header = {
+		...config.header,
+		'api-authorization': HaloConfig.apiAuthorization,
+		ContentType: 'application/json',
+		dataType: 'json'
+	}
+	return config
+})
+setInterceptors(http)
+
+export {
+	http
+}

+ 84 - 0
common/http/interceptors.js

@@ -0,0 +1,84 @@
+/**
+ *  功能:http拦截
+ *  作者:小莫唐尼
+ *  邮箱:studio@925i.cn
+ *  时间:2022年07月21日 19:02:14
+ *  版本:v0.1.0
+ *  修改记录:
+ *  修改内容:
+ *  修改人员:
+ *  修改时间:
+ */
+import {
+	getAdminAccessToken
+} from "@/utils/auth.js";
+import {
+	delCache
+} from "@/utils/storage";
+export const setInterceptors = (http) => {
+	http.interceptors.request.use(
+		(config) => {
+			// 可使用async await 做异步操作
+			config.header = {
+				...config.header
+				// ... 可以直接加参数
+			};
+			if (getAdminAccessToken()) {
+				config.header['admin-authorization'] = getAdminAccessToken()
+			}
+			return config;
+		},
+		(config) => {
+			// 可使用async await 做异步操作
+			return Promise.reject(config);
+		}
+	);
+
+	http.interceptors.response.use(
+		(response) => {
+			/* 对响应成功做点什么 可使用async await 做异步操作*/
+			//  if (response.data.code !== 200) { // 服务端返回的状态码不等于200,则reject()
+			//    return Promise.reject(response) // return Promise.reject 可使promise状态进入catch
+			// if (response.config.custom.verification) { // 演示自定义参数的作用
+			//   return response.data
+			// } 
+
+			if (response.statusCode == 200) {
+				return response.data;
+			} else {
+				return Promise.reject(response);
+			}
+		},
+		(response) => {
+			/*  对响应错误做点什么 (statusCode !== 200)*/
+			if (!response.data) {
+				return Promise.reject({
+					status: 500,
+					message: 'API接口服务异常!'
+				})
+			} else if (response.data.status == 401) {
+				delCache('APP_ADMIN_LOGIN_TOKEN');
+				uni.$eShowModal({
+					title: '提示',
+					content: '您未登录超管账号或登录已过期,是否重新登录?',
+					showCancel: true,
+					cancelText: '否',
+					cancelColor: '#999999',
+					confirmText: '是',
+					confirmColor: '#03a9f4'
+				}).then(res => {
+					uni.navigateTo({
+						url: '/pagesB/login/login'
+					})
+				}).catch(err => {
+					uni.switchTab({
+						url: '/pages/tabbar/about/about'
+					})
+				})
+			} else {
+				return Promise.reject(response.data);
+			}
+
+		}
+	);
+};

+ 29 - 0
common/http/request.js

@@ -0,0 +1,29 @@
+/**
+ * 封装各种请求方式
+ */
+
+
+import {
+	http
+} from '@/common/http/index.js'
+
+export default {
+	Get: (url, params, config = {}) => {
+		return http.get(url, {
+			params,
+			...config
+		})
+	},
+	Post: (url, data, config = {}) => {
+		return http.post(url, data, config)
+	},
+	Put: (url, data, config = {}) => {
+		return http.put(url, data, config)
+	},
+	Upload: (url, config = {}) => {
+		return http.upload(url, config)
+	},
+	Delete: (url, data, config = {}) => {
+		return http.delete(url, data, config)
+	}
+}

文件差異過大導致無法顯示
+ 5 - 0
common/icons/halocoloriconfont.css


文件差異過大導致無法顯示
+ 4 - 0
common/icons/haloiconfont.css


文件差異過大導致無法顯示
+ 2 - 0
common/icons/mphtmliconfont.css


+ 23 - 0
common/locales/en.js

@@ -0,0 +1,23 @@
+/**
+ *
+ *  可以以页面为单位来写,
+ *  比如首页的内容,写在index字段,个人中心写在center,共同部分写在common部分
+ * */
+
+export default {
+  app: {
+    name: "alley studio",
+    author: "Evan Mo",
+  },
+  tabbar: {
+    home: "Home",
+    moments: "Moments",
+    publish: "Publish",
+    mall: "Mall",
+    mine: "Mine",
+  },
+  // 提示文本
+  tips: {
+    switchLang: "switch Lang",
+  },
+};

+ 23 - 0
common/locales/zh.js

@@ -0,0 +1,23 @@
+/**
+ *
+ *  可以以页面为单位来写,
+ *  比如首页的内容,写在index字段,个人中心写在center,共同部分写在common部分
+ * */
+
+export default {
+  app: {
+    name: "巷子工坊",
+    author: "小莫唐尼",
+  },
+  tabbar: {
+    home: "首页",
+    moments: "动态",
+    publish: "发布",
+    mall: "商店",
+    mine: "我的",
+  },
+  // 提示文本
+  tips: {
+    switchLang: "切换语言",
+  },
+};

+ 97 - 0
common/markdown/markdown.config.js

@@ -0,0 +1,97 @@
+/**
+ * markdown配置
+ */
+
+import HaloConfig from '@/config/halo.config.js'
+export default {
+	domain: HaloConfig.apiUrl,
+	tagStyle: {
+		table: ` 
+			table-layout: fixed;
+			border-collapse:collapse;
+			margin-bottom: 18px;
+			overflow: hidden;
+			font-size: 13px;
+			color: var(--routine);
+			background: #f2f6fc;
+			border: 1px solid #dcdcdc;
+			border-radius: 4px;
+		`,
+		th: `
+			padding: 8px;
+			border-right: 1px solid var(--classE);
+			border-bottom: 1px solid var(--classE);
+		`,
+		td: `
+			padding: 8px;
+			border-right: 1px solid var(--classE);
+			border-bottom: 1px solid var(--classE);
+		`,
+		blockquote: `
+			padding: 8px 15px;
+		    color: #606266;
+		    background: #f2f6fc;
+		    border-left: 5px solid #50bfff;
+		    border-radius: 4px;
+			line-height: 26px;
+			margin-bottom: 18px;
+		`,
+		ul: 'padding-left: 15px;line-height: 1.85;',
+		ol: 'padding-left: 15px;line-height: 1.85;',
+		li: 'margin-bottom: 12px;line-height: 1.85;',
+		h1: `
+			margin: 30px 0 20px;
+			color: var(--main);
+			line-height: 24px;
+			position: relative;
+			font-size:1.25em;
+			`,
+		h2: `
+			color: var(--main);
+			line-height: 24px;
+			position: relative;
+			margin: 22px 0 16px;
+			font-size: 1.2em;
+		`,
+		h3: `
+		    color: var(--main);
+		    line-height: 24px;
+		    position: relative;
+			margin: 26px 0 18px;
+			font-size: 1.3em;
+		`,
+		h4: `
+			color: var(--main);
+		    line-height: 24px;
+		    margin-bottom: 18px;
+		    position: relative;
+			font-size: 1.18em;
+		`,
+		h5: `
+			color: var(--main);
+		    line-height: 24px;
+		    margin-bottom: 14px;
+		    position: relative;
+			font-size: 1em;
+		`,
+		h6: `
+			color: #303133;
+			line-height: 24px;
+			margin-bottom: 14px;
+			position: relative;
+			font-size: 1em;
+		`,
+		p: `
+		    line-height: 1.65;
+		    margin-bottom: 14px;
+			font-size: 15px;
+		`,
+		'code': `  `,
+		strong: 'font-weight: 700;color: rgb(248, 57, 41);',
+		video: 'width: 100%',
+	},
+	containStyle: 'font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, "PingFang SC", Cambria, Cochin, Georgia, Times, "Times New Roman", serif;padding:12px;font-size: 16px;color: #606266;word-spacing: 0.8px;letter-spacing: 0.8px;border-radius: 6px;background-color:#FFFFFF;',
+
+	loadingGif: HaloConfig.loadingGifUrl,
+	emptyGif: HaloConfig.loadingEmptyUrl,
+}

+ 155 - 0
common/markdown/markdown.scss

@@ -0,0 +1,155 @@
+:root {
+	--main: #303133;
+	--theme: #fb6c28;
+	--code-background: #e8f3ff;
+	--radius-inner: 4px;
+	--classA: #dcdfe6;
+	--classB: #e4e7ed;
+	--classC: #ebeef5;
+	--classD: #f2f6fc;
+	--classE: #dcdcdc;
+	--classF: #333;
+	--classG: #dcdcdc;
+	--classH: #e9f2ff;
+	--classI: #5a3713;
+	--classJ: #f9e5fb;
+	--classK: #e4e7ed;
+	--classL: #666;
+	--classM: #2d2e37;
+	--quote: #50bfff;
+	--code: #409eff;
+}
+.evan-markdown {
+	::v-deep {
+		h1::before,
+		h2::before,
+		h3::before,
+		h4::before,
+		h5::before,
+		h6::before {
+			position: relative;
+			display: inline-block;
+			vertical-align: middle;
+			content: '';
+			margin-right: 6px;
+			background-position: center;
+		}
+		h1::before {
+			position: relative;
+			display: inline-block;
+			vertical-align: middle;
+			content: '¶';
+			top: -4px;
+			margin-right: 12px;
+			font-size: 24px;
+			color: var(--theme);
+		}
+		h2::before {
+			top: -2px;
+			left: 0;
+			width: 20px;
+			height: 20px;
+			background-size: auto 100%;
+			background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAVJJREFUWEftl7FKw1AUhv9rbmf1PRw6utkHEOoi6WPoE1ifQJ8gySKCOtRFRBKsDuIiDdZAURBFF50M2sFIzpGogVK03JA2cbh3S/i55zvfvYETgRyLj5erIKOO949FyKm5eHvvHEBV2jyruq1QDQ7n2DNXALEx+D7e2vl6lBYp76scHCzErtmCEPVhqEIAfus8BZk4wM+Zd/46tskDeI0mgLUyAdoAFsoDcM0XCDFdHoDX4FGfbRF3QANoA9qANqANaAPawD82EEWId1sAOJQWz6iO+5nGch41kDw9I3bbYOaTis21wgG4G4AuAzCwXrEoGV6V1ngMJPoPjoB+PzSIa8KBr1QdwFgA6KID7t1AgFYNC5uqxZNcbgB+eASdngHM+9LmpSzF8wFEEagbgHvXoQA3s3aegmYz8P1f4NNVME+39z5e3w4lkSMc3GXtPM1/AjYDFjDGddN5AAAAAElFTkSuQmCC);
+		}
+		h3::before {
+			top: -3px;
+			left: 0;
+			width: 20px;
+			height: 20px;
+			background-size: auto 100%;
+			background-repeat: none;
+			background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAH1JREFUWEft1qENgDAQheH/JANg60DAJIgmrMFCzEEYgTFgDyRFIClp0yBf9evl8pl3RsYbp7BjNLHoMptljPiMZH3WAhKQgAQkIIGXQAXUT79cniHaNMa5draliqqojIID86nRHEtvbSqlBSQgAQlIQAISkECRAA746SC5Ad6XpiGnnOGPAAAAAElFTkSuQmCC);
+		}
+		h4::before {
+			top: -2px;
+			width: 22px;
+			height: 22px;
+			color: var(--theme);
+			background-size: auto 100%;
+			background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAndJREFUWEftVkty2kAQfT1SBLtwg6AqwzbxCQIniG8QvAmwCp8kRqvgFa5KwHjlkI3NCeIbhJwgXptUgW9AdiDQdGoITqUsjcZyuYqNZ6WSXs88vel+3YQtL9ry+Xgk8KhApALNAe8x5PPbCUrgabdsD2/e+1edEpN4FsIRps7OwT9cXKKHCLw75QIL+V0XRFIUP1dptBx3ChKkxQlw8UnOG5mqLESgdsYZy5dTAE8jg5lH3Ypd5Mlxxl8ttDgGRulcq5iYgApofgn6ILzVBQsSu5/e0OXiqnMOotdatVjsOvkPl4muQIFrp5y1hJzEBA67ZavEk07WX5EexzxM5b1SYgJrFQbBOQDt3wVSuP0qTRfjowsAr3SHODa75HrqqiKX1gdMycjAYa9stU3JCPBhKue1ExNQAY3BakSgl1HBDMykI9z+Ps0WV0eXIITKdhM3c+yUS259FrVPrBM2TlclEnSmY8+S93tV+/yvH+hxxLzv5D11paFltOLmIFD3FzIbtRMzpr2K5arn+bgzJZAWl8631rjby0zAUJI3xnTfkjQTiFEAwHW3bGWNCoCv0zlvjUukgOoJgPymzQGg3itbfVMOMKiezh30ExOIqwIAvwNHZFUVzMcdbbUonGOnsomr4P1XfiFZ/tS6GOOkW7FqJh8gwomz06ol9oE7O6GhH9zLCbfeCxqDoE3AR61sm5nA/3XUZ47pmneYCSLnAeHLCQGZaAvmH72yXdjMA6oTanHpnFfQ5tDmQ+KJ6MZ+jckXY7//k4pWYB7sQVCUccxU3a9teHKcWS7ne0wI4Rhipqv7REZkku8hvhut+CEOidvjkcDWFfgD9RMzMKE7f80AAAAASUVORK5CYII=);
+		}
+		h5::before {
+			top: -1px;
+			left: 0;
+			width: 18px;
+			height: 18px;
+			background-size: 100% 100%;
+			background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAC8klEQVRYR+3WP2gTURwH8O/vKnVRRHKXP52cBO3g4p/BqYNIhy4muajUQRBFKjQV1En6ZxJBcmlRCoJDRe3FDiJVwamLS3FwqbgIgss1l2ZQF5XeT3I1Z3NJ7u5dLlAwN9699/t97vfe7/EIO/yhHe5DD9jpCv3fFVTu856+Xzi62Y/35hj9CFPNrlUwWeRJAJfBGADwBcBNI0/PRZFdAdo4xlQLjCqKjBzogat7hZCRAgPghJGBgbFnxglAOvS7b/fLb+q+qnv5BHBCyEBAWTdnANy2IxOtWSC1qsY+1jOFwAVG+gIVvXyHQbcaKuZCxmf5iMQogXFQtEsBeO5JT6BcMu+Bcb1lUhcyqfExACUAB6JEtgUqi+U5JrrmmcyFHCjwSYtQO+tSUSFbAmXdnAdwJVASdyWLPAS2kbFA8xsHNS13EzCmlx8R6KJQcBcypfFpho3cKxRna3ADsgEo6+ZjAKMhgjZ1d2KWR2gTSyD0h4jnIB2gXDIXwciFCPZvSnMl0wwshYrJGDImaMUGKovlLBPVOrDjh8APzFx8zDkjNa7FzYoGZsKT9XEarQOHmei1aJA246cqOWW6/i2l8VMGzoWIPW/k6eq2Ja6UwCz8pw2JGZ8sS8pUz8fWau/jGp+SgLchcGBgZD1Py41NUuoI+ZloV8ZU93+ogZKzfBiWfXAPCgMJU8Y42avQdMzI4ZBfmSizocqrUeJaAmsvBZGGBCtTziXeRY1rCxRAViEhXckqK93AeQJ9kYTvlmVlqmcTdhNEtefc+9X3utVmuX+CkK6oyqtu4nwrWP8bF5IZdGYjJ79wDuMCz4D+XmhFWnZbt7ab5ltBB6mbkyAaBPFCJassuwMmNb4L4EZgXwBc4AoGTZrUeA6A9x1yK6tzzvnFDlxBv0D176kCP2TCpbbjBXCRV9DZk0VeAONCE1IQ1zWg3dlF1sFQHWQIXFeBNrLAw5BwHBZWjQl6E3SbbB8X+R4Mg/Ca0wN2WtFeBTut4B84mFI4VpekyAAAAABJRU5ErkJggg==);
+		}
+		h6::before {
+			top: -1px;
+			left: 0;
+			width: 16px;
+			height: 16px;
+			background-size: auto 100%;
+			background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAEI0lEQVRYR+3Xb2wTZRwH8G/vL22RPy5GW8fK6rJpGeFFY9RO3TRRE1HfmEAammEyjZmOSEg1RrPZaTD6xjhU/Ndlf0CZgwmD+qcgZBRIETeqY0Vcnc7pGonhRY2l3m2t5upqjq693l2vcy+8N81zz/NcPvf75fd7rjos8ku3yH34HyjK0PqlDLPzD56vMNL0VHxmpgXAoUIZXKgI3sMSpK+cNTAbylaj/9IkprnLM3+mkg8XQi4EsJ4lSL+ZNbAfVd+JG/XLcSERg3M8IAtZauBtDEkeNdN6fX9NfRqXueQiSwm00wQRMDMGw94snBJkqYBrSYIIXs/ojftqGq6IXHZRFIpkKYC2JSw9SJE663W0nthlqcMawwrJYpVCag1M48pNy1YNvLWRffGNIYSCv6Cnog7rjCtVIbUEXoG7qeqaNMj51F58dWoKPZY62JeWyUJG+ctcIplcIizWCpgTl9EIyODJn9BtceDWq/6B57tO/H4R688fFaZ7AWzWAiiJEyMDx39Ed6UDdyy7Ni/w1ekxbP95VJi/F8CRYoGycGLksaEJdFU6cPdy0zxkR/RbtE6FhPvHATQUm2JFODHSfyyCLmsd7lth/hf53q/jcE8OC+NTAG7PTKiNoCqcGPnpF+PotDrwwNXl+OC3H9A8cVqYPgPgFnFo1QCLwomRg4cvoPMGBzZHTgq3zwKwZ+ddKVATXAZhf+gdnPvuojAUqmJdrspRAtQU93pXEM+8clgwhQHU5itruUBNcTt6TsP9sl8weQC0S/VFOUBNcW/2folt2z+XhZPTZh6kSKJ79aqVRuFszRxfUm8sNbdz9xlsfekz2bhCQBvDMMM8z+ubN92Mjrb71brS+97dM4wtnk8U4SSBBEGErVarzeVywePxoHVLA1pb6lUh3+8bwZMv+BTjpIAVACa9Xq+uqakJ7e3tqpGd/WfR3Jr+81awIJS0mY0A+sLhMGw2W3qfGmTXvhAef/6gapxUBDuqqqoejUQiBvFbKUH2DHyNx54bLAqXF0jT9HBjY6Pd6/XOi7oc5K7936Dp2QNF4/IBrTqdLuLxeIi2tracRSGF/PDgKB55er8muHzATQB2m81m+P1+1NbmPoVyIfsOnUOj+2PNcPmAO1iWbeE4TmexWODz+WQhayrL4No2oCkuJ5Bl2VGO49ZmcisXObdeVSuRaq7ZZ3G10KBTqRQl3pQPGY1GEQgE4HQ6heVDAO5S1cklNmUDXQRB9KZSqXkfESaTCW63G7FYDKFQKDEyMoJoNKqfe/bbAJ7QGpcrxa8xDLOV5/k0kGXZv2ZnZ5FMJtNjmqYvURR1IpFInAcwAeD7ud/pUuDmAY1G41g8Hl9DkmScoqggx3FHAIyJMMlSQfI9V5zKagDC93dsoRFKimQx2dIWOV/U/yn6bx0WyDj8vgLOAAAAAElFTkSuQmCC);
+		}
+		blockquote > p {
+			margin-bottom: 0 !important;
+			margin-top: 0 !important;
+			font-size: 0.9em !important;
+		}
+		code[class='md-code'],
+		code:not([class]) {
+			display: inline-block;
+			font-size: 13px;
+			color: #409eff;
+			margin: 2px 5px;
+			padding: 0 8px;
+			white-space: normal;
+			text-indent: 0;
+			-webkit-user-select: auto;
+			-moz-user-select: auto;
+			-ms-user-select: auto;
+			user-select: auto;
+			vertical-align: baseline;
+			word-break: break-word;
+			background: #e8f3ff;
+			border-radius: 4px;
+		}
+		code[class*='language-'] {
+			display: block;
+			overflow-x: auto;
+			// border-radius: 0 0 8px 8px;
+			white-space: pre-wrap;
+			word-break: break-all;
+			user-select: auto;
+			padding: 12px 12px 14px 18px;
+			margin-bottom: 16px;
+			background: #282c34;
+			color: #abb2bf;
+			border-radius: 4px;
+			text-shadow: 0 1px rgba(0, 0, 0, 0.3);
+			font-family: 'Fira Code', 'Fira Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
+			direction: ltr;
+			text-align: left;
+			white-space: pre;
+			word-spacing: normal;
+			word-break: normal;
+			line-height: 1.5;
+			-moz-tab-size: 2;
+			-o-tab-size: 2;
+			tab-size: 2;
+			-webkit-hyphens: none;
+			-moz-hyphens: none;
+			-ms-hyphens: none;
+			hyphens: none;
+		}
+		table {
+			td {
+				padding: 8px;
+				border-right: 1px solid var(--classE);
+				border-bottom: 1px solid var(--classE);
+			}
+			thead th {
+				font-weight: 500;
+				background: var(--classC);
+			}
+
+			tbody tr {
+				transition: background 0.35s;
+			}
+		}
+	}
+}

+ 67 - 0
common/mixins/index.js

@@ -0,0 +1,67 @@
+/**
+ *  功能:全局混入函数
+ *  作者:小莫唐尼
+ *  邮箱:studio@925i.cn
+ *  时间:2022年07月21日 17:39:32
+ *  版本:v0.1.0
+ *  修改记录:
+ *  修改内容:
+ *  修改人员:
+ *  修改时间:
+ */
+import HaloConfig from '@/config/halo.config.js';
+import HaloAdConfig from '@/config/ad.config.js';
+export default {
+	install(Vue) {
+		Vue.mixin({
+			data() {
+				return {
+					author: HaloConfig.author,
+					_isWechat: true,
+					haloAdConfig: HaloAdConfig
+				};
+			},
+			computed: {
+				// 获取全局应用设置
+				globalAppSettings() {
+					return uni.$tm.vx.getters().setting.getSettings;
+				}
+			},
+			created() {
+				// #ifdef MP-WEIXIN
+				this._isWechat = true;
+				uni.$tm.vx.commit('setWxShare', HaloConfig.wxShareConfig);
+				// #endif
+				// #ifndef MP-WEIXIN
+				this._isWechat = false;
+				// #endif
+			},
+
+			methods: {
+				/**
+				 * 设置页面标题
+				 * @param {Object} title 标题
+				 */
+				fnSetPageTitle(title) {
+					uni.setNavigationBarTitle({
+						title: title || HaloConfig.title
+					})
+				},
+
+				/**
+				 * 页面返回顶部
+				 */
+				fnToTopPage(duration = 500) {
+					duration = isNaN(duration) ? 500 : duration
+					uni.pageScrollTo({
+						scrollTop: 0,
+						duration: duration,
+						fail: (err) => {
+							console.log('err:', err);
+						},
+					});
+				}
+			},
+		});
+	},
+};

+ 38 - 0
common/styles/app.base.scss

@@ -0,0 +1,38 @@
+// 全局css基础样式
+
+.card-shadow {
+	box-shadow: 0rpx 0rpx 24rpx rgba(0, 0, 0, 0.08);
+}
+.bg-white {
+	background-color: #fff;
+}
+.load-text {
+	padding: 0 0 20rpx 0;
+	text-align: center;
+	color: #999;
+	font-size: 24rpx;
+}
+.e-fixed {
+	position: fixed;
+	left: 0;
+	/* #ifndef H5 */
+	top: 0;
+	/* #endif */
+	/* #ifdef H5 */
+	top: 88rpx;
+	/* #endif */
+	right: 0;
+	z-index: 6;
+}
+.e-loading-icon {
+	animation: eLoading 0.8s linear infinite;
+}
+
+@keyframes eLoading {
+	0% {
+		transform: rotateZ(0deg);
+	}
+	100% {
+		transform: rotateZ(360deg);
+	}
+}

+ 8 - 0
common/styles/app.theme.scss

@@ -0,0 +1,8 @@
+// 主题
+
+:root {
+	--theme: #f79ea3;
+	// --theme: #ffaec3;
+	--main: #303133;
+	--main-text-color: rgba(12, 25, 50, 1);
+}

文件差異過大導致無法顯示
+ 4 - 0
common/theme/theme.css


+ 304 - 0
components/article-card/article-card.vue

@@ -0,0 +1,304 @@
+<template>
+	<view class="article-card " :class="cardType" @click="fnClickEvent('card')">
+		<view class="left">
+			<cache-image
+				class="thumbnail"
+				radius="12rpx"
+				:url="$utils.checkThumbnailUrl(article.thumbnail)"
+				:fileMd5="$utils.checkThumbnailUrl(article.thumbnail)"
+				mode="aspectFill"
+			></cache-image>
+			<!-- <image class="thumbnail" lazy-load :src="$utils.checkThumbnailUrl(article.thumbnail)" mode="aspectFill"></image> -->
+		</view>
+		<view class="right">
+			<view class="title">
+				<text class="is-top" v-if="article.topped">置顶</text>
+				<text class="title-text text-overflow">{{ article.title }}</text>
+			</view>
+			<view class="content text-overflow-2">{{ article.summary }}</view>
+			<view class="foot">
+				<view class="create-time">
+					<text class="time-label">发布时间:</text>
+					{{ { d: article.createTime, f: 'yyyy-MM-dd' } | formatTime }}
+				</view>
+				<view class="visits">
+					<!-- <tm-icons :size="24" name="icon-filter-fill"></tm-icons> -->
+					浏览
+					<text class="number">{{ article.visits }}</text>
+					次
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'article-card',
+	props: {
+		from: {
+			type: String,
+			default: ''
+		},
+		article: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	computed: {
+		cardType() {
+			// tb_image_text=上图下文
+			// tb_text_image=上文下图
+			if (this.from == 'home' && this.globalAppSettings.layout.home == 'h_row_col2') {
+				if (!['tb_image_text', 'tb_text_image', 'only_text'].some(x => x == this.globalAppSettings.layout.cardType)) {
+					return [this.from, this.globalAppSettings.layout.home, 'tb_image_text'];
+				}
+				return [this.from, this.globalAppSettings.layout.home, this.globalAppSettings.layout.cardType];
+			}
+			return [this.globalAppSettings.layout.home, this.globalAppSettings.layout.cardType];
+		}
+	},
+	methods: {
+		fnClickEvent() {
+			this.$emit('on-click', this.article);
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.article-card {
+	display: flex;
+	box-sizing: border-box;
+	margin: 0 24rpx;
+	padding: 32rpx;
+	border-radius: 12rpx;
+	background-color: #ffff;
+	box-shadow: 0rpx 2rpx 24rpx rgba(0, 0, 0, 0.03);
+	overflow: hidden;
+	margin-bottom: 24rpx;
+	&.home {
+		&.h_row_col2 {
+			margin: 12rpx;
+			.left {
+				width: 100%;
+				height: 200rpx;
+				.thumbnail {
+					::v-deep uni-image {
+						border-radius: 6rpx 6rpx 0 0 !important;
+					}
+				}
+			}
+			.right {
+				.title {
+					display: flex;
+					align-items: center;
+					font-size: 26rpx;
+					font-weight: bold;
+					.is-top {
+						height: 36rpx;
+						margin-right: 10rpx;
+						line-height: 36rpx;
+						vertical-align: 4rpx;
+						transform: scale(0.9);
+					}
+				}
+				.foot {
+					justify-content: space-between;
+					.create-time {
+						font-size: 24rpx;
+						.time-label {
+							display: none;
+						}
+					}
+					.visits {
+						font-size: 24rpx;
+						margin-left: 0;
+					}
+				}
+			}
+			&.tb_text_image {
+				padding: 12rpx;
+				.left .thumbnail {
+					::v-deep {
+						uni-image {
+							border-radius: 6rpx !important;
+						}
+					}
+				}
+			}
+			&.only_text {
+				padding: 24rpx;
+				.right .foot {
+					.create-time {
+						.time-label {
+							display: none;
+						}
+					}
+					.visits {
+						font-size: 24rpx;
+					}
+				}
+			}
+		}
+	}
+	&.lr_image_text {
+	}
+
+	&.lr_text_image {
+		.left {
+			order: 2;
+			padding-left: 30rpx;
+		}
+		.right {
+			order: 1;
+			padding-left: 0;
+		}
+	}
+	&.tb_image_text {
+		flex-direction: column;
+		padding: 0;
+		.left {
+			width: 100%;
+			height: 300rpx;
+			.thumbnail {
+				::v-deep uni-image {
+					border-radius: 6rpx 6rpx 0 0 !important;
+				}
+			}
+		}
+		.right {
+			padding-left: 0;
+			padding: 24rpx;
+			width: 100%;
+			.foot {
+				justify-content: flex-start;
+				.create-time {
+					.time-label {
+						display: inline-block;
+					}
+				}
+				.visits {
+					margin-left: 24rpx;
+				}
+			}
+		}
+	}
+	&.tb_text_image {
+		flex-direction: column;
+		.left {
+			width: 100%;
+			height: 260rpx;
+			order: 2;
+			margin-top: 24rpx;
+		}
+		.right {
+			padding-left: 0;
+			width: 100%;
+			order: 1;
+			.foot {
+				justify-content: flex-start;
+				.create-time {
+					.time-label {
+						display: inline-block;
+					}
+				}
+				.visits {
+					margin-left: 24rpx;
+				}
+			}
+		}
+	}
+	&.only_text {
+		padding: 36rpx;
+		.left {
+			display: none;
+		}
+		.right {
+			padding-left: 0;
+			.content {
+				margin-top: 24rpx;
+			}
+			.foot {
+				justify-content: flex-start;
+				margin-top: 24rpx;
+				.create-time {
+					.time-label {
+						display: inline-block;
+					}
+				}
+				.visits {
+					margin-left: 24rpx;
+				}
+			}
+		}
+	}
+
+	.left {
+		width: 240rpx;
+		height: 180rpx;
+		.thumbnail {
+			width: 100%;
+			height: 100%;
+			border-radius: 12rpx;
+		}
+	}
+	.right {
+		width: 0;
+		flex-grow: 1;
+		display: flex;
+		flex-direction: column;
+		padding-left: 30rpx;
+		box-sizing: border-box;
+		.title {
+			display: flex;
+			font-size: 30rpx;
+			color: var(--main-text-color);
+			.is-top {
+				height: 40rpx;
+				padding: 0 12rpx;
+				margin-right: 10rpx;
+				line-height: 40rpx;
+				font-size: 24rpx;
+				white-space: nowrap;
+				vertical-align: 4rpx;
+				color: #fff;
+				background-image: -webkit-linear-gradient(0deg, #3ca5f6 0, #a86af9 100%);
+				border-radius: 4rpx 12rpx;
+			}
+			&-text {
+				color: #303133;
+			}
+		}
+		.content {
+			display: -webkit-box;
+			font-size: 26rpx;
+			color: #909399;
+			height: 80rpx;
+			margin-top: 14rpx;
+			line-height: 42rpx;
+		}
+		.foot {
+			display: flex;
+			font-size: 24rpx;
+			justify-content: space-between;
+			align-items: center;
+			color: #909399;
+			margin-top: 18rpx;
+
+			.create-time {
+				font-size: 26rpx;
+				.time-label {
+					display: none;
+				}
+			}
+			.visits {
+				.number {
+					padding: 0 6rpx;
+					font-size: 26rpx;
+				}
+			}
+		}
+	}
+}
+</style>

+ 197 - 0
components/article-min-card/article-min-card.vue

@@ -0,0 +1,197 @@
+<template>
+	<view class="article-min-card" :class="[globalAppSettings.layout.cardType]" @click="fnClickEvent('card')">
+		<view class="left">
+			<cache-image
+				class="thumbnail"
+				radius="12rpx"
+				:url="$utils.checkThumbnailUrl(article.thumbnail)"
+				:fileMd5="$utils.checkThumbnailUrl(article.thumbnail)"
+				mode="aspectFill"
+			></cache-image>
+		</view>
+		<view class="right">
+			<view class="title text-overflow">{{ article.title }}</view>
+			<view class="content text-overflow">{{ article.summary }}</view>
+			<view class="foot">
+				<view class="create-time">
+					<!-- <text class="icon iconfont icon-clock"></text> -->
+					<text class="time-label">发布时间:</text>
+					{{ { d: article.createTime, f: 'yyyy-MM-dd' } | formatTime }}
+				</view>
+				<view class="visits">
+					<!-- <text class="icon iconfont icon-eye"></text> -->
+					浏览
+					<text class="number">{{ article.visits }}</text>
+					次
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'article-min-card',
+	props: {
+		article: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	methods: {
+		fnClickEvent() {
+			this.$emit('on-click', this.article);
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.article-min-card {
+	display: flex;
+	box-sizing: border-box;
+	border-radius: 12rpx;
+	background-color: #ffff;
+	overflow: hidden;
+	margin: 12rpx 24rpx;
+	margin-bottom: 24rpx;
+	padding: 16rpx;
+	box-shadow: 0rpx 2rpx 24rpx rgba(0, 0, 0, 0.05);
+	&.lr_image_text {
+	}
+
+	&.lr_text_image {
+		.left {
+			order: 2;
+			padding-left: 30rpx;
+		}
+		.right {
+			order: 1;
+			padding-left: 0;
+		}
+	}
+	&.tb_image_text {
+		flex-direction: column;
+		.left {
+			width: 100%;
+			height: 220rpx;
+		}
+		.right {
+			padding-left: 0;
+			width: 100%;
+			.title {
+				margin-top: 24rpx;
+			}
+			.foot {
+				justify-content: flex-start;
+				.create-time {
+					.time-label {
+						display: inline-block;
+					}
+				}
+				.visits {
+					margin-left: 24rpx;
+				}
+			}
+		}
+	}
+	&.tb_text_image {
+		flex-direction: column;
+		.left {
+			width: 100%;
+			height: 220rpx;
+			order: 2;
+			margin-top: 20rpx;
+		}
+		.right {
+			padding-left: 0;
+			width: 100%;
+			order: 1;
+			.foot {
+				justify-content: flex-start;
+				.create-time {
+					.time-label {
+						display: inline-block;
+					}
+				}
+				.visits {
+					margin-left: 24rpx;
+				}
+			}
+		}
+	}
+	&.only_text {
+		.left {
+			display: none;
+		}
+		.right {
+			padding-left: 0;
+			.foot {
+				justify-content: flex-start;
+				.create-time {
+					.time-label {
+						display: inline-block;
+					}
+				}
+				.visits {
+					margin-left: 24rpx;
+				}
+			}
+		}
+	}
+
+	.left {
+		width: 180rpx;
+		height: 130rpx;
+		.thumbnail {
+			width: 100%;
+			height: 100%;
+			border-radius: 12rpx;
+		}
+	}
+	.right {
+		width: 0;
+		flex-grow: 1;
+		display: flex;
+		flex-direction: column;
+		padding-left: 24rpx;
+		.title {
+			font-size: 28rpx;
+			font-weight: 600;
+			color: var(--main-text-color);
+		}
+		.content {
+			font-size: 26rpx;
+			color: #909399;
+			margin-top: 14rpx;
+		}
+		.foot {
+			display: flex;
+			font-size: 24rpx;
+			justify-content: space-between;
+			align-items: center;
+			color: #909399;
+			margin-top: 14rpx;
+			.create-time {
+				font-size: 24rpx;
+				.time-label {
+					display: none;
+				}
+				.icon {
+					font-size: 24rpx;
+					padding-right: 4rpx;
+				}
+			}
+			.visits {
+				.icon {
+					font-size: 28rpx;
+				}
+				.number {
+					padding: 0 6rpx;
+					font-size: 24rpx;
+				}
+			}
+		}
+	}
+}
+</style>

+ 207 - 0
components/attachment-select/attachment-select.vue

@@ -0,0 +1,207 @@
+<template>
+	<tm-poup v-model="show" position="bottom" height="auto" @change="fnClose">
+		<view class="poup-head pa-24 text-align-center text-weight-b ">{{ title }}</view>
+		<view class="poup-body pa-24 pt-0 pb-0">
+			<view v-if="loading != 'success'" class="loading-wrap flex flex-center">
+				<view v-if="loading == 'loading'" class="loading">加载中...</view>
+				<view v-else class="error" @click="fnGetData()">加载失败,点击刷新!</view>
+			</view>
+			<block v-else>
+				<view v-if="total == 0" class="empty">无附件</view>
+				<scroll-view v-else class="poup-content" :enable-flex="true" :scroll-y="true" @touchmove.stop>
+					<view class="card-content">
+						<view class="card pa-12" v-for="(file, index) in dataList" :key="index" @click="fnOnSelect(file, index)">
+							<view class="card-inner round-3" :class="{ 'is-select': selectIndex == index }">
+								<cache-image v-if="file.isImage" class="cover" height="160rpx" :url="file.thumbPath" :fileMd5="file.thumbPath" mode="aspectFill"></cache-image>
+								<view v-else class="cover flex pl-46 pr-46 flex-center bg-gradient-blue-grey-accent text-align-center text-size-m">{{ file.mediaType }}</view>
+
+								<view class="name text-overflow text-size-m pa-12">{{ file.name }}</view>
+							</view>
+						</view>
+					</view>
+				</scroll-view>
+			</block>
+		</view>
+		<view class="poup-foot pa-30 pb-12 pt-0">
+			<!-- 分页 -->
+			<view v-if="total > queryParams.size" class="mt-36 pl-24 pr-24">
+				<tm-pagination color="bg-gradient-blue-accent" :page.sync="queryParams.page" :total="total" :totalVisible="5" @change="fnGetPagingData"></tm-pagination>
+			</view>
+			<view class=" flex flex-center mt-12">
+				<tm-button size="m" theme="bg-gradient-blue-accent" @click="fnSava()">确定选用</tm-button>
+				<tm-button size="m" theme="bg-gradient-orange-accent" @click="fnUpload()">上传</tm-button>
+				<tm-button size="m" theme="bg-gradient-blue-grey-accent" @click="fnClose(false)">关 闭</tm-button>
+			</view>
+		</view>
+	</tm-poup>
+</template>
+
+<script>
+import { getAdminAccessToken } from '@/utils/auth.js';
+
+import tmPoup from '@/tm-vuetify/components/tm-poup/tm-poup.vue';
+import tmPagination from '@/tm-vuetify/components/tm-pagination/tm-pagination.vue';
+import tmButton from '@/tm-vuetify/components/tm-button/tm-button.vue';
+export default {
+	name: 'attachment-select',
+	components: { tmPoup, tmPagination, tmButton },
+	props: {
+		title: {
+			type: String,
+			default: '附件列表'
+		},
+		selectType: {
+			type: String,
+			default: ''
+		}
+	},
+	data() {
+		return {
+			show: true,
+			loading: 'loading',
+			total: 0,
+			queryParams: {
+				size: 6,
+				page: 1
+			},
+			dataList: [],
+			select: null,
+			selectIndex: -1
+		};
+	},
+	created() {
+		this.fnGetData();
+	},
+	methods: {
+		fnGetData() {
+			this.queryParams.page = 1;
+			this.fnGetPagingData(1);
+		},
+		fnGetPagingData(page) {
+			this.loading = 'loading';
+			const _params = {
+				...this.queryParams
+			};
+			_params.page = page - 1;
+			this.$httpApi.admin
+				.getAttachmentsByPage(_params)
+				.then(res => {
+					if (res.status == 200) {
+						this.total = res.data.total;
+						this.dataList = res.data.content.map(file => {
+							if (this.$utils.fnCheckIsFileType('image', file) && file.size / 1024 / 1024 > 2) {
+								file.isImage = false;
+								file.desc = '图片过大无法显示缩略图';
+							} else {
+								file.isImage = this.$utils.fnCheckIsFileType('image', file);
+							}
+							file.thumbPath = this.$utils.checkThumbnailUrl(file.thumbPath);
+							return file;
+						});
+						this.loading = 'success';
+					} else {
+						uni.$tm.toast('加载失败,请重试!');
+						this.loading = 'error';
+					}
+				})
+				.catch(err => {
+					console.error(err);
+					uni.$tm.toast('加载失败,请重试!');
+					this.loading = 'error';
+				});
+		},
+		fnOnSelect(file, index) {
+			this.select = file;
+			this.selectIndex = index;
+		},
+		fnSava() {
+			if (this.selectType) {
+				if (this.$utils.fnCheckIsFileType(this.selectType, this.select)) {
+					this.$emit('on-select', this.select);
+				} else {
+					uni.$tm.toast('该附件类型不符合!');
+				}
+			} else {
+				this.$emit('on-select', this.select);
+			}
+		},
+		fnClose(e) {
+			if (!e) {
+				this.$emit('on-close');
+			}
+		},
+		fnUpload() {
+			uni.chooseImage({
+				count: 1,
+				success: res => {
+					uni.uploadFile({
+						filePath: res.tempFilePaths[0],
+						header: {
+							'admin-authorization': getAdminAccessToken()
+						},
+						url: this.$baseApiUrl + '/api/admin/attachments/upload',
+						name: 'file',
+						success: upladRes => {
+							const _uploadRes = JSON.parse(upladRes.data);
+							if (_uploadRes.status == 200) {
+								uni.$tm.toast('上传成功!');
+								this.fnGetData(1);
+							} else {
+								uni.$tm.toast(_uploadRes.message);
+							}
+						},
+						fail: err => {
+							uni.$tm.toast(err.message);
+						}
+					});
+				}
+			});
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.poup-head {
+}
+.poup-body {
+	height: 50vh;
+}
+.loading-wrap {
+	height: 50vh;
+	background-color: #fafafa;
+}
+.poup-content {
+	height: inherit;
+	box-sizing: border-box;
+	.card-content {
+		height: inherit;
+		display: flex;
+		flex-wrap: wrap;
+	}
+}
+.card {
+	width: 50%;
+	box-sizing: border-box;
+	&-inner {
+		box-sizing: border-box;
+		overflow: hidden;
+		box-shadow: 0rpx 4rpx 24rpx rgba(0, 0, 0, 0.05);
+		border: 4rpx solid transparent;
+		&.is-select {
+			border-color: rgb(13, 141, 242);
+		}
+	}
+	.cover {
+		width: 100%;
+		height: 160rpx;
+		flex-wrap: wrap;
+		box-sizing: border-box;
+	}
+	.name {
+		color: #303133;
+		box-sizing: border-box;
+		text-align: center;
+	}
+}
+</style>

+ 194 - 0
components/bottom-tool-bar/bottom-tool-bar.vue

@@ -0,0 +1,194 @@
+<template>
+	<view class="bottom-tool-bar">
+		<tm-translate :auto="true" animation-name="fadeUp">
+			<view class="content flex">
+				<view class="input" @click="fnToComment()">
+					<text class="icon iconfont icon-edit"></text>
+					<text class="text">(*^▽^*)说点啥吧~</text>
+				</view>
+				<view class="right flex">
+					<!-- 点赞 -->
+					<view class="item likes" @click="fnDoLikes()">
+						<view class="iconfont icon-like"></view>
+						<view class="text">{{ tempPost.likes }}</view>
+					</view>
+					<!-- 评论 -->
+					<view class="item comment">
+						<view class="iconfont icon-comment-dots"></view>
+						<view class="text">{{ tempPost.commentCount }}</view>
+					</view>
+					<!-- 分享 -->
+					<view class="item share" @click="fnOnShare()"><text class="iconfont icon-share1"></text></view>
+				</view>
+			</view>
+		</tm-translate>
+
+		<tm-shareSheet @change="fnOnShareChange" :actions="share.list" title="分享文章" v-model="share.show"></tm-shareSheet>
+	</view>
+</template>
+
+<script>
+import tmTranslate from '@/tm-vuetify/components/tm-translate/tm-translate.vue';
+import tmShareSheet from '@/tm-vuetify/components/tm-shareSheet/tm-shareSheet.vue';
+export default {
+	name: 'bottom-tool-bar',
+	components: {
+		tmTranslate,
+		tmShareSheet
+	},
+	props: {
+		// 文章数据
+		post: {
+			type: Object,
+			default: () => {}
+		},
+		// 其他参数
+		params: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	data() {
+		return {
+			share: {
+				show: false,
+				list: [
+					[
+						{ name: '微信好友', bgcolor: '#07c160', icon: 'icon-weixin', color: 'white' },
+						{ name: '朋友圈', bgcolor: '#04c887', icon: 'icon-pengyouquan', color: 'white' },
+						{ name: '生成海报', bgcolor: '#1dc0fd', icon: 'icon-QQ', color: 'white' }
+					]
+				]
+			},
+			tempPost: {}
+		};
+	},
+	watch: {
+		post: {
+			deep: true,
+			handler(val) {
+				console.log('watch', val);
+				this.tempPost = this.$utils.deepClone(val);
+			}
+		}
+	},
+	created() {
+		console.log(this.post);
+		this.tempPost = this.$utils.deepClone(this.post);
+		console.log(this.tempPost);
+	},
+	methods: {
+		fnToComment() {
+			this.$Router.push({
+				path: '/pagesA/comment/comment',
+				query: {
+					postId: this.post.id,
+					parentId: 0,
+					title: this.post.title,
+					formPage: 'comment_list',
+					type: 'post'
+				}
+			});
+		},
+		fnDoLikes() {
+			this.$httpApi
+				.postLikePost(this.post.id)
+				.then(res => {
+					if (res.status == 200) {
+						uni.showToast({
+							icon: 'none',
+							title: '点赞成功'
+						});
+						this.tempPost.likes += 1;
+					} else {
+						uni.showToast({
+							icon: 'none',
+							title: res.message
+						});
+					}
+				})
+				.catch(err => {
+					console.log(err);
+					uni.showToast({
+						icon: 'none',
+						title: err.message
+					});
+				});
+		},
+		fnOnShare() {
+			// this.$emit('on-share');
+			this.share.show = true;
+		},
+		fnOnShareChange(e) {
+			console.log(e);
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.bottom-tool-bar {
+	width: 100vw;
+	position: fixed;
+	left: 0;
+	bottom: 0;
+	z-index: 401;
+
+	::v-deep {
+		.tm-shareSheet-wk .uni-scroll-view-content {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+		}
+	}
+	.content {
+		width: 100%;
+		justify-content: space-between;
+		box-sizing: border-box;
+		padding: 24rpx;
+		background-color: #ffffff;
+		box-shadow: 0rpx -4rpx 24rpx rgba(0, 0, 0, 0.07);
+		border-radius: 24rpx 24rpx 0 0;
+		.input {
+			width: 280rpx;
+			padding: 12rpx 24rpx;
+			background-color: #f5f5f5;
+			border-radius: 60rpx;
+			font-size: 24rpx;
+			color: #666;
+			.icon {
+			}
+			.text {
+				padding-left: 8rpx;
+			}
+		}
+
+		.right {
+			width: 0;
+			flex-grow: 1;
+			align-items: center;
+			justify-content: space-between;
+			padding-left: 24rpx;
+			.item {
+				margin-left: 24rpx;
+				text-align: center;
+				display: flex;
+				align-items: center;
+				&.share {
+					.iconfont {
+						font-size: 36rpx;
+					}
+				}
+				.iconfont {
+					font-size: 36rpx;
+					color: #333;
+				}
+				.text {
+					padding-left: 6rpx;
+					font-size: 32rpx;
+				}
+			}
+		}
+	}
+}
+</style>

+ 180 - 0
components/cache-image/cache-image.vue

@@ -0,0 +1,180 @@
+<template>
+	<view class="">
+		<view v-if="loadStatus == 'loading'" class="img-loading" :style="[imgStyle, loadStyle]">
+			<!-- <text class="img-load-icon iconfont icon-loading"></text>
+			<text class="img-load-text">{{ loadText }}</text> -->
+			<image :src="loadingImgSrc" :style="[imgStyle]" mode="aspectFit"></image>
+		</view>
+		<view v-if="loadStatus == 'error'" class="img-error" :style="[imgStyle, loadErrStyle]">
+			<text class="img-err-icon iconfont icon-exclamation-circle"></text>
+			<text class="img-load-text">{{ loadErrText }}</text>
+		</view>
+		<image
+			v-show="loadStatus == 'success'"
+			:src="src"
+			@load="fnOnLoad"
+			@error="fnOnError"
+			:lazy-load="lazyLoad"
+			:style="[imgStyle]"
+			:mode="mode"
+			@click="$emit('on-click', url)"
+		></image>
+	</view>
+</template>
+
+<script>
+import imageCache from '@/utils/imageCache.js';
+export default {
+	name: 'cache-image',
+	props: {
+		url: {
+			type: String,
+			default: ''
+		},
+		lazyLoad: {
+			type: Boolean,
+			default: true
+		},
+		loadStyle: {
+			type: Object,
+			default() {
+				return {
+					backgroundColor: '#ffffff',
+					color: '#333'
+				};
+			}
+		},
+		loadErrStyle: {
+			type: Object,
+			default() {
+				return {
+					color: 'rgba(244, 67, 54,1)'
+					// backgroundColor: 'rgba(244, 67, 54,0.2)'
+				};
+			}
+		},
+		mode: {
+			type: String,
+			default: 'aspectFill'
+		},
+		loadText: {
+			type: String,
+			default: '加载中...'
+		},
+		loadErrText: {
+			type: String,
+			default: '加载失败'
+		},
+		fileMd5: {
+			type: String,
+			default: ''
+		},
+		styles: {
+			type: Object,
+			default() {
+				return {};
+			}
+		},
+		width: {
+			type: String,
+			default: '100%'
+		},
+		height: {
+			type: String,
+			default: '100%'
+		},
+		radius: {
+			type: String,
+			default: ''
+		}
+	},
+	data() {
+		return {
+			imgStyle: {},
+			src: '', // 图片地址
+			loadStatus: 'loading'
+		};
+	},
+	computed: {
+		loadingImgSrc() {
+			return getApp().globalData.loadingGifUrl;
+		}
+	},
+	watch: {
+		// 监听图片md5值的变化
+		fileMd5(val) {
+			// 查找获取图片缓存
+			this.fnGetImageCache();
+		}
+	},
+	created() {
+		this.imgStyle = {
+			width: this.width,
+			height: this.height,
+			borderRadius: this.radius,
+			...this.styles
+		};
+
+		// 查找获取图片缓存
+		this.fnGetImageCache();
+	},
+	methods: {
+		// 查找获取图片缓存
+		async fnGetImageCache() {
+			// #ifdef APP-PLUS
+			var result = await imageCache.getImageCache(this.url, this.fileMd5);
+			if (result) {
+				this.src = result;
+			} else {
+				this.src = this.url;
+			}
+			// #endif
+			// #ifndef APP-PLUS
+			this.src = this.url;
+			// #endif
+		},
+		fnOnLoad() {
+			this.loadStatus = 'success';
+		},
+		fnOnError() {
+			this.loadStatus = 'error';
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.img-loading,
+.img-error {
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	box-sizing: border-box;
+	background-color: #f2f2f2;
+}
+.img-load-icon {
+	font-size: 36rpx;
+	animation: xhRote 0.8s infinite linear;
+}
+.img-load-text {
+	font-size: 28rpx;
+	margin-top: 8rpx;
+	color: inherit;
+}
+.img-error {
+	font-size: 28rpx;
+}
+.img-err-icon {
+	font-size: 36rpx;
+}
+@keyframes xhRote {
+	0% {
+		transform: rotate(0deg);
+	}
+
+	100% {
+		transform: rotate(360deg);
+	}
+}
+</style>

+ 63 - 0
components/category-mini-card/category-mini-card.vue

@@ -0,0 +1,63 @@
+<template>
+	<view class="category-mini-card">
+		<!-- 	<image class="img" lazy-load :src="$utils.checkThumbnailUrl(category.thumbnail)" mode="aspectFill"></image> -->
+		<cache-image
+			class="img"
+			height="120rpx"
+			:url="$utils.checkThumbnailUrl(category.thumbnail)"
+			:fileMd5="$utils.checkThumbnailUrl(category.thumbnail)"
+			mode="aspectFill"
+		></cache-image>
+
+		<text class="label">{{ category.postCount }}&nbsp;篇</text>
+		<view class="name">{{ category.name }}</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'category-mini-card',
+	props: {
+		category: {
+			type: Object,
+			default: () => {}
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.category-mini-card {
+	display: inline-block;
+	width: 260rpx;
+	height: 180rpx;
+	position: relative;
+	border-radius: 12rpx;
+	background-color: #fff;
+	overflow: hidden;
+	// border: 2rpx solid #f7f7f7;
+	box-shadow: 0rpx 2rpx 24rpx rgba(0, 0, 0, 0.03);
+	.img {
+		width: 100%;
+		height: 120rpx;
+		border: 6rpx 6rpx 0 0;
+	}
+	.label {
+		position: absolute;
+		left: 0;
+		top: 86rpx;
+		color: #03a9f4;
+		font-size: 24rpx;
+		background-color: rgba(255, 255, 255, 1);
+		border-radius: 0rpx 24rpx 0 0;
+		display: flex;
+		padding: 2rpx 12rpx;
+		padding-right: 24rpx;
+	}
+	.name {
+		font-size: 24rpx;
+		text-align: center;
+		color: var(--main-text-color);
+	}
+}
+</style>

+ 147 - 0
components/comment-item/comment-item.vue

@@ -0,0 +1,147 @@
+<template>
+	<view class=" comment-item flex flex-col mt-30 pt-24" :class="{ 'child-comment-item': isChild, 'no-solid': !useSolid, classItem }">
+		<view class="comment-item_user flex">
+			<image v-if="comment.isAdmin" class="user-avatar" :src="bloggerInfo.avatar" mode="aspectFill" @error="fnOnImageError(comment)"></image>
+			<image v-else class="user-avatar" :src="comment.avatar" mode="aspectFill" @error="fnOnImageError(comment)"></image>
+			<view class="user-info pl-14">
+				<view class="author">
+					<text class="mr-6 text-grey-darken-1 text-size-m">{{ comment.author }}</text>
+					<tm-tags v-if="comment.isAdmin" :dense="true" color="bg-gradient-amber-accent" size="xs" model="fill">博主</tm-tags>
+
+					<tm-tags v-else :dense="true" color="bg-gradient-light-blue-lighten " size="xs" model="fill">游客</tm-tags>
+				</view>
+				<view class="flex mt-4">
+					<view v-if="false" class="text-size-s text-grey mr-12">IP属地:浙江省杭州市</view>
+					<view class="time text-size-xs text-grey">
+						<text class="">{{ $tm.dayjs(comment.createTime).format('YYYY年MM月DD日') }}</text>
+						<text class="ml-12">{{ $tm.dayjs(comment.createTime).fromNow(true) }}前</text>
+					</view>
+				</view>
+			</view>
+			<view v-if="useActions" class="">
+				<tm-button size="s" text theme="blue" @click="$emit('on-comment', { type: 'user', comment: comment })">回复</tm-button>
+				<tm-button size="s" text theme="grey" @click="$emit('on-copy', comment.content)">复制</tm-button>
+			</view>
+		</view>
+
+		<view class="comment-item_content mt-12" :class="{ 'has-bg': useContentBg, 'not-ml': isChild }" @click="$emit('on-detail', comment)" v-html="comment.content"></view>
+
+		<!-- 	<view v-if="useActions" class="comment-item_info text-size-s text-grey">
+			<text v-if="false" @click="$emit('on-todo')">点赞</text>
+			<text @click="$emit('on-comment', { type: 'user', comment: comment })">回复</text>
+			<text v-if="false" class="ml-24" @click="$emit('on-todo')">举报</text>
+			<text class="ml-24" @click="$emit('on-copy', comment.content)">复制内容</text>
+		</view> -->
+	</view>
+</template>
+
+<script>
+import tmTags from '@/tm-vuetify/components/tm-tags/tm-tags.vue';
+import tmButton from '@/tm-vuetify/components/tm-button/tm-button.vue';
+export default {
+	name: 'comment-item',
+	components: { tmTags, tmButton },
+	props: {
+		classItem: {
+			type: Array,
+			default: () => []
+		},
+		useActions: {
+			type: Boolean,
+			default: true
+		},
+		useSolid: {
+			type: Boolean,
+			default: true
+		},
+		useContentBg: {
+			type: Boolean,
+			default: true
+		},
+		isChild: {
+			type: Boolean,
+			default: false
+		},
+		postId: {
+			type: Number,
+			default: null
+		},
+		comment: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	computed: {
+		// 获取博主信息
+		bloggerInfo() {
+			let blogger = this.$tm.vx.getters().blogger.getBlogger;
+			blogger.avatar = this.$utils.checkAvatarUrl(blogger.avatar, true);
+			return blogger;
+		}
+	},
+	methods: {
+		fnOnImageError(data) {
+			if (data.isAdmin) {
+				data.avatar = this.$haloConfig.author.avatar;
+			} else {
+				data.avatar = `${this.$haloConfig.defaultAvatarUrl}&rt=${new Date().getTime()}`;
+			}
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.comment-item {
+	box-sizing: border-box;
+	border-top: 2rpx solid #f5f5f5;
+
+	&.child-comment-item {
+		padding-top: 0;
+		margin-left: 80rpx;
+		border: 0;
+	}
+	&.no-solid {
+		border: 0;
+		margin-top: 0 !important;
+	}
+	&_user {
+		display: flex;
+		align-items: center;
+		.user-avatar {
+			width: 70rpx;
+			height: 70rpx;
+			border-radius: 50%;
+			box-sizing: border-box;
+			border: 4rpx solid #ffffff;
+			box-shadow: 0rpx 2rpx 24rpx rgba(0, 0, 0, 0.05);
+		}
+		.user-info {
+			width: 0;
+			flex-grow: 1;
+		}
+	}
+
+	&_content {
+		font-size: 28rpx;
+		margin-left: 80rpx;
+		box-sizing: border-box;
+		border-radius: 10rpx;
+		line-height: 1.8;
+		color: var(--main-text-color);
+		&.has-bg {
+			background-color: #fafafa;
+			padding: 6rpx 24rpx;
+		}
+		&.not-ml {
+			margin-left: 80rpx;
+		}
+	}
+	&_info {
+		margin-top: 6rpx;
+		display: flex;
+		align-items: center;
+		margin-left: 80rpx;
+	}
+}
+</style>

+ 266 - 0
components/comment-list/comment-list.vue

@@ -0,0 +1,266 @@
+<template>
+	<view class="comment-list">
+		<!-- 顶部区域 -->
+		<view class="comment-list_head">
+			<view class="title">
+				评论列表
+				<text class="count">({{ result.total || 0 }}条)</text>
+			</view>
+			<view class="filter">
+				<text class="filter-item " :class="{ active: sort == 0 }" @click="fnOnSort(0)">默认</text>
+				<text class="filter-item  " :class="{ active: sort == 1 }" @click="fnOnSort(1)">热评</text>
+				<!-- <text class="filter-item">全部</text> -->
+			</view>
+		</view>
+		<!-- <view v-if="disallowComment" class="disallow-comment"><tm-empty icon="icon-shiliangzhinengduixiang-" label="文章已开启禁止评论"></tm-empty></view> -->
+		<!-- 内容区域 -->
+		<view class="comment-list_content">
+			<view v-if="loading != 'success'" class="loading-wrap flex">
+				<view v-if="loading == 'loading'" class="loading flex flex-center flex-col">
+					<text class="e-loading-icon iconfont icon-loading text-blue"></text>
+					<view class="text-size-n text-grey-lighten-1 py-12 mt-12">加载中,请稍等...</view>
+				</view>
+				<view v-else-if="loading == 'error'" class="error">
+					<tm-empty icon="icon-wind-cry" label="加载失败">
+						<tm-button theme="bg-gradient-light-blue-accent" size="m" v-if="!disallowComment" @click="fnToComment()">刷新试试</tm-button>
+					</tm-empty>
+				</view>
+			</view>
+			<block v-else>
+				<view class="empty pt-50" v-if="dataList.length == 0">
+					<tm-empty icon="icon-shiliangzhinengduixiang-" label="暂无评论">
+						<tm-button theme="bg-gradient-light-blue-accent" size="m" v-if="!disallowComment" @click="fnToComment(null)">抢沙发</tm-button>
+					</tm-empty>
+				</view>
+				<block v-else>
+					<!-- 评论内容 : 目前仅支持二级评论 -->
+					<block v-for="(comment, index) in dataList" :key="comment.id">
+						<comment-item
+							:useContentBg="false"
+							:isChild="false"
+							:comment="comment"
+							:postId="postId"
+							@on-copy="fnCopyContent"
+							@on-comment="fnToComment"
+							@on-todo="fnToDo"
+							@on-detail="fnShowCommetnDetail"
+						></comment-item>
+
+						<!-- 二级评论 -->
+						<block v-if="comment.children && comment.children.length != 0">
+							<block v-for="(childComment, childIndex) in comment.children" :key="childComment.id">
+								<comment-item
+									:useContentBg="false"
+									:isChild="true"
+									:comment="childComment"
+									:postId="postId"
+									@on-copy="fnCopyContent"
+									@on-comment="fnToComment"
+									@on-todo="fnToDo"
+									@on-detail="fnShowCommetnDetail"
+								></comment-item>
+							</block>
+						</block>
+					</block>
+					<view v-if="false" class="to-more-comment">
+						<tm-button item-class="btn" :block="true" width="90vw" theme="bg-gradient-light-blue-lighten" size="m">点击查看全部评论</tm-button>
+					</view>
+				</block>
+			</block>
+		</view>
+	</view>
+</template>
+
+<script>
+import tmEmpty from '@/tm-vuetify/components/tm-empty/tm-empty.vue';
+import tmButton from '@/tm-vuetify/components/tm-button/tm-button.vue';
+export default {
+	name: 'comment-list',
+	components: { tmEmpty, tmButton },
+	props: {
+		// 是否禁用评论
+		disallowComment: {
+			type: Boolean,
+			default: false
+		},
+		postId: {
+			type: Number,
+			default: null
+		},
+		post: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	data() {
+		return {
+			loading: 'loading',
+			sort: 0,
+			queryParams: {
+				sort: '',
+				more: true
+			},
+			api: 'getPostCommentTree',
+			result: {},
+			dataList: []
+		};
+	},
+	created() {
+		this.fnGetData();
+		uni.$on('comment_list_refresh', () => {
+			this.fnOnSort(this.sort, true);
+		});
+	},
+	methods: {
+		fnOnSort(type, refresh = false) {
+			if (this.sort == type && refresh == false) return;
+			const _api = ['getPostCommentTree', 'getPostTopCommentList'];
+			this.sort = type;
+			this.api = _api[type];
+			this.fnGetData();
+		},
+		fnGetData() {
+			this.loading = 'loading';
+			this.$httpApi[this.api](this.postId, {})
+				.then(res => {
+					if (res.status == 200) {
+						this.result = res.data;
+						this.dataList = res.data.content;
+						this.loading = 'success';
+					} else {
+						this.loading = 'error';
+					}
+				})
+				.catch(err => {
+					this.loading = 'error';
+				})
+				.finally(() => {
+					uni.hideLoading();
+				});
+		},
+		fnToDo() {
+			uni.$tm.toast('Halo暂未支持!');
+		},
+		fnToComment(data) {
+			if (this.disallowComment) {
+				return uni.$tm.toast('文章已禁止评论!');
+			}
+			console.log(data);
+			let _comment = {};
+			if (data) {
+				let { type, comment } = data;
+				// 来自用户
+				_comment = {
+					id: this.post.id,
+					parentId: comment.id,
+					title: comment.author,
+					from: 'posts',
+					formPage: 'comment_list',
+					type: 'user'
+				};
+			} else {
+				// 来自文章
+				_comment = {
+					id: this.post.id,
+					parentId: 0,
+					title: '评论文章:' + this.post.title,
+					formPage: 'comment_list',
+					from: 'posts',
+					type: 'post'
+				};
+			}
+
+			uni.$tm.vx.commit('comment/setCommentInfo', _comment);
+			this.$Router.push({
+				path: '/pagesA/comment/comment',
+				query: _comment
+			});
+		},
+		fnCopyContent(content) {
+			uni.$tm.u.setClipboardData(content);
+			uni.$tm.toast('内容已复制成功!');
+		},
+
+		fnShowCommetnDetail(comment) {
+			this.$emit('on-comment-detail', {
+				postId: this.postId,
+				comment: comment
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.comment-list {
+	&_head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		position: relative;
+		box-sizing: border-box;
+		padding-left: 24rpx;
+		font-size: 34rpx;
+		font-weight: bold;
+
+		&:before {
+			content: '';
+			position: absolute;
+			left: 0rpx;
+			top: 8rpx;
+			width: 8rpx;
+			height: 30rpx;
+			background-color: rgb(3, 174, 252);
+			border-radius: 6rpx;
+		}
+		.title {
+			.count {
+				font-size: 28rpx;
+				font-weight: normal;
+			}
+		}
+		.filter {
+			font-size: 26rpx;
+			font-weight: normal;
+
+			&-item {
+				margin-left: 20rpx;
+				color: #666;
+				&.active {
+					font-weight: bold;
+					color: rgb(255, 152, 0);
+					font-size: 26rpx;
+				}
+			}
+		}
+	}
+	&_content {
+		margin-top: 24rpx;
+		padding-bottom: 36rpx;
+	}
+}
+.loading-wrap {
+	width: 100%;
+	height: 506rpx;
+	.loading {
+		width: 100%;
+	}
+
+	.e-loading-icon {
+		font-size: 100rpx;
+	}
+}
+
+.to-more-comment {
+	width: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-top: 80rpx;
+	::v-deep {
+		.tm-button .tm-button-btn uni-button {
+			height: 70rpx;
+		}
+	}
+}
+</style>

+ 341 - 0
components/e-swiper/e-swiper.scss

@@ -0,0 +1,341 @@
+// 轮播图
+
+.Swiper-mfw-index-box {
+	display: flex;
+	width: 100%;
+	justify-content: center;
+	align-items: center;
+	view {
+		display: flex;
+	}
+
+	.Swiper-mfw-index {
+		// 轮播图
+		width: 100%;
+		.Swiper-mfw {
+			width: inherit;
+			height: 450rpx;
+			border-radius: 12rpx;
+			.swiper-mfw-item {
+				width: inherit;
+				height: inherit;
+				border-radius: 12rpx;
+				.Image,
+				.ImageVideo {
+					border-radius: 12rpx;
+					width: inherit;
+					height: inherit;
+				}
+			}
+		}
+		// 指示器
+		.Swiper-indicator-box {
+			width: inherit;
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+
+			// Top顶部 [今日首推-盒子]
+			.Top-date-hot {
+				width: 100%;
+				box-sizing: border-box;
+				padding: 20rpx 24rpx;
+				display: flex;
+				align-items: center;
+				flex-wrap: nowrap;
+				.left-date-ri {
+					justify-content: center;
+					.date-ri-text {
+						color: #ffffff;
+						font-size: 60rpx;
+						font-weight: 700;
+						margin-top: -4rpx;
+					}
+				}
+				.conter-date-nianyue {
+					margin: 0 14rpx;
+					display: flex;
+					justify-content: center;
+					align-items: center;
+					flex-direction: row;
+					flex-wrap: nowrap;
+					.left-width-bgcolor {
+						width: 8rpx;
+						height: 45rpx;
+						border-radius: 12rpx;
+						background-color: #fafafa;
+						margin-right: 14rpx;
+					}
+					.right-date-nianyue {
+						flex-direction: column;
+						.Top-yue-usa,
+						.Bottom-nian,
+						.text {
+							color: #ffffff;
+							font-size: 24rpx;
+							font-weight: 700;
+							transform: scale(0.95);
+						}
+						.text {
+							margin-top: -4rpx;
+						}
+					}
+				}
+				.right-hot-ttf {
+					display: flex;
+					flex-direction: column;
+					justify-content: center;
+					margin-left: 6rpx;
+					.hot-text {
+						color: #ffffff;
+						font-size: 52rpx;
+						font-weight: 700;
+					}
+				}
+			}
+		}
+		// 指示器 [轮播信息 -> 标题,用户,头像,所在地]
+		.Swiper-indicator-Top {
+			position: absolute;
+			left: 0rpx;
+			bottom: 110rpx;
+			width: 100%;
+			box-sizing: border-box;
+			padding: 20rpx 24rpx;
+			&.no-dot {
+				bottom: 0;
+			}
+			.Top-item {
+				width: 100%;
+				display: flex;
+				flex-direction: column;
+				// 如果有视频,则显示“视频预览”
+				.Top-ImageVideo {
+					width: 150rpx;
+					box-sizing: border-box;
+					padding: 2rpx 6rpx;
+					margin-bottom: 10rpx;
+					background-color: #f0ad4e;
+					border-radius: 20rpx;
+					flex-direction: row;
+					flex-wrap: nowrap;
+					align-items: center;
+					.Icons {
+						color: #242629;
+						font-size: 26rpx;
+						transform: scale(0.8);
+					}
+
+					.ImageVideo-text {
+						color: #242629;
+						font-size: 24rpx;
+					}
+				}
+
+				// 标题
+				.Top-Title {
+					width: 100%;
+					display: flex;
+					align-items: flex-start;
+					.title-text {
+						width: 100%;
+						color: #ffffff;
+						font-size: 28rpx;
+						font-weight: 700;
+					}
+				}
+				// 用户信息盒子
+				.Bottom-UserInfo {
+					display: flex;
+					flex-direction: row;
+					flex-wrap: nowrap;
+					margin-top: 16rpx;
+					align-items: center;
+					.UserImage-box {
+						width: 40rpx;
+						border-radius: 20rpx;
+						margin-right: 20rpx;
+						.Image {
+							width: 40rpx;
+							height: 40rpx;
+							border-radius: 50%;
+						}
+					}
+					.textbox {
+						flex-direction: row;
+						flex-wrap: nowrap;
+						.wo-text,
+						.UserInfo {
+							font-size: 24rpx;
+						}
+						.wo-text {
+							color: #f1f2f6;
+							margin-right: 8rpx;
+						}
+						.UserInfo {
+							color: #ffffff;
+						}
+					}
+					.jiange-box {
+						margin: 0 8rpx;
+						.jiange-text {
+							color: #f1f2f6;
+						}
+					}
+				}
+			}
+		}
+		// 指示器 [左边图片列表+右边按钮]
+		.Swiper-indicator-Bottom {
+			position: absolute;
+			left: 0;
+			bottom: 0;
+			width: 100%;
+			display: flex;
+			justify-content: space-between;
+			border-radius: 12rpx;
+			flex-direction: row;
+			flex-wrap: nowrap;
+			box-sizing: border-box;
+			padding: 14rpx;
+			background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.4), rgba(101, 101, 101, 0.7));
+			// 左边[图片列表]
+			.Bottom-left-Imagelist {
+				// width: 560rpx;
+				display: flex;
+				flex-direction: row;
+				flex-wrap: nowrap;
+				justify-content: space-between;
+				// 指示图(小图模式)
+				.Bottom-item {
+					width: 98rpx;
+					height: 78rpx;
+					border-radius: 8rpx;
+
+					.Image {
+						width: 98rpx;
+						height: 78rpx;
+						border-radius: 8rpx;
+						box-sizing: border-box;
+					}
+					&.current {
+						.Image {
+							// border: 4rpx solid #ffda02;
+							border: 4rpx solid rgb(110, 186, 247);
+						}
+					}
+				}
+				.Bottom-item + .Bottom-item {
+					margin-left: 10rpx;
+				}
+			}
+			// 右边 [历历在目-按钮]
+			.Bottom-right-lili-btn {
+				width: 145rpx;
+				align-items: center;
+				justify-content: center;
+				border-radius: 10rpx;
+				// background-image: linear-gradient(45deg, rgb(110, 186, 247), rgb(13, 141, 242));
+				.Bottom-item {
+					width: 145rpx;
+					display: flex;
+					flex-direction: column;
+					align-items: center;
+					justify-content: center;
+					color: #fff;
+					.indicator-text {
+						font-size: 24rpx;
+						font-weight: 700;
+					}
+					.more {
+						display: flex;
+						align-items: center;
+						font-weight: bold;
+						font-size: 26rpx;
+						.iconfont {
+							color: #fff;
+							font-size: 24rpx;
+						}
+					}
+					.text {
+						display: flex;
+						flex-direction: column;
+						align-items: center;
+						// font-weight: normal;
+						letter-spacing: 4rpx;
+					}
+				}
+			}
+		}
+	}
+	.Swiper-box {
+		border-radius: 12rpx;
+		overflow: hidden !important;
+		transform: translateY(0) !important;
+		transform: translateX(0) !important;
+		&.right {
+			.indicator-Top-box {
+				position: absolute;
+			}
+			.Swiper-indicator-Top {
+				bottom: 0;
+				.Top-item .Top-Title .title-text {
+					width: 540rpx;
+				}
+			}
+			.Swiper-indicator-Bottom {
+				width: 120rpx;
+				position: absolute;
+				top: 0rpx;
+				left: initial;
+				right: 0rpx;
+				flex-direction: column;
+				background-image: none;
+				background-color: rgba(0, 0, 0, 0.1);
+				// border-radius: 0rpx 12rpx 12rpx 0;
+				border-radius: 12rpx;
+				.Bottom-left-Imagelist {
+					flex-direction: column;
+					align-items: center;
+					.Bottom-item {
+						border-radius: 6rpx;
+					}
+					.Bottom-item + .Bottom-item {
+						margin-left: 0rpx;
+						margin-top: 10rpx;
+					}
+				}
+				.Bottom-right-lili-btn {
+					width: 100rpx;
+					margin-left: -4rpx;
+					.Bottom-item {
+						width: initial;
+						margin-top: 6rpx;
+						align-items: flex-start;
+						justify-content: flex-start;
+
+						.more {
+							font-size: 24rpx;
+						}
+						.text {
+							letter-spacing: 0;
+							font-size: 24rpx;
+							font-weight: normal;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+.Swiper-mfw {
+	position: relative;
+}
+.indicator-Top-box {
+	position: absolute;
+}
+.indicator-Btoom-box {
+	position: absolute;
+	bottom: 0;
+}

+ 233 - 0
components/e-swiper/e-swiper.vue

@@ -0,0 +1,233 @@
+<template>
+	<!-- 轮播图 -->
+	<view class="Swiper-mfw-index-box">
+		<view class="Swiper-mfw-index Swiper-box" :class="[dotPosition]">
+			<swiper
+				class="Swiper-mfw"
+				:style="{ height: height }"
+				:circular="true"
+				:indicator-dots="false"
+				:autoplay="autoplay"
+				:interval="3000"
+				:duration="1000"
+				:current="currentIndex"
+				:disable-touch="disable_touch"
+				@change="change"
+			>
+				<!-- 只需要前5条数据 -->
+				<swiper-item class="swiper-mfw-item" v-if="index <= (dotPosition == 'right' ? 3 : 4)" v-for="(item, index) in list" :key="index">
+					<!-- /*
+					 1. 这里不需要用api控制暂停视频
+					 2. 因为video标签上加了v-if="current==index"
+					 3. 当current == index时才会创建视频组件
+					 4. 否current != index则就销毁视频
+					 */ -->
+					<!-- 如果有视频,则显示视频-->
+					<template v-if="item.mp4 && current == index">
+						<video
+							class="ImageVideo"
+							:id="'ImageVideo' + index"
+							:ref="'ImageVideo' + index"
+							:src="item.mp4"
+							:loop="true"
+							:muted="false"
+							:autoplay="current == index ? true : false"
+							:controls="false"
+							:show-fullscreen-btn="false"
+							:show-play-btn="false"
+							:enable-progress-gesture="false"
+							:play-strategy="0"
+							:poster="item.image || item.src"
+						></video>
+					</template>
+					<!-- 否则显示图片 -->
+					<image v-else :src="item.image || item.src" class="Image" mode="aspectFill" @click.stop="$emit('on-click', item)"></image>
+				</swiper-item>
+			</swiper>
+			<!-- 指示器 [Top] -->
+			<view v-if="useTop" class="Swiper-indicator-box indicator-Top-box">
+				<!-- Top顶部 [今日首推-盒子] -->
+				<view class="Top-date-hot">
+					<view class="left-date-ri">
+						<text class="date-ri-text text">{{ date.month }}</text>
+					</view>
+					<view class="conter-date-nianyue">
+						<view class="left-width-bgcolor"></view>
+						<view class="right-date-nianyue">
+							<text class="Top-yue-usa text">{{ date.monthEn }}</text>
+							<text class="Bottom-nian text">{{ date.year }}</text>
+						</view>
+					</view>
+					<view class="right-hot-ttf">
+						<text class="text hot-text">{{ title }}</text>
+					</view>
+				</view>
+			</view>
+
+			<!-- 指示器 标题区域 -->
+			<view v-if="useTitle" class="Swiper-indicator-Top" :class="{ 'no-dot': !useDot }">
+				<block v-for="(item, index) in list" :key="index">
+					<view v-if="currentIndex == index" class="Top-item" :class="current == index ? 'current' : 'no'">
+						<!-- 如果存在视频,则显示“视频预览”提示 -->
+						<view v-if="item.mp4" class="Top-ImageVideo">
+							<!-- icon图标 -->
+							<view class="Icons">
+								<!-- 播放按钮图标 -->
+								<text class="iconfont icon-caret-right"></text>
+							</view>
+							<text class="text ImageVideo-text app-ttf">视频预览</text>
+						</view>
+						<!-- 标题 -->
+						<view class="Top-Title">
+							<text class="text title-text">{{ item.title }}</text>
+						</view>
+						<!-- 用户信息 -->
+						<view class="Bottom-UserInfo">
+							<!-- 头像 -->
+							<view class="UserImage-box"><image :src="item.avatar" class="Image" mode="aspectFill"></image></view>
+							<!-- 用户名 -->
+							<view class="textbox UserName-box">
+								<text class="text UserInfo">{{ item.nickname }}</text>
+							</view>
+							<view v-if="item.createTime" class="jiange-box"><text class="text jiange-text"></text></view>
+							<view v-if="item.createTime" class="textbox UserGPS-box">
+								<text class="text UserInfo">发布于 {{ item.createTime }}</text>
+							</view>
+							<view v-if="item.address" class="jiange-box"><text class="text jiange-text">·</text></view>
+							<view v-if="item.address" class="textbox UserGPS-box">
+								<text class="text UserInfo">{{ item.address }}</text>
+							</view>
+						</view>
+					</view>
+				</block>
+			</view>
+			<!-- 指示器 [左边图片列表+右边按钮] -->
+			<view v-if="useDot" class="Swiper-indicator-Bottom">
+				<!-- 左边 -->
+				<view class="Bottom-left-Imagelist">
+					<block v-for="(item, index) in list" :key="index">
+						<view
+							class="Bottom-item"
+							v-if="Number(index) <= (dotPosition == 'right' ? 3 : 4)"
+							:class="currentIndex == index ? 'current' : 'no'"
+							@click="SwiperIndTap(index)"
+						>
+							<image :src="item.image || item.src" class="Image" mode="aspectFill"></image>
+						</view>
+					</block>
+				</view>
+				<!-- 右边 -->
+				<view class="Bottom-right-lili-btn">
+					<view class="Bottom-item is-more">
+						<view class="more" @click.stop="$emit('on-more')">
+							MORE
+							<text class="iconfont icon-caret-right"></text>
+						</view>
+						<text class="left text indicator-text">更多推荐</text>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'e-swiper',
+	props: {
+		title: {
+			type: String,
+			default: ''
+		},
+		height: {
+			type: String,
+			default: '450rpx'
+		},
+		dotPosition: {
+			type: String,
+			default: 'bottom'
+		},
+		useTop: {
+			type: Boolean,
+			default: true
+		},
+		useDot: {
+			type: Boolean,
+			default: true
+		},
+		useTitle: {
+			type: Boolean,
+			default: true
+		},
+		useUser: {
+			type: Boolean,
+			default: true
+		},
+		// 轮播图 数据列表
+		list: {
+			type: Array,
+			default: () => []
+		},
+		// 当前选中的项(指示器坐标位置)
+		current: {
+			type: Number,
+			default: 0
+		},
+		// 是否自动轮播
+		autoplay: {
+			type: Boolean,
+			default: false
+		}
+	},
+	data() {
+		return {
+			// 是否禁止用户 touch 操作
+			currentIndex: 0,
+			disable_touch: false, //touch 用户划动引起swiper变化。
+			date: {
+				year: '-',
+				monthEn: '-',
+				month: '-'
+			}
+		};
+	},
+	created() {
+		this.currentIndex = this.current;
+		const date = new Date();
+		//将月份名称存储在数组中
+		const monthArray = new Array('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec');
+
+		this.date.year = date.getFullYear();
+		let month = date.getMonth() + 1;
+		this.date.month = month < 10 ? '0' + month : month;
+		this.date.monthEn = monthArray[date.getMonth()].toUpperCase();
+	},
+	methods: {
+		// current 改变时会触发 change 事件,event.detail = {current: current, source: source}
+		change(e) {
+			let { current, source } = e.detail;
+			//只有页面自动切换,手动切换时才轮播,其他不允许
+			if (source === 'autoplay' || source === 'touch') {
+				let event = {
+					current: current
+				};
+				this.currentIndex = current;
+				this.$emit('change', event);
+			}
+		},
+		// 手动点击了指示器[小图模式]
+		SwiperIndTap(e) {
+			let index = e;
+			let event = {
+				current: index
+			};
+			this.currentIndex = index;
+			this.$emit('change', event);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import './e-swiper.scss';
+</style>

+ 177 - 0
components/journal-card/journal-card.vue

@@ -0,0 +1,177 @@
+<template>
+	<view class="journal-card mb-24  round-3 bg-white ">
+		<view class="head pa-24 pb-0 flex flex-between">
+			<view class="left flex">
+				<cache-image
+					class="avatar rounded"
+					radius="50%"
+					width="70rpx"
+					height="70rpx"
+					:url="bloggerInfo.avatar"
+					:fileMd5="bloggerInfo.avatar"
+					mode="scaleToFill"
+				></cache-image>
+				<view class="info pl-16 flex flex-col">
+					<view class="nickname text-weight-b text-grey-darken-4">{{ bloggerInfo.nickname }}</view>
+					<view class="mt-3 time text-size-m ">{{ $tm.dayjs(journal.createTime).format('YYYY-MM-DD HH:mm:ss') }}</view>
+				</view>
+			</view>
+			<view class="right">
+				<tm-button v-if="useLike" :shadow="0" theme="light-blue" size="s" @click="fnLike(journal)">点赞({{ journal.likes }})</tm-button>
+				<tm-button v-if="useEdit" :shadow="0" theme="light-blue" size="s" @click="$emit('on-edit', journal)">编辑</tm-button>
+				<tm-button v-if="useDel" :shadow="0" theme="red" size="s" @click="fnDel(journal)">删除</tm-button>
+			</view>
+		</view>
+		<tm-more v-if="journal.content.length > 50" :maxHeight="100" label="查看全部内容" open-label="隐藏部分内容">
+			<mp-html
+				class="evan-markdown"
+				lazy-load
+				:domain="markdownConfig.domain"
+				:loading-img="markdownConfig.loadingGif"
+				:scroll-table="true"
+				:selectable="true"
+				:tag-style="markdownConfig.tagStyle"
+				:container-style="markdownConfig.containStyle"
+				:content="journal.content"
+				:markdown="true"
+				:showLineNumber="true"
+				:showLanguageName="true"
+				:copyByLongPress="true"
+			/>
+		</tm-more>
+		<mp-html
+			v-else
+			class="evan-markdown"
+			lazy-load
+			:domain="markdownConfig.domain"
+			:loading-img="markdownConfig.loadingGif"
+			:scroll-table="true"
+			:selectable="true"
+			:tag-style="markdownConfig.tagStyle"
+			:container-style="markdownConfig.containStyle"
+			:content="journal.content"
+			:markdown="true"
+			:showLineNumber="true"
+			:showLanguageName="true"
+			:copyByLongPress="true"
+		/>
+	</view>
+</template>
+
+<script>
+import MarkdownConfig from '@/common/markdown/markdown.config.js';
+import mpHtml from '@/components/mp-html/components/mp-html/mp-html.vue';
+import tmButton from '@/tm-vuetify/components/tm-button/tm-button.vue';
+import tmMore from '@/tm-vuetify/components/tm-more/tm-more.vue';
+export default {
+	name: 'journal-card',
+	components: { mpHtml, tmButton, tmMore },
+	props: {
+		isAdmin: {
+			type: Boolean,
+			default: false
+		},
+		journal: {
+			type: Object,
+			default: () => {}
+		},
+		useLike: {
+			type: Boolean,
+			default: false
+		},
+		useEdit: {
+			type: Boolean,
+			default: false
+		},
+		useDel: {
+			type: Boolean,
+			default: false
+		}
+	},
+	data() {
+		return {
+			markdownConfig: MarkdownConfig
+		};
+	},
+	computed: {
+		// 获取博主信息
+		bloggerInfo() {
+			let blogger = this.$tm.vx.getters().blogger.getBlogger;
+			blogger.avatar = this.$utils.checkAvatarUrl(blogger.avatar, true);
+			return blogger;
+		}
+	},
+	methods: {
+		fnLike(journal) {
+			uni.showLoading({
+				mask: true,
+				title: '正在点赞中...'
+			});
+			this.$httpApi
+				.postJournalLikes(journal.id)
+				.then(res => {
+					if (res.status == 200) {
+						journal.likes += 1;
+						uni.$tm.toast('o( ̄▽ ̄)d点赞成功!');
+					} else {
+						uni.$tm.toast('Ծ‸Ծ点赞失败了~');
+					}
+				})
+				.catch(err => {
+					uni.$tm.toast('Ծ‸Ծ点赞失败了~');
+				});
+		},
+
+		fnDel(journal) {
+			uni.$eShowModal({
+				title: '提示',
+				content: '您确定要删除该日记吗?',
+				showCancel: true,
+				cancelText: '否',
+				cancelColor: '#999999',
+				confirmText: '是',
+				confirmColor: '#03a9f4'
+			})
+				.then(res => {
+					this.$httpApi.admin
+						.deleteJournalsById(journal.id)
+						.then(res => {
+							if (res.status == 200) {
+								this.$emit('on-del', journal);
+								uni.$tm.toast('删除成功!');
+							} else {
+								uni.$tm.toast('Ծ‸Ծ删除失败~');
+							}
+						})
+						.catch(err => {
+							uni.$tm.toast('Ծ‸Ծ删除失败~');
+						});
+				})
+				.catch(() => {});
+		}
+	}
+};
+</script>
+
+<style scoped lang="scss">
+.journal-card {
+	box-sizing: border-box;
+	box-shadow: 0rpx 2rpx 24rpx rgba(0, 0, 0, 0.05);
+	overflow: hidden;
+	.avatar {
+		width: 70rpx;
+		height: 70rpx;
+		border: 6rpx solid #fff;
+		box-shadow: 0rpx 0rpx 24rpx rgba(0, 0, 0, 0.05);
+	}
+	.info {
+		justify-content: center;
+		.nickname {
+			font-size: 30rpx;
+		}
+		.time {
+			font-size: 26rpx;
+		}
+	}
+}
+</style>

+ 250 - 0
components/lff-barrage/lff-barrage.vue

@@ -0,0 +1,250 @@
+<template>
+	<view style="overflow: hidden;position: fixed;width: 100%;height: 100%;pointer-events: none; top: 0;">
+		<view class="danmu-li" v-for="(item, index) in listData" :class="item.type" :style="item.style" :key="index">
+			<view class="danmu-inner">
+				<view class="user-box">
+					<view class="user-img">
+						<view class="img-box">
+							<image :src="item.avatar || 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=317894666,3379114684&fm=26&gp=0.jpg'"></image>
+						</view>
+					</view>
+					<view class="user-text cl1">{{ item.nickName }}</view>
+					<view class="user-status cl1">{{ item.text }}</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+<script>
+export default {
+	props: {
+		//rightToLeft leftToRight leftBottom
+		type: {
+			type: String,
+			default: 'rightToLeft'
+		},
+		minTime: {
+			type: Number,
+			default: 4
+		},
+		maxTime: {
+			type: Number,
+			default: 9
+		},
+		minTop: {
+			type: Number,
+			default: 0
+		},
+		maxTop: {
+			type: Number,
+			default: 240
+		},
+		hrackH: {
+			//轨道高度
+			type: Number,
+			default: 40
+		},
+		noStacked: {
+			//不允许堆叠(暂不可用)
+			type: Array,
+			default() {
+				return [];
+			}
+		}
+	},
+	data() {
+		return {
+			listData: []
+		};
+	},
+	mounted() {
+		this.hrackNum = Math.floor((this.maxTop - this.minTop) / this.hrackH);
+	},
+	methods: {
+		add(obj) {
+			let data = {
+				item: obj.item,
+				id: Date.parse(new Date()),
+				time: Math.ceil(Math.floor(Math.random() * (this.maxTime - this.minTime + 1) + this.minTime)),
+				type: this.type
+			};
+			if (this.type === 'leftBottom') {
+				let objData = {
+					item: data.item,
+					type: 'leftBottomEnter',
+					style: {
+						transition: `all 0.5s`,
+						animationDuration: `0.5s`,
+						transform: `translateX(0%)`,
+						bottom: `${this.minTop}px`
+					}
+				};
+				let listLen = this.listData.length;
+				let hrackNum = this.hrackNum;
+				for (let i in this.listData) {
+					if (this.listData[i].status === 'reuse') {
+						//重用
+						this.$set(this.listData, i, objData);
+					} else if (this.listData[i].status === 'reset') {
+						//重置
+						this.listData[i].style.transition = 'none';
+						this.listData[i].style.bottom = 0;
+						this.listData[i].status = 'reuse';
+					} else if (this.listData[i].status === 'recycle') {
+						//回收
+						this.listData[i].type = 'leftBottomExit';
+						this.listData[i].status = 'reset';
+					} else {
+						this.listData[i].style.bottom = parseInt(this.listData[i].style.bottom) + this.hrackH + 'px';
+					}
+					if (parseInt(this.listData[i].style.bottom) >= this.maxTop - this.hrackH && this.listData[i].status !== 'reset') {
+						//需要回收
+						this.listData[i].status = 'recycle';
+					}
+				}
+				if (listLen < hrackNum + 2) {
+					this.listData.push(objData);
+				}
+			} else if (this.type === 'rightToLeft' || this.type === 'leftToRight') {
+				let objData = this.horStacked(data);
+				for (let i in this.listData) {
+					if (this.listData[i].delTime <= Date.parse(new Date())) {
+						this.repaint(i, objData.type);
+						objData.type = '';
+						this.$set(this.listData, i, objData);
+						return;
+					}
+				}
+				this.listData.push(objData);
+			}
+		},
+		horStacked(data) {
+			return {
+				item: data.item,
+				type: this.type,
+				style: {
+					animationDuration: `${data.time}s`,
+					top: `${Math.ceil(Math.random() * (this.maxTop - this.minTop + 1) + this.minTop)}px`
+				},
+				delTime: Date.parse(new Date()) + data.time * 1200
+			};
+		},
+		repaint(index, type) {
+			setTimeout(() => {
+				this.listData[index].type = type;
+			}, 100);
+		}
+	}
+};
+</script>
+<style></style>
+<style lang="scss">
+@keyframes leftBottomEnter {
+	0% {
+		transform: translateY(100%);
+		opacity: 0;
+	}
+
+	100% {
+		transform: translateY(0%);
+		opacity: 1;
+	}
+}
+
+@keyframes leftBottomExit {
+	0% {
+		transform: translateY(0%);
+		opacity: 1;
+	}
+
+	100% {
+		transform: translateY(-200%);
+		opacity: 0;
+	}
+}
+
+@keyframes leftToRight {
+	0% {
+		transform: translateX(-100%);
+	}
+
+	100% {
+		transform: translateX(100%);
+	}
+}
+
+@keyframes rightToLeft {
+	0% {
+		transform: translateX(100%);
+	}
+
+	100% {
+		transform: translateX(-100%);
+	}
+}
+
+.danmu-li {
+	position: absolute;
+	width: 100%;
+	transform: translateX(100%);
+	animation-timing-function: linear;
+
+	&.leftBottomEnter {
+		animation-name: leftBottomEnter;
+	}
+	&.leftBottomExit {
+		animation-name: leftBottomExit;
+		animation-fill-mode: forwards;
+	}
+
+	&.rightToLeft {
+		animation-name: rightToLeft;
+	}
+
+	&.leftToRight {
+		animation-name: leftToRight;
+	}
+
+	.danmu-inner {
+		display: inline-block;
+
+		.user-box {
+			display: flex;
+			padding: 3rpx 40rpx 3rpx 10rpx;
+			background: rgba(0, 0, 0, 0.3);
+			border-radius: 32rpx;
+			align-items: center;
+
+			.user-img {
+				.img-box {
+					display: flex;
+
+					image {
+						width: 58rpx;
+						height: 58rpx;
+						background: rgba(55, 55, 55, 1);
+						border-radius: 50%;
+					}
+				}
+			}
+
+			.user-status {
+				margin-left: 10rpx;
+				white-space: nowrap;
+				font-size: 28rpx;
+				font-weight: 400;
+				color: rgba(255, 255, 255, 1);
+			}
+
+			.user-text {
+				margin-left: 10rpx;
+				// white-space: nowrap;
+				font-size: 28rpx;
+				font-weight: 400;
+				width: 80rpx;
+				color: rgba(255, 255, 255, 1);
+			}
+		}
+	}
+}
+</style>

+ 15 - 0
components/lff-barrage/markdown.md

@@ -0,0 +1,15 @@
+#如何使用
+###js
+```javascript
+import lffBarrage from '@/components/lff-barrage/lff-barrage.vue'
+components:{lffBarrage},
+methods:{
+	colrdo(){ //插入一条弹幕
+		this.$refs.lffBarrage.add({item:'你好呀小伙子'});
+	}
+}
+```
+###HTML
+```html
+<lff-barrage ref="lffBarrage"></lff-barrage>
+```

+ 11 - 0
components/mp-html/components/mp-html/editable/config.js

@@ -0,0 +1,11 @@
+// 以下项目可以删减或更换顺序,但不能添加或更改名字
+export default {
+  // 普通标签的菜单项
+  node: ['大小', '斜体', '粗体', '下划线', '居中', '缩进', '上移', '下移', '删除'],
+  // 图片的菜单项
+  img: ['换图', '宽度', '超链接', '预览图', '禁用预览', '上移', '下移', '删除'],
+  // 链接的菜单项
+  link: ['更换链接', '上移', '下移', '删除'],
+  // 音视频的菜单项
+  media: ['封面', '循环', '自动播放', '上移', '下移', '删除']
+}

+ 532 - 0
components/mp-html/components/mp-html/editable/index.js

@@ -0,0 +1,532 @@
+/**
+ * @fileoverview editable 插件
+ */
+import config from './config'
+import Parser from '../parser'
+
+function Editable (vm) {
+  this.vm = vm
+  this.editHistory = [] // 历史记录
+  this.editI = -1 // 历史记录指针
+  vm._mask = [] // 蒙版被点击时进行的操作
+
+  vm._setData = function (path, val) {
+    const paths = path.split('.')
+    let target = vm
+    for (let i = 0; i < paths.length - 1; i++) {
+      target = target[paths[i]]
+    }
+    vm.$set(target, paths.pop(), val)
+  }
+
+  /**
+   * @description 移动历史记录指针
+   * @param {Number} num 移动距离
+   */
+  const move = num => {
+    setTimeout(() => {
+      const item = this.editHistory[this.editI + num]
+      if (item) {
+        this.editI += num
+        vm._setData(item.key, item.value)
+      }
+    }, 200)
+  }
+  vm.undo = () => move(-1) // 撤销
+  vm.redo = () => move(1) // 重做
+
+  /**
+   * @description 更新记录
+   * @param {String} path 更新内容路径
+   * @param {*} oldVal 旧值
+   * @param {*} newVal 新值
+   * @param {Boolean} set 是否更新到视图
+   * @private
+   */
+  vm._editVal = (path, oldVal, newVal, set) => {
+    // 当前指针后的内容去除
+    while (this.editI < this.editHistory.length - 1) {
+      this.editHistory.pop()
+    }
+
+    // 最多存储 30 条操作记录
+    while (this.editHistory.length > 30) {
+      this.editHistory.pop()
+      this.editI--
+    }
+
+    const last = this.editHistory[this.editHistory.length - 1]
+    if (!last || last.key !== path) {
+      if (last) {
+        // 去掉上一次的新值
+        this.editHistory.pop()
+        this.editI--
+      }
+      // 存入这一次的旧值
+      this.editHistory.push({
+        key: path,
+        value: oldVal
+      })
+      this.editI++
+    }
+
+    // 存入本次的新值
+    this.editHistory.push({
+      key: path,
+      value: newVal
+    })
+    this.editI++
+
+    // 更新到视图
+    if (set) {
+      vm._setData(path, newVal)
+    }
+  }
+
+  /**
+   * @description 获取菜单项
+   * @private
+   */
+  vm._getItem = function (node, up, down) {
+    let items
+    let i
+    if (node.name === 'img') {
+      items = config.img.slice(0)
+      if (!vm.getSrc) {
+        i = items.indexOf('换图')
+        if (i !== -1) {
+          items.splice(i, 1)
+        }
+        i = items.indexOf('超链接')
+        if (i !== -1) {
+          items.splice(i, 1)
+        }
+        i = items.indexOf('预览图')
+        if (i !== -1) {
+          items.splice(i, 1)
+        }
+      }
+      i = items.indexOf('禁用预览')
+      if (i !== -1 && node.attrs.ignore) {
+        items[i] = '启用预览'
+      }
+    } else if (node.name === 'a') {
+      items = config.link.slice(0)
+      if (!vm.getSrc) {
+        i = items.indexOf('更换链接')
+        if (i !== -1) {
+          items.splice(i, 1)
+        }
+      }
+    } else if (node.name === 'video' || node.name === 'audio') {
+      items = config.media.slice(0)
+      i = items.indexOf('封面')
+      if (!vm.getSrc && i !== -1) {
+        items.splice(i, 1)
+      }
+      i = items.indexOf('循环')
+      if (node.attrs.loop && i !== -1) {
+        items[i] = '不循环'
+      }
+      i = items.indexOf('自动播放')
+      if (node.attrs.autoplay && i !== -1) {
+        items[i] = '不自动播放'
+      }
+    } else {
+      items = config.node.slice(0)
+    }
+    if (!up) {
+      i = items.indexOf('上移')
+      if (i !== -1) {
+        items.splice(i, 1)
+      }
+    }
+    if (!down) {
+      i = items.indexOf('下移')
+      if (i !== -1) {
+        items.splice(i, 1)
+      }
+    }
+    return items
+  }
+
+  /**
+   * @description 显示 tooltip
+   * @param {object} obj
+   * @private
+   */
+  vm._tooltip = function (obj) {
+    vm.$set(vm, 'tooltip', {
+      top: obj.top,
+      items: obj.items
+    })
+    vm._tooltipcb = obj.success
+  }
+
+  /**
+   * @description 显示滚动条
+   * @param {object} obj
+   * @private
+   */
+  vm._slider = function (obj) {
+    vm.$set(vm, 'slider', {
+      min: obj.min,
+      max: obj.max,
+      value: obj.value,
+      top: obj.top
+    })
+    vm._slideringcb = obj.changing
+    vm._slidercb = obj.change
+  }
+
+  /**
+   * @description 点击蒙版
+   * @private
+   */
+  vm._maskTap = function () {
+    // 隐藏所有悬浮窗
+    while (vm._mask.length) {
+      (vm._mask.pop())()
+    }
+    if (vm.tooltip) {
+      vm.$set(vm, 'tooltip', null)
+    }
+    if (vm.slider) {
+      vm.$set(vm, 'slider', null)
+    }
+  }
+
+  /**
+   * @description 插入节点
+   * @param {Object} node
+   */
+  function insert (node) {
+    if (vm._edit) {
+      vm._edit.insert(node)
+    } else {
+      const nodes = vm.nodes.slice(0)
+      nodes.push(node)
+      vm._editVal('nodes', vm.nodes, nodes, true)
+    }
+  }
+
+  /**
+   * @description 在光标处插入指定 html 内容
+   * @param {String} html 内容
+   */
+  vm.insertHtml = html => {
+    this.inserting = true
+    const arr = new Parser(vm).parse(html)
+    this.inserting = undefined
+    for (let i = 0; i < arr.length; i++) {
+      insert(arr[i])
+    }
+  }
+
+  /**
+   * @description 在光标处插入图片
+   */
+  vm.insertImg = function () {
+    vm.getSrc && vm.getSrc('img').then(src => {
+      if (typeof src === 'string') {
+        src = [src]
+      }
+      const parser = new Parser(vm)
+      for (let i = 0; i < src.length; i++) {
+        insert({
+          name: 'img',
+          attrs: {
+            src: parser.getUrl(src[i])
+          }
+        })
+      }
+    }).catch(() => { })
+  }
+
+  /**
+   * @description 在光标处插入一个链接
+   */
+  vm.insertLink = function () {
+    vm.getSrc && vm.getSrc('link').then(url => {
+      insert({
+        name: 'a',
+        attrs: {
+          href: url
+        },
+        children: [{
+          type: 'text',
+          text: url
+        }]
+      })
+    }).catch(() => { })
+  }
+
+  /**
+   * @description 在光标处插入一个表格
+   * @param {Number} rows 行数
+   * @param {Number} cols 列数
+   */
+  vm.insertTable = function (rows, cols) {
+    const table = {
+      name: 'table',
+      attrs: {
+        style: 'display:table;width:100%;margin:10px 0;text-align:center;border-spacing:0;border-collapse:collapse;border:1px solid gray'
+      },
+      children: []
+    }
+    for (let i = 0; i < rows; i++) {
+      const tr = {
+        name: 'tr',
+        attrs: {},
+        children: []
+      }
+      for (let j = 0; j < cols; j++) {
+        tr.children.push({
+          name: 'td',
+          attrs: {
+            style: 'padding:2px;border:1px solid gray'
+          },
+          children: [{
+            type: 'text',
+            text: ''
+          }]
+        })
+      }
+      table.children.push(tr)
+    }
+    insert(table)
+  }
+
+  /**
+   * @description 插入视频/音频
+   * @param {Object} node
+   */
+  function insertMedia (node) {
+    if (typeof node.src === 'string') {
+      node.src = [node.src]
+    }
+    const parser = new Parser(vm)
+    // 拼接主域名
+    for (let i = 0; i < node.src.length; i++) {
+      node.src[i] = parser.getUrl(node.src[i])
+    }
+    insert({
+      name: 'div',
+      attrs: {
+        style: 'text-align:center'
+      },
+      children: [node]
+    })
+  }
+
+  /**
+   * @description 在光标处插入一个视频
+   */
+  vm.insertVideo = function () {
+    vm.getSrc && vm.getSrc('video').then(src => {
+      insertMedia({
+        name: 'video',
+        attrs: {
+          controls: 'T'
+        },
+        children: [],
+        src,
+        // #ifdef APP-PLUS
+        html: `<video src="${src}" style="width:100%;height:100%"></video>`
+        // #endif
+      })
+    }).catch(() => { })
+  }
+
+  /**
+   * @description 在光标处插入一个音频
+   */
+  vm.insertAudio = function () {
+    vm.getSrc && vm.getSrc('audio').then(attrs => {
+      let src
+      if (attrs.src) {
+        src = attrs.src
+        attrs.src = undefined
+      } else {
+        src = attrs
+        attrs = {}
+      }
+      attrs.controls = 'T'
+      insertMedia({
+        name: 'audio',
+        attrs,
+        children: [],
+        src
+      })
+    }).catch(() => { })
+  }
+
+  /**
+   * @description 在光标处插入一段文本
+   */
+  vm.insertText = function () {
+    insert({
+      name: 'p',
+      attrs: {},
+      children: [{
+        type: 'text',
+        text: ''
+      }]
+    })
+  }
+
+  /**
+   * @description 清空内容
+   */
+  vm.clear = function () {
+    vm._maskTap()
+    vm._edit = undefined
+    vm.$set(vm, 'nodes', [{
+      name: 'p',
+      attrs: {},
+      children: [{
+        type: 'text',
+        text: ''
+      }]
+    }])
+  }
+
+  /**
+   * @description 获取编辑后的 html
+   */
+  vm.getContent = function () {
+    let html = '';
+    // 递归遍历获取
+    (function traversal (nodes, table) {
+      for (let i = 0; i < nodes.length; i++) {
+        let item = nodes[i]
+        if (item.type === 'text') {
+          html += item.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>').replace(/\xa0/g, '&nbsp;') // 编码实体
+        } else {
+          if (item.name === 'img') {
+            item.attrs.i = ''
+            // 还原被转换的 svg
+            if ((item.attrs.src || '').includes('data:image/svg+xml;utf8,')) {
+              html += item.attrs.src.substr(24).replace(/%23/g, '#').replace('<svg', '<svg style="' + (item.attrs.style || '') + '"')
+              continue
+            }
+          } else if (item.name === 'video' || item.name === 'audio') {
+            // 还原 video 和 audio 的 source
+            item = JSON.parse(JSON.stringify(item))
+            if (item.src.length > 1) {
+              item.children = []
+              for (let j = 0; j < item.src.length; j++) {
+                item.children.push({
+                  name: 'source',
+                  attrs: {
+                    src: item.src[j]
+                  }
+                })
+              }
+            } else {
+              item.attrs.src = item.src[0]
+            }
+          } else if (item.name === 'div' && (item.attrs.style || '').includes('overflow:auto') && (item.children[0] || {}).name === 'table') {
+            // 还原滚动层
+            item = item.children[0]
+          }
+          // 还原 table
+          if (item.name === 'table') {
+            item = JSON.parse(JSON.stringify(item))
+            table = item.attrs
+            if ((item.attrs.style || '').includes('display:grid')) {
+              item.attrs.style = item.attrs.style.split('display:grid')[0]
+              const children = [{
+                name: 'tr',
+                attrs: {},
+                children: []
+              }]
+              for (let j = 0; j < item.children.length; j++) {
+                item.children[j].attrs.style = item.children[j].attrs.style.replace(/grid-[^;]+;*/g, '')
+                if (item.children[j].r !== children.length) {
+                  children.push({
+                    name: 'tr',
+                    attrs: {},
+                    children: [item.children[j]]
+                  })
+                } else {
+                  children[children.length - 1].children.push(item.children[j])
+                }
+              }
+              item.children = children
+            }
+          }
+          html += '<' + item.name
+          for (const attr in item.attrs) {
+            let val = item.attrs[attr]
+            if (!val) continue
+            if (val === 'T' || val === true) {
+              // bool 型省略值
+              html += ' ' + attr
+              continue
+            } else if (item.name[0] === 't' && attr === 'style' && table) {
+              // 取消为了显示 table 添加的 style
+              val = val.replace(/;*display:table[^;]*/, '')
+              if (table.border) {
+                val = val.replace(/border[^;]+;*/g, $ => $.includes('collapse') ? $ : '')
+              }
+              if (table.cellpadding) {
+                val = val.replace(/padding[^;]+;*/g, '')
+              }
+              if (!val) continue
+            }
+            html += ' ' + attr + '="' + val.replace(/"/g, '&quot;') + '"'
+          }
+          html += '>'
+          if (item.children) {
+            traversal(item.children, table)
+            html += '</' + item.name + '>'
+          }
+        }
+      }
+    })(vm.nodes)
+
+    // 其他插件处理
+    for (let i = vm.plugins.length; i--;) {
+      if (vm.plugins[i].onGetContent) {
+        html = vm.plugins[i].onGetContent(html) || html
+      }
+    }
+
+    return html
+  }
+}
+
+Editable.prototype.onUpdate = function (content, config) {
+  if (this.vm.editable) {
+    this.vm._maskTap()
+    config.entities.amp = '&'
+    if (!this.inserting) {
+      this.vm._edit = undefined
+      if (!content) {
+        setTimeout(() => {
+          this.vm.$set(this.vm, 'nodes', [{
+            name: 'p',
+            attrs: {},
+            children: [{
+              type: 'text',
+              text: ''
+            }]
+          }])
+        }, 0)
+      }
+    }
+  }
+}
+
+Editable.prototype.onParse = function (node) {
+  // 空白单元格可编辑
+  if (this.vm.editable && (node.name === 'td' || node.name === 'th') && !this.vm.getText(node.children)) {
+    node.children.push({
+      type: 'text',
+      text: ''
+    })
+  }
+}
+
+export default Editable

+ 203 - 0
components/mp-html/components/mp-html/emoji/index.js

@@ -0,0 +1,203 @@
+/**
+ * @fileoverview emoji 插件
+ */
+const reg = /\[(\S+?)\]/g
+const data = {
+  笑脸: '😄',
+  生病: '😷',
+  破涕为笑: '😂',
+  吐舌: '😝',
+  脸红: '😳',
+  恐惧: '😱',
+  失望: '😔',
+  无语: '😒',
+  眨眼: '😉',
+  酷: '😎',
+  哭: '😭',
+  痴迷: '😍',
+  吻: '😘',
+  思考: '🤔',
+  困惑: '😕',
+  颠倒: '🙃',
+  钱: '🤑',
+  惊讶: '😲',
+  白眼: '🙄',
+  叹气: '😤',
+  睡觉: '😴',
+  书呆子: '🤓',
+  愤怒: '😡',
+  面无表情: '😑',
+  张嘴: '😮',
+  量体温: '🤒',
+  呕吐: '🤮',
+  光环: '😇',
+  幽灵: '👻',
+  外星人: '👽',
+  机器人: '🤖',
+  捂眼镜: '🙈',
+  捂耳朵: '🙉',
+  捂嘴: '🙊',
+  婴儿: '👶',
+  男孩: '👦',
+  女孩: '👧',
+  男人: '👨',
+  女人: '👩',
+  老人: '👴',
+  老妇人: '👵',
+  警察: '👮',
+  王子: '🤴',
+  公主: '🤴',
+  举手: '🙋',
+  跑步: '🏃',
+  家庭: '👪',
+  眼睛: '👀',
+  鼻子: '👃',
+  耳朵: '👂',
+  舌头: '👅',
+  嘴: '👄',
+  心: '❤️',
+  心碎: '💔',
+  雪人: '☃️',
+  情书: '💌',
+  大便: '💩',
+  闹钟: '⏰',
+  眼镜: '👓',
+  雨伞: '☂️',
+  音乐: '🎵',
+  话筒: '🎤',
+  游戏机: '🎮',
+  喇叭: '📢',
+  耳机: '🎧',
+  礼物: '🎁',
+  电话: '📞',
+  电脑: '💻',
+  打印机: '🖨️',
+  手电筒: '🔦',
+  灯泡: '💡',
+  书本: '📖',
+  信封: '✉️',
+  药丸: '💊',
+  口红: '💄',
+  手机: '📱',
+  相机: '📷',
+  电视: '📺',
+  中: '🀄',
+  垃圾桶: '🚮',
+  厕所: '🚾',
+  感叹号: '❗',
+  禁: '🈲',
+  可: '🉑',
+  彩虹: '🌈',
+  旋风: '🌀',
+  雷电: '⚡',
+  雪花: '❄️',
+  星星: '⭐',
+  水滴: '💧',
+  玫瑰: '🌹',
+  加油: '💪',
+  左: '👈',
+  右: '👉',
+  上: '👆',
+  下: '👇',
+  手掌: '🖐️',
+  好的: '👌',
+  好: '👍',
+  差: '👎',
+  胜利: '✌',
+  拳头: '👊',
+  挥手: '👋',
+  鼓掌: '👏',
+  猴子: '🐒',
+  狗: '🐶',
+  狼: '🐺',
+  猫: '🐱',
+  老虎: '🐯',
+  马: '🐎',
+  独角兽: '🦄',
+  斑马: '🦓',
+  鹿: '🦌',
+  牛: '🐮',
+  猪: '🐷',
+  羊: '🐏',
+  长颈鹿: '🦒',
+  大象: '🐘',
+  老鼠: '🐭',
+  蝙蝠: '🦇',
+  刺猬: '🦔',
+  熊猫: '🐼',
+  鸽子: '🕊️',
+  鸭子: '🦆',
+  兔子: '🐇',
+  老鹰: '🦅',
+  青蛙: '🐸',
+  蛇: '🐍',
+  龙: '🐉',
+  鲸鱼: '🐳',
+  海豚: '🐬',
+  足球: '⚽',
+  棒球: '⚾',
+  篮球: '🏀',
+  排球: '🏐',
+  橄榄球: '🏉',
+  网球: '🎾',
+  骰子: '🎲',
+  鸡腿: '🍗',
+  蛋糕: '🎂',
+  啤酒: '🍺',
+  饺子: '🥟',
+  汉堡: '🍔',
+  薯条: '🍟',
+  意大利面: '🍝',
+  干杯: '🥂',
+  筷子: '🥢',
+  糖果: '🍬',
+  奶瓶: '🍼',
+  爆米花: '🍿',
+  邮局: '🏤',
+  医院: '🏥',
+  银行: '🏦',
+  酒店: '🏨',
+  学校: '🏫',
+  城堡: '🏰',
+  火车: '🚂',
+  高铁: '🚄',
+  地铁: '🚇',
+  公交: '🚌',
+  救护车: '🚑',
+  消防车: '🚒',
+  警车: '🚓',
+  出租车: '🚕',
+  汽车: '🚗',
+  货车: '🚛',
+  自行车: '🚲',
+  摩托: '🛵',
+  红绿灯: '🚥',
+  帆船: '⛵',
+  游轮: '🛳️',
+  轮船: '⛴️',
+  飞机: '✈️',
+  直升机: '🚁',
+  缆车: '🚠',
+  警告: '⚠️',
+  禁止: '⛔'
+}
+
+function Emoji () {
+
+}
+
+Emoji.prototype.onUpdate = function (content) {
+  return content.replace(reg, ($, $1) => {
+    if (data[$1]) return data[$1]
+    return $
+  })
+}
+
+Emoji.prototype.onGetContent = function (content) {
+  for (const item in data) {
+    content = content.replace(new RegExp(data[item], 'g'), '[' + item + ']')
+  }
+  return content
+}
+
+export default Emoji

+ 5 - 0
components/mp-html/components/mp-html/highlight/config.js

@@ -0,0 +1,5 @@
+export default {
+  copyByLongPress: true, // 是否需要长按代码块时显示复制代码内容菜单
+  showLanguageName: true, // 是否在代码块右上角显示语言的名称
+  showLineNumber: true // 是否显示行号
+}

+ 96 - 0
components/mp-html/components/mp-html/highlight/index.js

@@ -0,0 +1,96 @@
+/**
+ * @fileoverview highlight 插件
+ * Include prismjs (https://prismjs.com)
+ */
+import prism from './prism.min'
+import config from './config'
+import Parser from '../parser'
+
+function Highlight (vm) {
+  this.vm = vm
+}
+
+Highlight.prototype.onParse = function (node, vm) {
+  if (node.name === 'pre') {
+    if (vm.options.editable) {
+      node.attrs.class = (node.attrs.class || '') + ' hl-pre'
+      return
+    }
+    let i
+    for (i = node.children.length; i--;) {
+      if (node.children[i].name === 'code') break
+    }
+    if (i === -1) return
+    const code = node.children[i]
+    let className = code.attrs.class + ' ' + node.attrs.class
+    i = className.indexOf('language-')
+    if (i === -1) {
+      i = className.indexOf('lang-')
+      if (i === -1) {
+        className = 'language-text'
+        i = 9
+      } else {
+        i += 5
+      }
+    } else {
+      i += 9
+    }
+    let j
+    for (j = i; j < className.length; j++) {
+      if (className[j] === ' ') break
+    }
+    const lang = className.substring(i, j)
+    if (code.children.length) {
+      const text = this.vm.getText(code.children).replace(/&amp;/g, '&')
+      if (!text) return
+      if (node.c) {
+        node.c = undefined
+      }
+      if (prism.languages[lang]) {
+        code.children = (new Parser(this.vm).parse(
+          // 加一层 pre 保留空白符
+          '<pre>' + prism.highlight(text, prism.languages[lang], lang).replace(/token /g, 'hl-') + '</pre>'))[0].children
+      }
+      node.attrs.class = 'hl-pre'
+      code.attrs.class = 'hl-code'
+      if (config.showLanguageName) {
+        node.children.push({
+          name: 'div',
+          attrs: {
+            class: 'hl-language',
+            style: 'user-select:none'
+          },
+          children: [{
+            type: 'text',
+            text: lang
+          }]
+        })
+      }
+      if (config.copyByLongPress) {
+        node.attrs.style += (node.attrs.style || '') + ';user-select:none'
+        node.attrs['data-content'] = text
+        vm.expose()
+      }
+      if (config.showLineNumber) {
+        const line = text.split('\n').length; const children = []
+        for (let k = line; k--;) {
+          children.push({
+            name: 'span',
+            attrs: {
+              class: 'span'
+            }
+          })
+        }
+        node.children.push({
+          name: 'span',
+          attrs: {
+            class: 'line-numbers-rows'
+          },
+          children
+        })
+      }
+    }
+  }
+}
+
+export default Highlight

文件差異過大導致無法顯示
+ 2 - 0
components/mp-html/components/mp-html/highlight/prism.min.js


+ 138 - 0
components/mp-html/components/mp-html/img-cache/index.js

@@ -0,0 +1,138 @@
+const data = {
+  name: 'imgcache',
+  prefix: 'imgcache_'
+}
+function ImgCache (vm) {
+  this.vm = vm // 保存实例在其他周期使用
+  this.i = 0 // 用于标记第几张图
+  vm.imgCache = {
+    get list () {
+      return uni
+        .getStorageInfoSync()
+        .keys.filter((key) => key.startsWith(data.prefix))
+        .map((key) => key.split(data.prefix)[1])
+    },
+    get (url) {
+      return uni.getStorageSync(data.prefix + url)
+    },
+    delete (url) {
+      const path = uni.getStorageSync(data.prefix + url)
+      if (!path) return false
+      plus.io.resolveLocalFileSystemURL(path, (entry) => {
+        entry.remove()
+      })
+      uni.removeStorageSync(data.prefix + url)
+      return true
+    },
+    async add (url) {
+      const filename = await download(url)
+      if (filename) {
+        uni.setStorageSync(data.prefix + url, filename)
+        return 'file://' + plus.io.convertLocalFileSystemURL(filename)
+      }
+      return null
+    },
+    clear () {
+      uni
+        .getStorageInfoSync()
+        .keys.filter((key) => key.startsWith(data.prefix))
+        .forEach((key) => {
+          uni.removeStorageSync(key)
+        })
+
+      plus.io.resolveLocalFileSystemURL(`_doc/${data.name}/`, (entry) => {
+        entry.removeRecursively(
+          (entry) => {
+            console.log(`${data.name}缓存删除成功`, entry)
+          },
+          (e) => {
+            console.log(`${data.name}缓存删除失败`, e)
+          }
+        )
+      })
+    }
+  }
+}
+
+// #ifdef APP-PLUS
+ImgCache.prototype.onParse = function (node, parser) {
+  // 启用本插件 && 解析图片标签 && 拥有src属性 && 是网络图片
+  if (
+    this.vm.ImgCache &&
+    node.name === 'img' &&
+    node.attrs.src &&
+    /^https?:\/\//.test(node.attrs.src)
+  ) {
+    const src = node.attrs.src
+    node.attrs.src = ''
+    node.attrs.i = this.vm.imgList.length + this.i++
+    parser.expose()
+
+    async function getUrl (path) {
+      if (await resolveFile(path)) return path
+      const filename = await download(src)
+      filename && uni.setStorageSync(data.prefix + src, filename)
+      return filename
+    }
+
+    uni.getStorage({
+      key: data.prefix + src,
+      success: async (res) => {
+        const path = await getUrl(res.data)
+        const url = path
+          ? 'file://' + plus.io.convertLocalFileSystemURL(path)
+          : src
+        node.attrs.src = url
+        this.vm.imgList[node.attrs.i] = path || src
+      },
+      fail: async () => {
+        const path = await getUrl()
+        const url = path
+          ? 'file://' + plus.io.convertLocalFileSystemURL(path)
+          : src
+        node.attrs.src = url
+        this.vm.imgList[node.attrs.i] = path || src
+      }
+    })
+  }
+}
+
+const taskQueue = new Set()
+
+function download (url) {
+  return new Promise((resolve) => {
+    if (taskQueue.has(url)) return
+    taskQueue.add(url)
+    const suffix = /.+\.(jpg|jpeg|png|bmp|gif|webp)/.exec(url)
+    const name = `${makeid(8)}_${Date.now()}${suffix ? '.' + suffix[1] : ''}`
+    const task = plus.downloader.createDownload(
+      url,
+      { filename: `_doc/${data.name}/${name}` },
+      (download, status) => {
+        taskQueue.delete(url)
+        resolve(status === 200 ? download.filename : null)
+      }
+    )
+    task.start()
+  })
+}
+
+// 判断文件存在
+function resolveFile (url) {
+  return new Promise((resolve) => {
+    plus.io.resolveLocalFileSystemURL(url, resolve, () => resolve(null))
+  })
+}
+
+// 生成uuid
+function makeid (length) {
+  let result = ''
+  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
+  for (let i = 0; i < length; i++) {
+    result += characters.charAt(Math.floor(Math.random() * characters.length))
+  }
+  return result
+}
+// #endif
+
+export default ImgCache

+ 34 - 0
components/mp-html/components/mp-html/markdown/index.js

@@ -0,0 +1,34 @@
+/**
+ * @fileoverview markdown 插件
+ * Include marked (https://github.com/markedjs/marked)
+ * Include github-markdown-css (https://github.com/sindresorhus/github-markdown-css)
+ */
+import marked from './marked.min'
+let index = 0
+
+function Markdown (vm) {
+  this.vm = vm
+  vm._ids = {}
+}
+
+Markdown.prototype.onUpdate = function (content) {
+  if (this.vm.markdown) {
+    return marked(content)
+  }
+}
+
+Markdown.prototype.onParse = function (node, vm) {
+  if (vm.options.markdown) {
+    // 中文 id 需要转换,否则无法跳转
+    if (vm.options.useAnchor && node.attrs && /[\u4e00-\u9fa5]/.test(node.attrs.id)) {
+      const id = 't' + index++
+      this.vm._ids[node.attrs.id] = id
+      node.attrs.id = id
+    }
+    if (node.name === 'p' || node.name === 'table' || node.name === 'tr' || node.name === 'th' || node.name === 'td' || node.name === 'blockquote' || node.name === 'pre' || node.name === 'code') {
+      node.attrs.class = `md-${node.name} ${node.attrs.class || ''}`
+    }
+  }
+}
+
+export default Markdown

文件差異過大導致無法顯示
+ 5 - 0
components/mp-html/components/mp-html/markdown/marked.min.js


+ 579 - 0
components/mp-html/components/mp-html/mp-html.vue

@@ -0,0 +1,579 @@
+<template>
+  <view id="_root" :class="(selectable?'_select ':'')+'_root'" :style="(editable?'min-height:200px;':'')+containerStyle" @tap="_containTap">
+    <slot v-if="!nodes[0]" />
+    <!-- #ifndef APP-PLUS-NVUE -->
+    <node v-else :childs="nodes" :opts="[lazyLoad,loadingImg,errorImg,showImgMenu,selectable,editable,placeholder,'nodes']" name="span" />
+    <!-- #endif -->
+    <!-- #ifdef APP-PLUS-NVUE -->
+    <web-view ref="web" src="/static/app-plus/mp-html/local.html" :style="'margin-top:-2px;height:' + height + 'px'" @onPostMessage="_onMessage" />
+    <!-- #endif -->
+    <view v-if="tooltip" class="_tooltip_contain" :style="'top:'+tooltip.top+'px'">
+      <view class="_tooltip">
+        <view v-for="(item, index) in tooltip.items" v-bind:key="index" class="_tooltip_item" :data-i="index" @tap="_tooltipTap">{{item}}</view>
+      </view>
+    </view>
+    <view v-if="slider" class="_slider" :style="'top:'+slider.top+'px'">
+      <slider :value="slider.value" :min="slider.min" :max="slider.max" handle-size="14" block-size="14" show-value activeColor="white" style="padding:3px" @changing="_sliderChanging" @change="_sliderChange" />
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * mp-html v2.4.0
+ * @description 富文本组件
+ * @tutorial https://github.com/jin-yufeng/mp-html
+ * @property {String} container-style 容器的样式
+ * @property {String} content 用于渲染的 html 字符串
+ * @property {Boolean} copy-link 是否允许外部链接被点击时自动复制
+ * @property {String} domain 主域名,用于拼接链接
+ * @property {String} error-img 图片出错时的占位图链接
+ * @property {Boolean} lazy-load 是否开启图片懒加载
+ * @property {string} loading-img 图片加载过程中的占位图链接
+ * @property {Boolean} pause-video 是否在播放一个视频时自动暂停其他视频
+ * @property {Boolean} preview-img 是否允许图片被点击时自动预览
+ * @property {Boolean} scroll-table 是否给每个表格添加一个滚动层使其能单独横向滚动
+ * @property {Boolean | String} selectable 是否开启长按复制
+ * @property {Boolean} set-title 是否将 title 标签的内容设置到页面标题
+ * @property {Boolean} show-img-menu 是否允许图片被长按时显示菜单
+ * @property {Object} tag-style 标签的默认样式
+ * @property {Boolean | Number} use-anchor 是否使用锚点链接
+ * @event {Function} load dom 结构加载完毕时触发
+ * @event {Function} ready 所有图片加载完毕时触发
+ * @event {Function} imgtap 图片被点击时触发
+ * @event {Function} linktap 链接被点击时触发
+ * @event {Function} play 音视频播放时触发
+ * @event {Function} error 媒体加载出错时触发
+ */
+// #ifndef APP-PLUS-NVUE
+import node from './node/node'
+// #endif
+import Parser from './parser'
+import markdown from './markdown/index.js'
+import emoji from './emoji/index.js'
+import highlight from './highlight/index.js'
+import style from './style/index.js'
+import imgCache from './img-cache/index.js'
+import editable from './editable/index.js'
+const plugins=[markdown,emoji,highlight,style,imgCache,editable,]
+// #ifdef APP-PLUS-NVUE
+const dom = weex.requireModule('dom')
+// #endif
+export default {
+  name: 'mp-html',
+  data() {
+    return {
+      tooltip: null,
+      slider: null,
+      nodes: [],
+      // #ifdef APP-PLUS-NVUE
+      height: 3
+      // #endif
+    }
+  },
+  props: {
+    editable: Boolean,
+    placeholder: String,
+    ImgCache: Boolean,
+    markdown: Boolean,
+    containerStyle: {
+      type: String,
+      default: ''
+    },
+    content: {
+      type: String,
+      default: ''
+    },
+    copyLink: {
+      type: [Boolean, String],
+      default: true
+    },
+    domain: String,
+    errorImg: {
+      type: String,
+      default: ''
+    },
+    lazyLoad: {
+      type: [Boolean, String],
+      default: false
+    },
+    loadingImg: {
+      type: String,
+      default: ''
+    },
+    pauseVideo: {
+      type: [Boolean, String],
+      default: true
+    },
+    previewImg: {
+      type: [Boolean, String],
+      default: true
+    },
+    scrollTable: [Boolean, String],
+    selectable: [Boolean, String],
+    setTitle: {
+      type: [Boolean, String],
+      default: true
+    },
+    showImgMenu: {
+      type: [Boolean, String],
+      default: true
+    },
+    tagStyle: Object,
+    useAnchor: [Boolean, Number]
+  },
+  // #ifdef VUE3
+  emits: ['load', 'ready', 'imgtap', 'linktap', 'play', 'error'],
+  // #endif
+  // #ifndef APP-PLUS-NVUE
+  components: {
+    node
+  },
+  // #endif
+  watch: {
+    editable(val) {
+      this.setContent(val ? this.content : this.getContent())
+      if (!val)
+        this._maskTap()
+    },
+    content (content) {
+      this.setContent(content)
+    }
+  },
+  created () {
+    this.plugins = []
+    for (let i = plugins.length; i--;) {
+      this.plugins.push(new plugins[i](this))
+    }
+  },
+  mounted () {
+    if ((this.content || this.editable) && !this.nodes.length) {
+      this.setContent(this.content)
+    }
+  },
+  beforeDestroy () {
+    this._hook('onDetached')
+  },
+  methods: {
+    _containTap() {
+      if (!this._lock && !this.slider) {
+        this._edit = undefined
+        this._maskTap()
+      }
+    },
+    _tooltipTap(e) {
+      this._tooltipcb(e.currentTarget.dataset.i)
+      this.$set(this, 'tooltip', null)
+    },
+    _sliderChanging(e) {
+      this._slideringcb(e.detail.value)
+    },
+    _sliderChange(e) {
+      this._slidercb(e.detail.value)
+    },
+    /**
+     * @description 将锚点跳转的范围限定在一个 scroll-view 内
+     * @param {Object} page scroll-view 所在页面的示例
+     * @param {String} selector scroll-view 的选择器
+     * @param {String} scrollTop scroll-view scroll-top 属性绑定的变量名
+     */
+    in (page, selector, scrollTop) {
+      // #ifndef APP-PLUS-NVUE
+      if (page && selector && scrollTop) {
+        this._in = {
+          page,
+          selector,
+          scrollTop
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 锚点跳转
+     * @param {String} id 要跳转的锚点 id
+     * @param {Number} offset 跳转位置的偏移量
+     * @returns {Promise}
+     */
+    navigateTo (id, offset) {
+      id = this._ids[decodeURI(id)] || id
+      return new Promise((resolve, reject) => {
+        if (!this.useAnchor) {
+          reject(Error('Anchor is disabled'))
+          return
+        }
+        offset = offset || parseInt(this.useAnchor) || 0
+        // #ifdef APP-PLUS-NVUE
+        if (!id) {
+          dom.scrollToElement(this.$refs.web, {
+            offset
+          })
+          resolve()
+        } else {
+          this._navigateTo = {
+            resolve,
+            reject,
+            offset
+          }
+          this.$refs.web.evalJs('uni.postMessage({data:{action:"getOffset",offset:(document.getElementById(' + id + ')||{}).offsetTop}})')
+        }
+        // #endif
+        // #ifndef APP-PLUS-NVUE
+        let deep = ' '
+        // #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO
+        deep = '>>>'
+        // #endif
+        const selector = uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this._in ? this._in.page : this)
+          // #endif
+          .select((this._in ? this._in.selector : '._root') + (id ? `${deep}#${id}` : '')).boundingClientRect()
+        if (this._in) {
+          selector.select(this._in.selector).scrollOffset()
+            .select(this._in.selector).boundingClientRect()
+        } else {
+          // 获取 scroll-view 的位置和滚动距离
+          selector.selectViewport().scrollOffset() // 获取窗口的滚动距离
+        }
+        selector.exec(res => {
+          if (!res[0]) {
+            reject(Error('Label not found'))
+            return
+          }
+          const scrollTop = res[1].scrollTop + res[0].top - (res[2] ? res[2].top : 0) + offset
+          if (this._in) {
+            // scroll-view 跳转
+            this._in.page[this._in.scrollTop] = scrollTop
+          } else {
+            // 页面跳转
+            uni.pageScrollTo({
+              scrollTop,
+              duration: 300
+            })
+          }
+          resolve()
+        })
+        // #endif
+      })
+    },
+
+    /**
+     * @description 获取文本内容
+     * @return {String}
+     */
+    getText (nodes) {
+      let text = '';
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          const node = nodes[i]
+          if (node.type === 'text') {
+            text += node.text.replace(/&amp;/g, '&')
+          } else if (node.name === 'br') {
+            text += '\n'
+          } else {
+            // 块级标签前后加换行
+            const isBlock = node.name === 'p' || node.name === 'div' || node.name === 'tr' || node.name === 'li' || (node.name[0] === 'h' && node.name[1] > '0' && node.name[1] < '7')
+            if (isBlock && text && text[text.length - 1] !== '\n') {
+              text += '\n'
+            }
+            // 递归获取子节点的文本
+            if (node.children) {
+              traversal(node.children)
+            }
+            if (isBlock && text[text.length - 1] !== '\n') {
+              text += '\n'
+            } else if (node.name === 'td' || node.name === 'th') {
+              text += '\t'
+            }
+          }
+        }
+      })(nodes || this.nodes)
+      return text
+    },
+
+    /**
+     * @description 获取内容大小和位置
+     * @return {Promise}
+     */
+    getRect () {
+      return new Promise((resolve, reject) => {
+        uni.createSelectorQuery()
+          // #ifndef MP-ALIPAY
+          .in(this)
+          // #endif
+          .select('#_root').boundingClientRect().exec(res => res[0] ? resolve(res[0]) : reject(Error('Root label not found')))
+      })
+    },
+
+    /**
+     * @description 暂停播放媒体
+     */
+    pauseMedia () {
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].pause()
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].pause()'
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置媒体播放速率
+     * @param {Number} rate 播放速率
+     */
+    setPlaybackRate (rate) {
+      this.playbackRate = rate
+      for (let i = (this._videos || []).length; i--;) {
+        this._videos[i].playbackRate(rate)
+      }
+      // #ifdef APP-PLUS
+      const command = 'for(var e=document.getElementsByTagName("video"),i=e.length;i--;)e[i].playbackRate=' + rate
+      // #ifndef APP-PLUS-NVUE
+      let page = this.$parent
+      while (!page.$scope) page = page.$parent
+      page.$scope.$getAppWebview().evalJS(command)
+      // #endif
+      // #ifdef APP-PLUS-NVUE
+      this.$refs.web.evalJs(command)
+      // #endif
+      // #endif
+    },
+
+    /**
+     * @description 设置内容
+     * @param {String} content html 内容
+     * @param {Boolean} append 是否在尾部追加
+     */
+    setContent (content, append) {
+      if (!append || !this.imgList) {
+        this.imgList = []
+      }
+      const nodes = new Parser(this).parse(content)
+      // #ifdef APP-PLUS-NVUE
+      if (this._ready) {
+        this._set(nodes, append)
+      }
+      // #endif
+      this.$set(this, 'nodes', append ? (this.nodes || []).concat(nodes) : nodes)
+
+      // #ifndef APP-PLUS-NVUE
+      this._videos = []
+      this.$nextTick(() => {
+        this._hook('onLoad')
+        this.$emit('load')
+      })
+
+      if (this.lazyLoad || this.imgList._unloadimgs < this.imgList.length / 2) {
+        // 设置懒加载,每 350ms 获取高度,不变则认为加载完毕
+        let height
+        const callback = rect => {
+          // 350ms 总高度无变化就触发 ready 事件
+          if (rect.height === height) {
+            this.$emit('ready', rect)
+          } else {
+            height = rect.height
+            setTimeout(() => {
+              this.getRect().then(callback)
+            }, 350)
+          }
+        }
+        this.getRect().then(callback)
+      } else {
+        // 未设置懒加载,等待所有图片加载完毕
+        if (!this.imgList._unloadimgs) {
+          this.getRect(rect => {
+            this.$emit('ready', rect)
+          })
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 调用插件钩子函数
+     */
+    _hook (name) {
+      for (let i = plugins.length; i--;) {
+        if (this.plugins[i][name]) {
+          this.plugins[i][name]()
+        }
+      }
+    },
+
+    // #ifdef APP-PLUS-NVUE
+    /**
+     * @description 设置内容
+     */
+    _set (nodes, append) {
+      this.$refs.web.evalJs('setContent(' + JSON.stringify(nodes) + ',' + JSON.stringify([this.containerStyle.replace(/(?:margin|padding)[^;]+/g, ''), this.errorImg, this.loadingImg, this.pauseVideo, this.scrollTable, this.selectable]) + ',' + append + ')')
+    },
+
+    /**
+     * @description 接收到 web-view 消息
+     */
+    _onMessage (e) {
+      const message = e.detail.data[0]
+      switch (message.action) {
+        // web-view 初始化完毕
+        case 'onJSBridgeReady':
+          this._ready = true
+          if (this.nodes) {
+            this._set(this.nodes)
+          }
+          break
+        // 内容 dom 加载完毕
+        case 'onLoad':
+          this.height = message.height
+          this._hook('onLoad')
+          this.$emit('load')
+          break
+        // 所有图片加载完毕
+        case 'onReady':
+          this.getRect().then(res => {
+            this.$emit('ready', res)
+          }).catch(() => { })
+          break
+        // 总高度发生变化
+        case 'onHeightChange':
+          this.height = message.height
+          break
+        // 图片点击
+        case 'onImgTap':
+          this.$emit('imgtap', message.attrs)
+          if (this.previewImg) {
+            uni.previewImage({
+              current: parseInt(message.attrs.i),
+              urls: this.imgList
+            })
+          }
+          break
+        // 链接点击
+        case 'onLinkTap': {
+          const href = message.attrs.href
+          this.$emit('linktap', message.attrs)
+          if (href) {
+            // 锚点跳转
+            if (href[0] === '#') {
+              if (this.useAnchor) {
+                dom.scrollToElement(this.$refs.web, {
+                  offset: message.offset
+                })
+              }
+            } else if (href.includes('://')) {
+              // 打开外链
+              if (this.copyLink) {
+                plus.runtime.openWeb(href)
+              }
+            } else {
+              uni.navigateTo({
+                url: href,
+                fail () {
+                  uni.switchTab({
+                    url: href
+                  })
+                }
+              })
+            }
+          }
+          break
+        }
+        case 'onPlay':
+          this.$emit('play')
+          break
+        // 获取到锚点的偏移量
+        case 'getOffset':
+          if (typeof message.offset === 'number') {
+            dom.scrollToElement(this.$refs.web, {
+              offset: message.offset + this._navigateTo.offset
+            })
+            this._navigateTo.resolve()
+          } else {
+            this._navigateTo.reject(Error('Label not found'))
+          }
+          break
+        // 点击
+        case 'onClick':
+          this.$emit('tap')
+          this.$emit('click')
+          break
+        // 出错
+        case 'onError':
+          this.$emit('error', {
+            source: message.source,
+            attrs: message.attrs
+          })
+      }
+    }
+    // #endif
+  }
+}
+</script>
+
+<style>
+/* #ifndef APP-PLUS-NVUE */
+/* 根节点样式 */
+._root {
+  padding: 1px 0;
+  overflow-x: auto;
+  overflow-y: hidden;
+  -webkit-overflow-scrolling: touch;
+}
+
+/* 长按复制 */
+._select {
+  user-select: text;
+}
+/* #endif */
+
+/* 提示条 */
+._tooltip_contain {
+  position: absolute;
+  right: 20px;
+  left: 20px;
+  text-align: center;
+}
+
+._tooltip {
+  box-sizing: border-box;
+  display: inline-block;
+  width: auto;
+  max-width: 100%;
+  height: 30px;
+  padding: 0 3px;
+  overflow: scroll;
+  font-size: 14px;
+  line-height: 30px;
+  white-space: nowrap;
+}
+
+._tooltip_item {
+  display: inline-block;
+  width: auto;
+  padding: 0 2vw;
+  line-height: 30px;
+  background-color: black;
+  color: white;
+}
+
+/* 图片宽度滚动条 */
+._slider {
+  position: absolute;
+  left: 20px;
+  width: 220px;
+}
+
+._tooltip,
+._slider {
+  background-color: black;
+  border-radius: 3px;
+  opacity: 0.75;
+}
+</style>

+ 1119 - 0
components/mp-html/components/mp-html/node/node.vue

@@ -0,0 +1,1119 @@
+<template>
+  <view @tap="nodeTap" :id="attrs.id" :class="'_block _'+name+' '+attrs.class" :style="(ctrl.root?'border:1px solid black;padding:5px;display:block;':'')+attrs.style">
+    <block v-for="(n, i) in childs" v-bind:key="i">
+      <!-- 图片 -->
+      <!-- 占位图 -->
+      <image v-if="n.name==='img'&&!n.t&&((opts[1]&&!ctrl[i])||ctrl[i]<0)" class="_img" :style="n.attrs.style" :src="ctrl[i]<0?opts[2]:opts[1]" mode="widthFix" />
+      <!-- 显示图片 -->
+      <!-- #ifdef H5 || (APP-PLUS && VUE2) -->
+      <img v-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl['e'+i]?'border:1px dashed black;padding:3px;':'')+(ctrl[i]===-1?'display:none;':'')+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- #ifndef H5 || (APP-PLUS && VUE2) -->
+      <!-- 表格中的图片,使用 rich-text 防止大小不正确 -->
+      <rich-text v-if="n.name==='img'&&n.t" :style="'display:'+n.t" :nodes="'<img class=\'_img\' style=\''+n.attrs.style+'\' src=\''+n.attrs.src+'\'>'" :data-i="i" @tap.stop="imgTap" />
+      <!-- #endif -->
+      <!-- #ifndef H5 || APP-PLUS -->
+      <image v-else-if="n.name==='img'" :id="n.attrs.id||('n'+i)" :class="'_img '+n.attrs.class" :style="(ctrl['e'+i]?'border:1px dashed black;padding:3px;':'')+(ctrl[i]===-1?'display:none;':'')+'width:'+(ctrl[i]||1)+'px;height:'+(ctrl['h'+i]||1)+'px;'+n.attrs.style" :src="n.attrs.src" :mode="!n.h?'widthFix':(!n.w?'heightFix':'')" :lazy-load="opts[0]" :webp="n.webp" :show-menu-by-longpress="!opts[5]&&opts[3]&&!n.attrs.ignore" :image-menu-prevent="opts[5]||!opts[3]||n.attrs.ignore" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- #ifdef APP-PLUS && VUE3 -->
+      <image v-else-if="n.name==='img'" :id="n.attrs.id" :class="'_img '+n.attrs.class" :style="(ctrl['e'+i]?'border:1px dashed black;padding:3px;':'')+(ctrl[i]===-1?'display:none;':'')+'width:'+(ctrl[i]||1)+'px;'+n.attrs.style" :src="n.attrs.src||(ctrl.load?n.attrs['data-src']:'')" :mode="!n.h?'widthFix':(!n.w?'heightFix':'')" :data-i="i" @load="imgLoad" @error="mediaError" @tap.stop="imgTap" @longpress="imgLongTap" />
+      <!-- #endif -->
+      <!-- 文本 -->
+      <text v-else-if="n.type==='text'&&!ctrl['e'+i]" :data-i="i" :user-select="opts[4]" :decode="!opts[5]" @tap="editStart">{{n.text}}
+        <text v-if="!n.text" style="color:gray">{{opts[6]||'请输入'}}</text>
+      </text>
+      <text v-else-if="n.type==='text'&&ctrl['e'+i]===1" :data-i="i" style="border:1px dashed black;min-width:50px;width:auto;padding:5px;display:block" @tap.stop="editStart">{{n.text}}
+        <text v-if="!n.text" style="color:gray">{{opts[6]||'请输入'}}</text>
+      </text>
+      <textarea v-else-if="n.type==='text'" style="border:1px dashed black;min-width:50px;width:auto;padding:5px" auto-height maxlength="-1" :focus="ctrl['e'+i]===3" :value="n.text" :data-i="i" @input="editInput" @blur="editEnd" />
+      <text v-else-if="n.name==='br'">\n</text>
+      <!-- 链接 -->
+      <view v-else-if="n.name==='a'" :id="n.attrs.id" :class="(n.attrs.href?'_a ':'')+n.attrs.class" hover-class="_hover" :style="'display:inline;'+n.attrs.style" :data-i="i" @tap.stop="linkTap">
+        <node name="span" :childs="n.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children']" style="display:inherit" />
+      </view>
+      <!-- 视频 -->
+      <!-- #ifdef APP-PLUS -->
+      <view v-else-if="n.html" :data-i="i" @tap="mediaTap" :id="n.attrs.id" :class="'_video '+n.attrs.class" :style="n.attrs.style" v-html="n.html" @vplay.stop="play" />
+      <!-- #endif -->
+      <!-- #ifndef APP-PLUS -->
+      <video :show-center-play-btn="!opts[5]" @tap="mediaTap" v-else-if="n.name==='video'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :autoplay="n.attrs.autoplay" :controls="n.attrs.controls" :loop="n.attrs.loop" :muted="n.attrs.muted" :object-fit="n.attrs['object-fit']" :poster="n.attrs.poster" :src="n.src[ctrl[i]||0]" :data-i="i" @play="play" @error="mediaError" />
+      <!-- #endif -->
+      <!-- #ifdef H5 || APP-PLUS -->
+      <iframe v-else-if="n.name==='iframe'" :style="n.attrs.style" :allowfullscreen="n.attrs.allowfullscreen" :frameborder="n.attrs.frameborder" :src="n.attrs.src" />
+      <embed v-else-if="n.name==='embed'" :style="n.attrs.style" :src="n.attrs.src" />
+      <!-- #endif -->
+      <!-- #ifndef MP-TOUTIAO || ((H5 || APP-PLUS) && VUE3) -->
+      <!-- 音频 -->
+      <audio @tap="mediaTap" v-else-if="n.name==='audio'" :id="n.attrs.id" :class="n.attrs.class" :style="n.attrs.style" :author="n.attrs.author" :controls="n.attrs.controls" :loop="n.attrs.loop" :name="n.attrs.name" :poster="n.attrs.poster" :src="n.src[ctrl[i]||0]" :data-i="i" @play="play" @error="mediaError" />
+      <!-- #endif -->
+      <view v-else-if="(n.name==='table'&&(n.c||opts[5]))||n.name==='li'" :id="n.attrs.id" :class="'_'+n.name+' '+n.attrs.class" :style="n.attrs.style">
+        <node v-if="n.name==='li'" :childs="n.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children']" />
+        <view v-else v-for="(tbody, x) in n.children" v-bind:key="x" :class="'_'+tbody.name+' '+tbody.attrs.class" :style="tbody.attrs.style">
+          <node v-if="tbody.name==='td'||tbody.name==='th'" :childs="tbody.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children.'+x+'.children']" />
+          <block v-else v-for="(tr, y) in tbody.children" v-bind:key="y">
+            <view v-if="tr.name==='td'||tr.name==='th'" :class="'_'+tr.name+' '+tr.attrs.class" :style="tr.attrs.style">
+              <node :childs="tr.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children.'+x+'.children.'+y+'.children']" />
+            </view>
+            <view v-else :class="'_'+tr.name+' '+tr.attrs.class" :style="tr.attrs.style">
+              <view v-for="(td, z) in tr.children" v-bind:key="z" :class="'_'+td.name+' '+td.attrs.class" :style="td.attrs.style">
+                <node :childs="td.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children.'+x+'.children.'+y+'.children.'+z+'.children']" />
+              </view>
+            </view>
+          </block>
+        </view>
+      </view>
+      <rich-text v-else-if="n.attrs['data-content']" :nodes="[n]" :data-content="n.attrs['data-content']" :data-lang="n.attrs['data-lang']" @longpress="copyCode" />
+      <!-- 富文本 -->
+      <!-- #ifdef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) -->
+      <rich-text v-else-if="!opts[5]&&!n.c&&!handler.isInline(n.name, n.attrs.style)" :id="n.attrs.id" :style="n.f" :user-select="opts[4]" :nodes="[n]" />
+      <!-- #endif -->
+      <!-- #ifndef H5 || ((MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE2) -->
+      <rich-text v-else-if="!opts[5]&&!n.c" :id="n.attrs.id" :style="n.f+';display:inline'" :preview="false" :selectable="opts[4]" :user-select="opts[4]" :nodes="[n]" />
+      <!-- #endif -->
+      <!-- 继续递归 -->
+      <view v-else-if="n.c===2" :id="n.attrs.id" :class="'_block _'+n.name+' '+n.attrs.class" :style="n.f+';'+n.attrs.style">
+        <node v-for="(n2, j) in n.children" v-bind:key="j" :style="n2.f" :name="n2.name" :attrs="n2.attrs" :childs="n2.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children.'+j+'.children']" />
+      </view>
+      <node v-else :style="n.f" :name="n.name" :attrs="n.attrs" :childs="n.children" :opts="[opts[0],opts[1],opts[2],opts[3],opts[4],opts[5],opts[6],opts[7]+'.'+i+'.children']" />
+    </block>
+  </view>
+</template>
+<script module="handler" lang="wxs">
+// 行内标签列表
+var inlineTags = {
+  abbr: true,
+  b: true,
+  big: true,
+  code: true,
+  del: true,
+  em: true,
+  i: true,
+  ins: true,
+  label: true,
+  q: true,
+  small: true,
+  span: true,
+  strong: true,
+  sub: true,
+  sup: true
+}
+/**
+ * @description 判断是否为行内标签
+ */
+module.exports = {
+  isInline: function (tagName, style) {
+    return inlineTags[tagName] || (style || '').indexOf('display:inline') !== -1
+  }
+}
+</script>
+<script>
+function getTop(e) {
+  let top
+  // #ifdef H5 || APP-PLUS
+  top = e.touches[0].pageY
+  // #endif
+  // #ifdef MP-ALIPAY
+  top = e.detail.pageY
+  // #endif
+  // #ifndef H5 || MP-ALIPAY || APP-PLUS
+  top = e.detail.y
+  // #endif
+  if (top - e.currentTarget.offsetTop < 150 || top < 600) {
+    top = e.currentTarget.offsetTop
+  }
+  if (top < 30) {
+    top += 70
+  }
+  return top - 30
+}
+
+import node from './node'
+export default {
+  name: 'node',
+  options: {
+    // #ifdef MP-WEIXIN
+    virtualHost: true,
+    // #endif
+    // #ifdef MP-TOUTIAO
+    addGlobalClass: false
+    // #endif
+  },
+  data () {
+    return {
+      ctrl: {},
+      // #ifdef MP-WEIXIN
+      isiOS: uni.getSystemInfoSync().system.includes('iOS')
+      // #endif
+    }
+  },
+  props: {
+    name: String,
+    attrs: {
+      type: Object,
+      default () {
+        return {}
+      }
+    },
+    childs: Array,
+    opts: Array
+  },
+  components: {
+
+    // #ifndef H5 && VUE3
+    node
+    // #endif
+  },
+  mounted () {
+    this.$nextTick(() => {
+      for (this.root = this.$parent; this.root.$options.name !== 'mp-html'; this.root = this.root.$parent);
+    })
+    // #ifdef H5 || APP-PLUS
+    if (this.opts[0]) {
+      let i
+      for (i = this.childs.length; i--;) {
+        if (this.childs[i].name === 'img') break
+      }
+      if (i !== -1) {
+        this.observer = uni.createIntersectionObserver(this).relativeToViewport({
+          top: 500,
+          bottom: 500
+        })
+        this.observer.observe('._img', res => {
+          if (res.intersectionRatio) {
+            this.$set(this.ctrl, 'load', 1)
+            this.observer.disconnect()
+          }
+        })
+      }
+    }
+    // #endif
+  },
+  beforeDestroy () {
+  if (this.root._edit === this) {
+    this.root._edit = undefined
+  }
+    // #ifdef H5 || APP-PLUS
+    if (this.observer) {
+      this.observer.disconnect()
+    }
+    // #endif
+  },
+  methods:{copyCode (e) {
+      uni.showActionSheet({
+        itemList: ['复制代码'],
+        success: () =>
+          uni.setClipboardData({
+            data: e.currentTarget.dataset.content
+          })
+      })
+    },editStart (e) {
+      if (this.opts[5]) {
+        const i = e.currentTarget.dataset.i
+        if (!this.ctrl['e' + i]) {
+          // 显示虚线框
+          this.$set(this.ctrl, 'e' + i, 1)
+          setTimeout(() => {
+            this.root._mask.push(() => this.$set(this.ctrl, 'e' + i, 0))
+          }, 50)
+          this.root._edit = this
+          this.i = i
+          this.cursor = this.childs[i].text.length
+        } else {
+          this.root._mask.pop()
+          this.root._maskTap()
+          // 将 text 转为 textarea
+          this.$set(this.ctrl, 'e' + i, 2)
+          // 延时对焦,避免高度错误
+          setTimeout(() => {
+            this.$set(this.ctrl, 'e' + i, 3)
+          }, 50)
+        }
+      }
+    },editInput (e) {
+      const i = e.target.dataset.i
+      // 替换连续空格
+      const value = e.detail.value.replace(/ {2,}/, $ => {
+        let res = '\xa0'
+        for (let i = 1; i < $.length; i++) {
+          res += '\xa0'
+        }
+        return res
+      })
+      this.root._editVal(`${this.opts[7]}.${i}.text`, this.childs[i].text, value) // 记录编辑历史
+      this.cursor = e.detail.cursor
+    },editEnd (e) {
+      const i = e.target.dataset.i
+      this.$set(this.ctrl, 'e' + i, 0)
+      // 更新到视图
+      this.root._setData(`${this.opts[7]}.${i}.text`, e.detail.value.replace(/ {2}/g, '\xa0 '))
+      if (e.detail.cursor !== undefined) {
+        this.cursor = e.detail.cursor
+      }
+    },insert (node) {
+      setTimeout(() => {
+        const childs = this.childs.slice(0)
+        if (!childs[this.i]) {
+          childs.push(node)
+        } else if (childs[this.i].text) {
+          // 在文本中插入
+          const text = childs[this.i].text
+          if (node.type === 'text') {
+            if (this.cursor) {
+              childs[this.i].text = text.substring(0, this.cursor) + node.text + text.substring(this.cursor)
+            } else {
+              childs[this.i].text += node.text
+            }
+          } else {
+            const list = []
+            if (this.cursor) {
+              list.push({
+                type: 'text',
+                text: text.substring(0, this.cursor)
+              })
+            }
+            list.push(node)
+            if (this.cursor < text.length) {
+              list.push({
+                type: 'text',
+                text: text.substring(this.cursor)
+              })
+            }
+            childs.splice(this.i, 1, ...list)
+          }
+        } else {
+          childs.splice(parseInt(this.i) + 1, 0, node)
+        }
+        this.root._editVal(this.opts[7], this.childs, childs, true)
+        this.i = parseInt(this.i) + 1
+      }, 200)
+    },remove (i) {
+      const arr = this.childs.slice(0)
+      const delEle = arr.splice(i, 1)[0]
+      if (delEle.name === 'img' || delEle.name === 'video' || delEle.name === 'audio') {
+        let src = delEle.attrs.src
+        if (delEle.src) {
+          src = delEle.src.length === 1 ? delEle.src[0] : delEle.src
+        }
+        this.root.$emit('remove', {
+          type: delEle.name,
+          src
+        })
+      }
+      this.root._edit = undefined
+      this.root._maskTap()
+      this.root._editVal(this.opts[7], this.childs, arr, true)
+    },nodeTap (e) {
+      if (this.opts[5]) {
+        if (this.root._lock) return
+        this.root._lock = true
+        setTimeout(() => {
+          this.root._lock = false
+        }, 50)
+        if (this.ctrl['e' + this.i] === 3) return
+        this.root._maskTap()
+        this.root._edit = this
+        let start = this.opts[7].lastIndexOf('children.')
+        if (start !== -1) {
+          start += 9
+        } else {
+          start = 6
+        }
+        const i = parseInt(this.opts[7].substring(start, this.opts[7].lastIndexOf('.children')))
+        let parent = this.$parent
+        while (parent && parent.$options.name !== 'node') {
+          parent = parent.$parent
+        }
+        if (!parent || this.opts[7].length - parent.opts[7].length > 15) return
+        // 显示实线框
+        this.$set(this.ctrl, 'root', 1)
+        this.root._mask.push(() => this.$set(this.ctrl, 'root', 0))
+        if (this.childs.length === 1 && this.childs[0].type === 'text' && !this.ctrl.e0) {
+          this.$set(this.ctrl, 'e0', 1)
+          this.root._mask.push(() => this.$set(this.ctrl, 'e0', 0))
+          this.i = 0
+          this.cursor = this.childs[0].text.length
+        }
+        const items = this.root._getItem(parent.childs[i], i !== 0, i !== parent.childs.length - 1)
+        this.root._tooltip({
+          top: getTop(e),
+          items,
+          success: tapIndex => {
+            if (items[tapIndex] === '大小') {
+              // 改变字体大小
+              const style = parent.childs[i].attrs.style || ''
+              let value = style.match(/;font-size:([0-9]+)px/)
+              if (value) {
+                value = parseInt(value[1])
+              } else {
+                value = 16
+              }
+              this.root._slider({
+                min: 10,
+                max: 30,
+                value,
+                top: getTop(e),
+                changing: val => {
+                  if (Math.abs(val - value) > 2) {
+                    // 字号变换超过 2 时更新到视图
+                    parent.changeStyle('font-size', i, val + 'px', value + 'px')
+                    value = e.detail.value
+                  }
+                },
+                change: val => {
+                  if (val !== value) {
+                    parent.changeStyle('font-size', i, val + 'px', value + 'px')
+                  }
+                  this.root._editVal(`${parent.opts[7]}.${i}.attrs.style`, style, parent.childs[i].attrs.style)
+                }
+              })
+            } else if (items[tapIndex] === '上移' || items[tapIndex] === '下移') {
+              const arr = parent.childs.slice(0)
+              const item = arr[i]
+              if (items[tapIndex] === '上移') {
+                arr[i] = arr[i - 1]
+                arr[i - 1] = item
+              } else {
+                arr[i] = arr[i + 1]
+                arr[i + 1] = item
+              }
+              this.root._editVal(parent.opts[7], parent.childs, arr, true)
+            } else if (items[tapIndex] === '删除') {
+              parent.remove(i)
+            } else {
+              const style = parent.childs[i].attrs.style || ''
+              let newStyle = ''
+              const item = items[tapIndex]
+              let name
+              let value
+              if (item === '斜体') {
+                name = 'font-style'
+                value = 'italic'
+              } else if (item === '粗体') {
+                name = 'font-weight'
+                value = 'bold'
+              } else if (item === '下划线') {
+                name = 'text-decoration'
+                value = 'underline'
+              } else if (item === '居中') {
+                name = 'text-align'
+                value = 'center'
+              } else if (item === '缩进') {
+                name = 'text-indent'
+                value = '2em'
+              }
+              if (style.includes(name + ':')) {
+                // 已有则取消
+                newStyle = style.replace(new RegExp(name + ':[^;]+'), '')
+              } else {
+                // 没有则添加
+                newStyle = style + ';' + name + ':' + value
+              }
+              this.root._editVal(`${parent.opts[7]}.${i}.attrs.style`, style, newStyle, true)
+            }
+          }
+        })
+      }
+    },mediaTap (e) {
+      if (this.opts[5]) {
+        const i = e.target.dataset.i
+        const node = this.childs[i]
+        const items = this.root._getItem(node)
+        this.root._edit = this
+        this.i = i
+        this.root._tooltip({
+          top: e.target.offsetTop - 30,
+          items,
+          success: tapIndex => {
+            switch (items[tapIndex]) {
+              case '封面':
+                // 设置封面
+                this.root.getSrc('img', node.attrs.poster || '').then(url => {
+                  this.root._editVal(`${this.opts[7]}.${i}.attrs.poster`, node.attrs.poster, url instanceof Array ? url[0] : url, true)
+                }).catch(() => { })
+                break
+              case '删除':
+                this.remove(i)
+                break
+              case '循环':
+              case '不循环':
+                // 切换循环播放
+                this.root._setData(`${this.opts[7]}.${i}.attrs.loop`, !node.attrs.loop)
+                uni.showToast({
+                  title: '成功'
+                })
+                break
+              case '自动播放':
+              case '不自动播放':
+                // 切换自动播放播放
+                this.root._setData(`${this.opts[7]}.${i}.attrs.autoplay`, !node.attrs.autoplay)
+                uni.showToast({
+                  title: '成功'
+                })
+                break
+            }
+          }
+        })
+        // 避免上层出现点击态
+        this.root._lock = true
+        setTimeout(() => {
+          this.root._lock = false
+        }, 50)
+      }
+    },changeStyle (name, i, value, oldVal) {
+      let style = this.childs[i].attrs.style || ''
+      if (style.includes(';' + name + ':' + oldVal)) {
+        // style 中已经有
+        style = style.replace(';' + name + ':' + oldVal, ';' + name + ':' + value)
+      } else {
+        // 没有则新增
+        style += ';' + name + ':' + value
+      }
+      this.root._setData(`${this.opts[7]}.${i}.attrs.style`, style)
+    },
+    // #ifdef MP-WEIXIN
+    toJSON () { return this },
+    // #endif
+    /**
+     * @description 播放视频事件
+     * @param {Event} e
+     */
+    play (e) {
+      this.root.$emit('play')
+      // #ifndef APP-PLUS
+      if (this.root.pauseVideo) {
+        let flag = false
+        const id = e.target.id
+        for (let i = this.root._videos.length; i--;) {
+          if (this.root._videos[i].id === id) {
+            flag = true
+          } else {
+            this.root._videos[i].pause() // 自动暂停其他视频
+          }
+        }
+        // 将自己加入列表
+        if (!flag) {
+          const ctx = uni.createVideoContext(id
+            // #ifndef MP-BAIDU
+            , this
+            // #endif
+          )
+          ctx.id = id
+          if (this.root.playbackRate) {
+            ctx.playbackRate(this.root.playbackRate)
+          }
+          this.root._videos.push(ctx)
+        }
+      }
+      // #endif
+    },
+
+    /**
+     * @description 图片点击事件
+     * @param {Event} e
+     */
+    imgTap (e) {
+      if (!this.opts[5]) {
+      const node = this.childs[e.currentTarget.dataset.i]
+      if (node.a) {
+        this.linkTap(node.a)
+        return
+      }
+      if (node.attrs.ignore) return
+      // #ifdef H5 || APP-PLUS
+      node.attrs.src = node.attrs.src || node.attrs['data-src']
+      // #endif
+      this.root.$emit('imgtap', node.attrs)
+      // 自动预览图片
+      if (this.root.previewImg) {
+        uni.previewImage({
+          // #ifdef MP-WEIXIN
+          showmenu: this.root.showImgMenu,
+          // #endif
+          // #ifdef MP-ALIPAY
+          enablesavephoto: this.root.showImgMenu,
+          enableShowPhotoDownload: this.root.showImgMenu,
+          // #endif
+          current: parseInt(node.attrs.i),
+          urls: this.root.imgList
+        })
+      }
+    } else {
+        const i = e.currentTarget.dataset.i
+        const node = this.childs[i]
+        const items = this.root._getItem(node)
+        this.root._edit = this
+        this.i = i
+        this.root._maskTap()
+        this.$set(this.ctrl, 'e' + i, 1)
+        this.root._mask.push(() => this.$set(this.ctrl, 'e' + i, 0))
+        this.root._tooltip({
+          top: getTop(e),
+          items,
+          success: tapIndex => {
+            if (items[tapIndex] === '换图') {
+              // 换图
+              this.root.getSrc('img', node.attrs.src || '').then(url => {
+                this.root._editVal(this.opts[7] + '.' + i + '.attrs.src', node.attrs.src, url instanceof Array ? url[0] : url, true)
+              }).catch(() => { })
+            } else if (items[tapIndex] === '宽度') {
+              // 更改宽度
+              const style = node.attrs.style || ''
+              let value = style.match(/max-width:([0-9]+)%/)
+              if (value) {
+                value = parseInt(value[1])
+              } else {
+                value = 100
+              }
+              this.root._slider({
+                min: 0,
+                max: 100,
+                value,
+                top: getTop(e),
+                changing: val => {
+                  // 变化超过 5% 更新时视图
+                  if (Math.abs(val - value) > 5) {
+                    this.changeStyle('max-width', i, val + '%', value + '%')
+                    value = val
+                  }
+                },
+                change: val => {
+                  if (val !== value) {
+                    this.changeStyle('max-width', i, val + '%', value + '%')
+                    value = val
+                  }
+                  this.root._editVal(this.opts[7] + '.' + i + '.attrs.style', style, this.childs[i].attrs.style)
+                }
+              })
+            } else if (items[tapIndex] === '超链接') {
+              // 将图片设置为链接
+              this.root.getSrc('link', node.a ? node.a.href : '').then(url => {
+                // 如果有 a 标签则替换 href
+                if (node.a) {
+                  this.root._editVal(this.opts[7] + '.' + i + '.a.href', node.a.href, url, true)
+                } else {
+                  const link = {
+                    name: 'a',
+                    attrs: {
+                      href: url
+                    },
+                    children: [node]
+                  }
+                  node.a = link.attrs
+                  this.root._editVal(this.opts[7] + '.' + i, node, link, true)
+                }
+                wx.showToast({
+                  title: '成功'
+                })
+              }).catch(() => { })
+            } else if (items[tapIndex] === '预览图') {
+              // 设置预览图链接
+              this.root.getSrc('img', node.attrs['original-src'] || '').then(url => {
+                this.root._editVal(this.opts[7] + '.' + i + '.attrs.original-src', node.attrs['original-src'], url instanceof Array ? url[0] : url, true)
+                uni.showToast({
+                  title: '成功'
+                })
+              }).catch(() => { })
+            } else if (items[tapIndex] === '删除') {
+              this.remove(i)
+            } else {
+              // 禁用 / 启用预览
+              this.root._setData(this.opts[7] + '.' + i + '.attrs.ignore', !node.attrs.ignore)
+              uni.showToast({
+                title: '成功'
+              })
+            }
+          }
+        })
+        this.root._lock = true
+        setTimeout(() => {
+          this.root._lock = false
+        }, 50)
+      }
+    },
+    /**
+     * @description 图片长按
+     */
+    imgLongTap (e) {
+      // #ifdef APP-PLUS
+      const attrs = this.childs[e.currentTarget.dataset.i].attrs
+      if (this.opts[3] && !attrs.ignore) {
+        uni.showActionSheet({
+          itemList: ['保存图片'],
+          success: () => {
+            const save = path => {
+              uni.saveImageToPhotosAlbum({
+                filePath: path,
+                success () {
+                  uni.showToast({
+                    title: '保存成功'
+                  })
+                }
+              })
+            }
+            if (this.root.imgList[attrs.i].startsWith('http')) {
+              uni.downloadFile({
+                url: this.root.imgList[attrs.i],
+                success: res => save(res.tempFilePath)
+              })
+            } else {
+              save(this.root.imgList[attrs.i])
+            }
+          }
+        })
+      }
+      // #endif
+    },
+
+    /**
+     * @description 图片加载完成事件
+     * @param {Event} e
+     */
+    imgLoad(e) {
+      // #ifdef MP-WEIXIN || MP-QQ
+      if (this.opts[5])
+        this.$nextTick(() => {
+          const id = this.childs[i].attrs.id || ('n' + i)
+          uni.createSelectorQuery().in(this).select('#' + id).boundingClientRect().exec(res => {
+            this.$set(this.ctrl, 'h'+i, res[0].height)
+          })
+        })
+      // #endif
+      const i = e.currentTarget.dataset.i
+      /* #ifndef H5 || (APP-PLUS && VUE2) */
+      if (!this.childs[i].w) {
+        this.$set(this.ctrl, i, e.detail.width)
+        if (this.opts[5]) {
+          const path = this.opts[7] + '.' + i + '.attrs.'
+          if (e.detail.width < 150)
+            this.root._setData(path + 'ignore', 'T')
+          this.root._setData(path + 'width', e.detail.width.toString())
+        }
+      } else /* #endif */ if ((this.opts[1] && !this.ctrl[i]) || this.ctrl[i] === -1) {
+        // 加载完毕,取消加载中占位图
+        this.$set(this.ctrl, i, 1)
+      }
+      this.checkReady()
+    },
+
+    /**
+     * @description 检查是否所有图片加载完毕
+     */
+    checkReady () {
+      if (!this.root.lazyLoad) {
+        this.root._unloadimgs -= 1
+        if (!this.root._unloadimgs) {
+          setTimeout(() => {
+            this.root.getRect().then(rect => {
+              this.root.$emit('ready', rect)
+            })
+          }, 350)
+        }
+      }
+    },
+
+    /**
+     * @description 链接点击事件
+     * @param {Event} e
+     */
+    linkTap (e) {
+      if (!this.opts[5]) {
+      const node = e.currentTarget ? this.childs[e.currentTarget.dataset.i] : {}
+      const attrs = node.attrs || e
+      const href = attrs.href
+      this.root.$emit('linktap', Object.assign({
+        innerText: this.root.getText(node.children || []) // 链接内的文本内容
+      }, attrs))
+      if (href) {
+        if (href[0] === '#') {
+          // 跳转锚点
+          this.root.navigateTo(href.substring(1)).catch(() => { })
+        } else if (href.split('?')[0].includes('://')) {
+          // 复制外部链接
+          if (this.root.copyLink) {
+            // #ifdef H5
+            window.open(href)
+            // #endif
+            // #ifdef MP
+            uni.setClipboardData({
+              data: href,
+              success: () =>
+                uni.showToast({
+                  title: '链接已复制'
+                })
+            })
+            // #endif
+            // #ifdef APP-PLUS
+            plus.runtime.openWeb(href)
+            // #endif
+          }
+        } else {
+          // 跳转页面
+          uni.navigateTo({
+            url: href,
+            fail () {
+              uni.switchTab({
+                url: href,
+                fail () { }
+              })
+            }
+          })
+        }
+      }
+    } else {
+        const i = e.currentTarget.dataset.i
+        const node = this.childs[i]
+        const items = this.root._getItem(node)
+        this.root._tooltip({
+          top: getTop(e),
+          items,
+          success: tapIndex => {
+            if (items[tapIndex] === '更换链接') {
+              this.root.getSrc('link', node.attrs.href).then(url => {
+                this.root._editVal(this.opts[7] + '.' + i + '.attrs.href', node.attrs.href, url, true)
+                uni.showToast({
+                  title: '成功'
+                })
+              }).catch(() => { })
+            } else {
+              this.remove(i)
+            }
+          }
+        })
+      }
+    },
+    /**
+     * @description 错误事件
+     * @param {Event} e
+     */
+    mediaError (e) {
+      const i = e.currentTarget.dataset.i
+      const node = this.childs[i]
+      // 加载其他源
+      if (node.name === 'video' || node.name === 'audio') {
+        let index = (this.ctrl[i] || 0) + 1
+        if (index > node.src.length) {
+          index = 0
+        }
+        if (index < node.src.length) {
+          this.$set(this.ctrl, i, index)
+          return
+        }
+      } else if (node.name === 'img') {
+        // #ifdef H5 && VUE3
+        if (this.opts[0] && !this.ctrl.load) return
+        // #endif
+        // 显示错误占位图
+        if (this.opts[2]) {
+          this.$set(this.ctrl, i, -1)
+        }
+        this.checkReady()
+      }
+      if (this.root) {
+        this.root.$emit('error', {
+          source: node.name,
+          attrs: node.attrs,
+          // #ifndef H5 && VUE3
+          errMsg: e.detail.errMsg
+          // #endif
+        })
+      }
+    }
+  }
+}
+</script>
+<style>/deep/ .hl-code,/deep/ .hl-pre{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}/deep/ .hl-pre{padding:1em;margin:.5em 0;overflow:auto}/deep/ .hl-pre{background:#2d2d2d}/deep/ .hl-block-comment,/deep/ .hl-cdata,/deep/ .hl-comment,/deep/ .hl-doctype,/deep/ .hl-prolog{color:#999}/deep/ .hl-punctuation{color:#ccc}/deep/ .hl-attr-name,/deep/ .hl-deleted,/deep/ .hl-namespace,/deep/ .hl-tag{color:#e2777a}/deep/ .hl-function-name{color:#6196cc}/deep/ .hl-boolean,/deep/ .hl-function,/deep/ .hl-number{color:#f08d49}/deep/ .hl-class-name,/deep/ .hl-constant,/deep/ .hl-property,/deep/ .hl-symbol{color:#f8c555}/deep/ .hl-atrule,/deep/ .hl-builtin,/deep/ .hl-important,/deep/ .hl-keyword,/deep/ .hl-selector{color:#cc99cd}/deep/ .hl-attr-value,/deep/ .hl-char,/deep/ .hl-regex,/deep/ .hl-string,/deep/ .hl-variable{color:#7ec699}/deep/ .hl-entity,/deep/ .hl-operator,/deep/ .hl-url{color:#67cdcc}/deep/ .hl-bold,/deep/ .hl-important{font-weight:700}/deep/ .hl-italic{font-style:italic}/deep/ .hl-entity{cursor:help}/deep/ .hl-inserted{color:green}/deep/ .md-p {
+  margin-block-start: 1em;
+  margin-block-end: 1em;
+}
+
+/deep/ .md-table,
+/deep/ .md-blockquote {
+  margin-bottom: 16px;
+}
+
+/deep/ .md-table {
+  box-sizing: border-box;
+  width: 100%;
+  overflow: auto;
+  border-spacing: 0;
+  border-collapse: collapse;
+}
+
+/deep/ .md-tr {
+  background-color: #fff;
+  border-top: 1px solid #c6cbd1;
+}
+
+.md-table .md-tr:nth-child(2n) {
+  background-color: #f6f8fa;
+}
+
+/deep/ .md-th,
+/deep/ .md-td {
+  padding: 6px 13px !important;
+  border: 1px solid #dfe2e5;
+}
+
+/deep/ .md-th {
+  font-weight: 600;
+}
+
+/deep/ .md-blockquote {
+  padding: 0 1em;
+  color: #6a737d;
+  border-left: 0.25em solid #dfe2e5;
+}
+
+/deep/ .md-code {
+  padding: 0.2em 0.4em;
+  font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
+  font-size: 85%;
+  background-color: rgba(27, 31, 35, 0.05);
+  border-radius: 3px;
+}
+
+/deep/ .md-pre .md-code {
+  padding: 0;
+  font-size: 100%;
+  background: transparent;
+  border: 0;
+}/deep/ .hl-pre {
+  position: relative;
+}
+/deep/ .hl-code {
+  overflow: auto;
+  display: block;
+}/deep/ .hl-language {
+  font-size: 12px;
+  font-weight: 600;
+  position: absolute;
+  right: 8px;
+  text-align: right;
+  top: 3px;
+}
+/deep/ .hl-pre {
+  padding-top: 1.5em;
+}/deep/ .hl-pre {
+  font-size: 14px;
+  padding-left: 3.8em;
+  counter-reset: linenumber;
+}
+/deep/ .line-numbers-rows {
+  position: absolute;
+  pointer-events: none;
+  top: 1.5em;
+  font-size: 100%;
+  left: 0;
+  width: 3em; /* works for line-numbers below 1000 lines */
+  letter-spacing: -1px;
+  border-right: 1px solid #999;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+/deep/ .line-numbers-rows .span {
+  display: block;
+  counter-increment: linenumber;
+} 
+/deep/ .line-numbers-rows .span:before {
+  content: counter(linenumber);
+  color: #999;
+  display: block;
+  padding-right: 0.8em;
+  text-align: right;
+}/* #ifndef H5 || MP-ALIPAY || APP-PLUS */
+  /deep/ ._address,
+  /deep/ ._article,
+  /deep/ ._aside,
+  /deep/ ._body,
+  /deep/ ._caption,
+  /deep/ ._center,
+  /deep/ ._cite,
+  /deep/ ._footer,
+  /deep/ ._header,
+  /deep/ ._html,
+  /deep/ ._nav,
+  /deep/ ._pre,
+  /deep/ ._section {
+    display: block;
+  }
+  
+  /* #endif */
+  /deep/ ._video {
+    width: 300px;
+    height: 225px;
+    display: inline-block;
+    background-color: black;
+  }
+/* a 标签默认效果 */
+._a {
+  padding: 1.5px 0 1.5px 0;
+  color: #366092;
+  word-break: break-all;
+}
+
+/* a 标签点击态效果 */
+._hover {
+  text-decoration: underline;
+  opacity: 0.7;
+}
+
+/* 图片默认效果 */
+._img {
+  max-width: 100%;
+  -webkit-touch-callout: none;
+}
+
+/* 内部样式 */
+
+._block {
+  display: block;
+}
+
+._b,
+._strong {
+  font-weight: bold;
+}
+
+._code {
+  font-family: monospace;
+}
+
+._del {
+  text-decoration: line-through;
+}
+
+._em,
+._i {
+  font-style: italic;
+}
+
+._h1 {
+  font-size: 2em;
+}
+
+._h2 {
+  font-size: 1.5em;
+}
+
+._h3 {
+  font-size: 1.17em;
+}
+
+._h5 {
+  font-size: 0.83em;
+}
+
+._h6 {
+  font-size: 0.67em;
+}
+
+._h1,
+._h2,
+._h3,
+._h4,
+._h5,
+._h6 {
+  display: block;
+  font-weight: bold;
+}
+
+._image {
+  height: 1px;
+}
+
+._ins {
+  text-decoration: underline;
+}
+
+._li {
+  display: list-item;
+}
+
+._ol {
+  list-style-type: decimal;
+}
+
+._ol,
+._ul {
+  display: block;
+  padding-left: 40px;
+  margin: 1em 0;
+}
+
+._q::before {
+  content: '"';
+}
+
+._q::after {
+  content: '"';
+}
+
+._sub {
+  font-size: smaller;
+  vertical-align: sub;
+}
+
+._sup {
+  font-size: smaller;
+  vertical-align: super;
+}
+
+._thead,
+._tbody,
+._tfoot {
+  display: table-row-group;
+}
+
+._tr {
+  display: table-row;
+}
+
+._td,
+._th {
+  display: table-cell;
+  vertical-align: middle;
+}
+
+._th {
+  font-weight: bold;
+  text-align: center;
+}
+
+._ul {
+  list-style-type: disc;
+}
+
+._ul ._ul {
+  margin: 0;
+  list-style-type: circle;
+}
+
+._ul ._ul ._ul {
+  list-style-type: square;
+}
+
+._abbr,
+._b,
+._code,
+._del,
+._em,
+._i,
+._ins,
+._label,
+._q,
+._span,
+._strong,
+._sub,
+._sup {
+  display: inline;
+}
+
+/* #ifdef APP-PLUS */
+._video {
+  width: 300px;
+  height: 225px;
+}
+/* #endif */
+</style>

+ 1342 - 0
components/mp-html/components/mp-html/parser.js

@@ -0,0 +1,1342 @@
+/**
+ * @fileoverview html 解析器
+ */
+
+// 配置
+const config = {
+  // 信任的标签(保持标签名不变)
+  trustTags: makeMap('a,abbr,ad,audio,b,blockquote,br,code,col,colgroup,dd,del,dl,dt,div,em,fieldset,h1,h2,h3,h4,h5,h6,hr,i,img,ins,label,legend,li,ol,p,q,ruby,rt,source,span,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,title,ul,video'),
+
+  // 块级标签(转为 div,其他的非信任标签转为 span)
+  blockTags: makeMap('address,article,aside,body,caption,center,cite,footer,header,html,nav,pre,section'),
+
+  // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
+  // 行内标签
+  inlineTags: makeMap('abbr,b,big,code,del,em,i,ins,label,q,small,span,strong,sub,sup'),
+  // #endif
+
+  // 要移除的标签
+  ignoreTags: makeMap('area,base,canvas,embed,frame,head,iframe,input,link,map,meta,param,rp,script,source,style,textarea,title,track,wbr'),
+
+  // 自闭合的标签
+  voidTags: makeMap('area,base,br,col,circle,ellipse,embed,frame,hr,img,input,line,link,meta,param,path,polygon,rect,source,track,use,wbr'),
+
+  // html 实体
+  entities: {
+    lt: '<',
+    gt: '>',
+    quot: '"',
+    apos: "'",
+    ensp: '\u2002',
+    emsp: '\u2003',
+    nbsp: '\xA0',
+    semi: ';',
+    ndash: '–',
+    mdash: '—',
+    middot: '·',
+    lsquo: '‘',
+    rsquo: '’',
+    ldquo: '“',
+    rdquo: '”',
+    bull: '•',
+    hellip: '…',
+    larr: '←',
+    uarr: '↑',
+    rarr: '→',
+    darr: '↓'
+  },
+
+  // 默认的标签样式
+  tagStyle: {
+    // #ifndef APP-PLUS-NVUE
+    address: 'font-style:italic',
+    big: 'display:inline;font-size:1.2em',
+    caption: 'display:table-caption;text-align:center',
+    center: 'text-align:center',
+    cite: 'font-style:italic',
+    dd: 'margin-left:40px',
+    mark: 'background-color:yellow',
+    pre: 'font-family:monospace;white-space:pre',
+    s: 'text-decoration:line-through',
+    small: 'display:inline;font-size:0.8em',
+    strike: 'text-decoration:line-through',
+    u: 'text-decoration:underline'
+    // #endif
+  },
+
+  // svg 大小写对照表
+  svgDict: {
+    animatetransform: 'animateTransform',
+    lineargradient: 'linearGradient',
+    viewbox: 'viewBox',
+    attributename: 'attributeName',
+    repeatcount: 'repeatCount',
+    repeatdur: 'repeatDur'
+  }
+}
+const tagSelector={}
+const {
+  windowWidth,
+  // #ifdef MP-WEIXIN
+  system
+  // #endif
+} = uni.getSystemInfoSync()
+const blankChar = makeMap(' ,\r,\n,\t,\f')
+let idIndex = 0
+
+// #ifdef H5 || APP-PLUS
+config.ignoreTags.iframe = undefined
+config.trustTags.iframe = true
+config.ignoreTags.embed = undefined
+config.trustTags.embed = true
+// #endif
+// #ifdef APP-PLUS-NVUE
+config.ignoreTags.source = undefined
+config.ignoreTags.style = undefined
+// #endif
+
+/**
+ * @description 创建 map
+ * @param {String} str 逗号分隔
+ */
+function makeMap (str) {
+  const map = Object.create(null)
+  const list = str.split(',')
+  for (let i = list.length; i--;) {
+    map[list[i]] = true
+  }
+  return map
+}
+
+/**
+ * @description 解码 html 实体
+ * @param {String} str 要解码的字符串
+ * @param {Boolean} amp 要不要解码 &amp;
+ * @returns {String} 解码后的字符串
+ */
+function decodeEntity (str, amp) {
+  let i = str.indexOf('&')
+  while (i !== -1) {
+    const j = str.indexOf(';', i + 3)
+    let code
+    if (j === -1) break
+    if (str[i + 1] === '#') {
+      // &#123; 形式的实体
+      code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j))
+      if (!isNaN(code)) {
+        str = str.substr(0, i) + String.fromCharCode(code) + str.substr(j + 1)
+      }
+    } else {
+      // &nbsp; 形式的实体
+      code = str.substring(i + 1, j)
+      if (config.entities[code] || (code === 'amp' && amp)) {
+        str = str.substr(0, i) + (config.entities[code] || '&') + str.substr(j + 1)
+      }
+    }
+    i = str.indexOf('&', i + 1)
+  }
+  return str
+}
+
+/**
+ * @description 合并多个块级标签,加快长内容渲染
+ * @param {Array} nodes 要合并的标签数组
+ */
+function mergeNodes (nodes) {
+  let i = nodes.length - 1
+  for (let j = i; j >= -1; j--) {
+    if (j === -1 || nodes[j].c || !nodes[j].name || (nodes[j].name !== 'div' && nodes[j].name !== 'p' && nodes[j].name[0] !== 'h') || (nodes[j].attrs.style || '').includes('inline')) {
+      if (i - j >= 5) {
+        nodes.splice(j + 1, i - j, {
+          name: 'div',
+          attrs: {},
+          children: nodes.slice(j + 1, i + 1)
+        })
+      }
+      i = j - 1
+    }
+  }
+}
+
+/**
+ * @description html 解析器
+ * @param {Object} vm 组件实例
+ */
+function Parser (vm) {
+  this.options = vm || {}
+  this.tagStyle = Object.assign({}, config.tagStyle, this.options.tagStyle)
+  this.imgList = vm.imgList || []
+  this.imgList._unloadimgs = 0
+  this.plugins = vm.plugins || []
+  this.attrs = Object.create(null)
+  this.stack = []
+  this.nodes = []
+  this.pre = (this.options.containerStyle || '').includes('white-space') && this.options.containerStyle.includes('pre') ? 2 : 0
+}
+
+/**
+ * @description 执行解析
+ * @param {String} content 要解析的文本
+ */
+Parser.prototype.parse = function (content) {
+  // 插件处理
+  for (let i = this.plugins.length; i--;) {
+    if (this.plugins[i].onUpdate) {
+      content = this.plugins[i].onUpdate(content, config) || content
+    }
+  }
+
+  new Lexer(this).parse(content)
+  // 出栈未闭合的标签
+  while (this.stack.length) {
+    this.popNode()
+  }
+  if (this.nodes.length > 50) {
+    mergeNodes(this.nodes)
+  }
+  return this.nodes
+}
+
+/**
+ * @description 将标签暴露出来(不被 rich-text 包含)
+ */
+Parser.prototype.expose = function () {
+  // #ifndef APP-PLUS-NVUE
+  for (let i = this.stack.length; i--;) {
+    const item = this.stack[i]
+    if (item.c || item.name === 'a' || item.name === 'video' || item.name === 'audio') return
+    item.c = 1
+  }
+  // #endif
+}
+
+/**
+ * @description 处理插件
+ * @param {Object} node 要处理的标签
+ * @returns {Boolean} 是否要移除此标签
+ */
+Parser.prototype.hook = function (node) {
+  for (let i = this.plugins.length; i--;) {
+    if (this.plugins[i].onParse && this.plugins[i].onParse(node, this) === false) {
+      return false
+    }
+  }
+  return true
+}
+
+/**
+ * @description 将链接拼接上主域名
+ * @param {String} url 需要拼接的链接
+ * @returns {String} 拼接后的链接
+ */
+Parser.prototype.getUrl = function (url) {
+  const domain = this.options.domain
+  if (url[0] === '/') {
+    if (url[1] === '/') {
+      // // 开头的补充协议名
+      url = (domain ? domain.split('://')[0] : 'http') + ':' + url
+    } else if (domain) {
+      // 否则补充整个域名
+      url = domain + url
+    } /* #ifdef APP-PLUS */ else {
+      url = plus.io.convertLocalFileSystemURL(url)
+    } /* #endif */
+  } else if (!url.includes('data:') && !url.includes('://')) {
+    if (domain) {
+      url = domain + '/' + url
+    } /* #ifdef APP-PLUS */ else {
+      url = plus.io.convertLocalFileSystemURL(url)
+    } /* #endif */
+  }
+  return url
+}
+
+/**
+ * @description 解析样式表
+ * @param {Object} node 标签
+ * @returns {Object}
+ */
+Parser.prototype.parseStyle = function (node) {
+  const attrs = node.attrs
+  const list = (this.tagStyle[node.name] || '').split(';').concat((attrs.style || '').split(';'))
+  const styleObj = {}
+  let tmp = ''
+
+  if (attrs.id && !this.xml) {
+    // 暴露锚点
+    if (this.options.useAnchor) {
+      this.expose()
+    } else if (node.name !== 'img' && node.name !== 'a' && node.name !== 'video' && node.name !== 'audio') {
+      attrs.id = undefined
+    }
+  }
+
+  // 转换 width 和 height 属性
+  if (attrs.width) {
+    styleObj.width = parseFloat(attrs.width) + (attrs.width.includes('%') ? '%' : 'px')
+    attrs.width = undefined
+  }
+  if (attrs.height) {
+    styleObj.height = parseFloat(attrs.height) + (attrs.height.includes('%') ? '%' : 'px')
+    attrs.height = undefined
+  }
+
+  for (let i = 0, len = list.length; i < len; i++) {
+    const info = list[i].split(':')
+    if (info.length < 2) continue
+    const key = info.shift().trim().toLowerCase()
+    let value = info.join(':').trim()
+    if ((value[0] === '-' && value.lastIndexOf('-') > 0) || value.includes('safe')) {
+      // 兼容性的 css 不压缩
+      tmp += `;${key}:${value}`
+    } else if (!styleObj[key] || value.includes('import') || !styleObj[key].includes('import')) {
+      // 重复的样式进行覆盖
+      if (value.includes('url')) {
+        // 填充链接
+        let j = value.indexOf('(') + 1
+        if (j) {
+          while (value[j] === '"' || value[j] === "'" || blankChar[value[j]]) {
+            j++
+          }
+          value = value.substr(0, j) + this.getUrl(value.substr(j))
+        }
+      } else if (value.includes('rpx')) {
+        // 转换 rpx(rich-text 内部不支持 rpx)
+        value = value.replace(/[0-9.]+\s*rpx/g, $ => parseFloat($) * windowWidth / 750 + 'px')
+      }
+      styleObj[key] = value
+    }
+  }
+
+  node.attrs.style = tmp
+  return styleObj
+}
+
+/**
+ * @description 解析到标签名
+ * @param {String} name 标签名
+ * @private
+ */
+Parser.prototype.onTagName = function (name) {
+  this.tagName = this.xml ? name : name.toLowerCase()
+  if (this.tagName === 'svg') {
+    this.xml = (this.xml || 0) + 1 // svg 标签内大小写敏感
+  }
+}
+
+/**
+ * @description 解析到属性名
+ * @param {String} name 属性名
+ * @private
+ */
+Parser.prototype.onAttrName = function (name) {
+  name = this.xml ? name : name.toLowerCase()
+  if (name.substr(0, 5) === 'data-') {
+    if (name === 'data-src' && !this.attrs.src) {
+      // data-src 自动转为 src
+      this.attrName = 'src'
+    } else if (this.tagName === 'img' || this.tagName === 'a') {
+      // a 和 img 标签保留 data- 的属性,可以在 imgtap 和 linktap 事件中使用
+      this.attrName = name
+    } else {
+      // 剩余的移除以减小大小
+      this.attrName = undefined
+    }
+  } else {
+    this.attrName = name
+    this.attrs[name] = 'T' // boolean 型属性缺省设置
+  }
+}
+
+/**
+ * @description 解析到属性值
+ * @param {String} val 属性值
+ * @private
+ */
+Parser.prototype.onAttrVal = function (val) {
+  const name = this.attrName || ''
+  if (name === 'style' || name === 'href') {
+    // 部分属性进行实体解码
+    this.attrs[name] = decodeEntity(val, true)
+  } else if (name.includes('src')) {
+    // 拼接主域名
+    this.attrs[name] = this.getUrl(decodeEntity(val, true))
+  } else if (name) {
+    this.attrs[name] = val
+  }
+}
+
+/**
+ * @description 解析到标签开始
+ * @param {Boolean} selfClose 是否有自闭合标识 />
+ * @private
+ */
+Parser.prototype.onOpenTag = function (selfClose) {
+  // 拼装 node
+  const node = Object.create(null)
+  node.name = this.tagName
+  node.attrs = this.attrs
+  // 避免因为自动 diff 使得 type 被设置为 null 导致部分内容不显示
+  if (this.options.nodes.length) {
+    node.type = 'node'
+  }
+  this.attrs = Object.create(null)
+
+  const attrs = node.attrs
+  const parent = this.stack[this.stack.length - 1]
+  const siblings = parent ? parent.children : this.nodes
+  const close = this.xml ? selfClose : config.voidTags[node.name]
+
+  // 替换标签名选择器
+  if (tagSelector[node.name]) {
+    attrs.class = tagSelector[node.name] + (attrs.class ? ' ' + attrs.class : '')
+  }
+
+  // 转换 embed 标签
+  if (node.name === 'embed') {
+    // #ifndef H5 || APP-PLUS
+    const src = attrs.src || ''
+    // 按照后缀名和 type 将 embed 转为 video 或 audio
+    if (src.includes('.mp4') || src.includes('.3gp') || src.includes('.m3u8') || (attrs.type || '').includes('video')) {
+      node.name = 'video'
+    } else if (src.includes('.mp3') || src.includes('.wav') || src.includes('.aac') || src.includes('.m4a') || (attrs.type || '').includes('audio')) {
+      node.name = 'audio'
+    }
+    if (attrs.autostart) {
+      attrs.autoplay = 'T'
+    }
+    attrs.controls = 'T'
+    // #endif
+    // #ifdef H5 || APP-PLUS
+    this.expose()
+    // #endif
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  // 处理音视频
+  if (node.name === 'video' || node.name === 'audio') {
+    // 设置 id 以便获取 context
+    if (node.name === 'video' && !attrs.id) {
+      attrs.id = 'v' + idIndex++
+    }
+    // 没有设置 controls 也没有设置 autoplay 的自动设置 controls
+    if (!attrs.controls && !attrs.autoplay) {
+      attrs.controls = 'T'
+    }
+    // 用数组存储所有可用的 source
+    node.src = []
+    if (attrs.src) {
+      node.src.push(attrs.src)
+      attrs.src = undefined
+    }
+    this.expose()
+  }
+  // #endif
+
+  // 处理自闭合标签
+  if (close) {
+    if (!this.hook(node) || config.ignoreTags[node.name]) {
+      // 通过 base 标签设置主域名
+      if (node.name === 'base' && !this.options.domain) {
+        this.options.domain = attrs.href
+      } /* #ifndef APP-PLUS-NVUE */ else if (node.name === 'source' && parent && (parent.name === 'video' || parent.name === 'audio') && attrs.src) {
+        // 设置 source 标签(仅父节点为 video 或 audio 时有效)
+        parent.src.push(attrs.src)
+      } /* #endif */
+      return
+    }
+
+    // 解析 style
+    const styleObj = this.parseStyle(node)
+
+    // 处理图片
+    if (node.name === 'img') {
+      if (attrs.src) {
+        // 标记 webp
+        if (attrs.src.includes('webp')) {
+          node.webp = 'T'
+        }
+        // data url 图片如果没有设置 original-src 默认为不可预览的小图片
+        if (attrs.src.includes('data:') && !attrs['original-src']) {
+          attrs.ignore = 'T'
+        }
+        if (!attrs.ignore || node.webp || attrs.src.includes('cloud://')) {
+          for (let i = this.stack.length; i--;) {
+            const item = this.stack[i]
+            if (item.name === 'a') {
+              node.a = item.attrs
+            }
+            if (item.name === 'table' && !node.webp && !attrs.src.includes('cloud://')) {
+              if (!styleObj.display || styleObj.display.includes('inline')) {
+                node.t = 'inline-block'
+              } else {
+                node.t = styleObj.display
+              }
+              styleObj.display = undefined
+            }
+            // #ifndef H5 || APP-PLUS
+            const style = item.attrs.style || ''
+            if (style.includes('flex:') && !style.includes('flex:0') && !style.includes('flex: 0') && (!styleObj.width || parseInt(styleObj.width) > 100)) {
+              styleObj.width = '100% !important'
+              styleObj.height = ''
+              for (let j = i + 1; j < this.stack.length; j++) {
+                this.stack[j].attrs.style = (this.stack[j].attrs.style || '').replace('inline-', '')
+              }
+            } else if (style.includes('flex') && styleObj.width === '100%') {
+              for (let j = i + 1; j < this.stack.length; j++) {
+                const style = this.stack[j].attrs.style || ''
+                if (!style.includes(';width') && !style.includes(' width') && style.indexOf('width') !== 0) {
+                  styleObj.width = ''
+                  break
+                }
+              }
+            } else if (style.includes('inline-block')) {
+              if (styleObj.width && styleObj.width[styleObj.width.length - 1] === '%') {
+                item.attrs.style += ';max-width:' + styleObj.width
+                styleObj.width = ''
+              } else {
+                item.attrs.style += ';max-width:100%'
+              }
+            }
+            // #endif
+            item.c = 1
+          }
+          attrs.i = this.imgList.length.toString()
+          let src = attrs['original-src'] || attrs.src
+          // #ifndef H5 || MP-ALIPAY || APP-PLUS || MP-360
+          if (this.imgList.includes(src)) {
+            // 如果有重复的链接则对域名进行随机大小写变换避免预览时错位
+            let i = src.indexOf('://')
+            if (i !== -1) {
+              i += 3
+              let newSrc = src.substr(0, i)
+              for (; i < src.length; i++) {
+                if (src[i] === '/') break
+                newSrc += Math.random() > 0.5 ? src[i].toUpperCase() : src[i]
+              }
+              newSrc += src.substr(i)
+              src = newSrc
+            }
+          }
+          // #endif
+          this.imgList.push(src)
+          if (!node.t) {
+            this.imgList._unloadimgs += 1
+          }
+          // #ifdef H5 || APP-PLUS
+          if (this.options.lazyLoad) {
+            attrs['data-src'] = attrs.src
+            attrs.src = undefined
+          }
+          // #endif
+        }
+      }
+      if (styleObj.display === 'inline') {
+        styleObj.display = ''
+      }
+      // #ifndef APP-PLUS-NVUE
+      if (attrs.ignore) {
+        styleObj['max-width'] = styleObj['max-width'] || '100%'
+        attrs.style += ';-webkit-touch-callout:none'
+      }
+      // #endif
+      // 设置的宽度超出屏幕,为避免变形,高度转为自动
+      if (parseInt(styleObj.width) > windowWidth) {
+        styleObj.height = undefined
+      }
+      // 记录是否设置了宽高
+      if (!isNaN(parseInt(styleObj.width))) {
+        node.w = 'T'
+      }
+      if (!isNaN(parseInt(styleObj.height)) && (!styleObj.height.includes('%') || (parent && (parent.attrs.style || '').includes('height')))) {
+        node.h = 'T'
+      }
+    } else if (node.name === 'svg') {
+      siblings.push(node)
+      this.stack.push(node)
+      this.popNode()
+      return
+    }
+    for (const key in styleObj) {
+      if (styleObj[key]) {
+        attrs.style += `;${key}:${styleObj[key].replace(' !important', '')}`
+      }
+    }
+    attrs.style = attrs.style.substr(1) || undefined
+    // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
+    if (!attrs.style) {
+      delete attrs.style
+    }
+    // #endif
+  } else {
+    if ((node.name === 'pre' || ((attrs.style || '').includes('white-space') && attrs.style.includes('pre'))) && this.pre !== 2) {
+      this.pre = node.pre = 1
+    }
+    node.children = []
+    this.stack.push(node)
+  }
+
+  // 加入节点树
+  siblings.push(node)
+}
+
+/**
+ * @description 解析到标签结束
+ * @param {String} name 标签名
+ * @private
+ */
+Parser.prototype.onCloseTag = function (name) {
+  // 依次出栈到匹配为止
+  name = this.xml ? name : name.toLowerCase()
+  let i
+  for (i = this.stack.length; i--;) {
+    if (this.stack[i].name === name) break
+  }
+  if (i !== -1) {
+    while (this.stack.length > i) {
+      this.popNode()
+    }
+  } else if (name === 'p' || name === 'br') {
+    const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
+    siblings.push({
+      name,
+      attrs: {
+        class: tagSelector[name] || '',
+        style: this.tagStyle[name] || ''
+      }
+    })
+  }
+}
+
+/**
+ * @description 处理标签出栈
+ * @private
+ */
+Parser.prototype.popNode = function () {
+  const editable = this.options.editable
+  const node = this.stack.pop()
+  let attrs = node.attrs
+  const children = node.children
+  const parent = this.stack[this.stack.length - 1]
+  const siblings = parent ? parent.children : this.nodes
+
+  if (!this.hook(node) || config.ignoreTags[node.name]) {
+    // 获取标题
+    if (node.name === 'title' && children.length && children[0].type === 'text' && this.options.setTitle) {
+      uni.setNavigationBarTitle({
+        title: children[0].text
+      })
+    }
+    siblings.pop()
+    return
+  }
+
+  if (node.pre && this.pre !== 2) {
+    // 是否合并空白符标识
+    this.pre = node.pre = undefined
+    for (let i = this.stack.length; i--;) {
+      if (this.stack[i].pre) {
+        this.pre = 1
+      }
+    }
+  }
+
+  const styleObj = {}
+
+  // 转换 svg
+  if (node.name === 'svg') {
+    if (this.xml > 1) {
+      // 多层 svg 嵌套
+      this.xml--
+      return
+    }
+    // #ifdef APP-PLUS-NVUE
+    (function traversal (node) {
+      if (node.name) {
+        // 调整 svg 的大小写
+        node.name = config.svgDict[node.name] || node.name
+        for (const item in node.attrs) {
+          if (config.svgDict[item]) {
+            node.attrs[config.svgDict[item]] = node.attrs[item]
+            node.attrs[item] = undefined
+          }
+        }
+        for (let i = 0; i < (node.children || []).length; i++) {
+          traversal(node.children[i])
+        }
+      }
+    })(node)
+    // #endif
+    // #ifndef APP-PLUS-NVUE
+    let src = ''
+    const style = attrs.style
+    attrs.style = ''
+    attrs.xmlns = 'http://www.w3.org/2000/svg';
+    (function traversal (node) {
+      if (node.type === 'text') {
+        src += node.text
+        return
+      }
+      const name = config.svgDict[node.name] || node.name
+      src += '<' + name
+      for (const item in node.attrs) {
+        const val = node.attrs[item]
+        if (val) {
+          src += ` ${config.svgDict[item] || item}="${val}"`
+        }
+      }
+      if (!node.children) {
+        src += '/>'
+      } else {
+        src += '>'
+        for (let i = 0; i < node.children.length; i++) {
+          traversal(node.children[i])
+        }
+        src += '</' + name + '>'
+      }
+    })(node)
+    node.name = 'img'
+    node.attrs = {
+      src: 'data:image/svg+xml;utf8,' + src.replace(/#/g, '%23'),
+      style,
+      ignore: 'T'
+    }
+    node.children = undefined
+    // #endif
+    this.xml = false
+    return
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  // 转换 align 属性
+  if (attrs.align) {
+    if (node.name === 'table') {
+      if (attrs.align === 'center') {
+        styleObj['margin-inline-start'] = styleObj['margin-inline-end'] = 'auto'
+      } else {
+        styleObj.float = attrs.align
+      }
+    } else {
+      styleObj['text-align'] = attrs.align
+    }
+    attrs.align = undefined
+  }
+
+  // 转换 dir 属性
+  if (attrs.dir) {
+    styleObj.direction = attrs.dir
+    attrs.dir = undefined
+  }
+
+  // 转换 font 标签的属性
+  if (node.name === 'font') {
+    if (attrs.color) {
+      styleObj.color = attrs.color
+      attrs.color = undefined
+    }
+    if (attrs.face) {
+      styleObj['font-family'] = attrs.face
+      attrs.face = undefined
+    }
+    if (attrs.size) {
+      let size = parseInt(attrs.size)
+      if (!isNaN(size)) {
+        if (size < 1) {
+          size = 1
+        } else if (size > 7) {
+          size = 7
+        }
+        styleObj['font-size'] = ['x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large'][size - 1]
+      }
+      attrs.size = undefined
+    }
+  }
+  // #endif
+
+  // 一些编辑器的自带 class
+  if ((attrs.class || '').includes('align-center')) {
+    styleObj['text-align'] = 'center'
+  }
+
+  Object.assign(styleObj, this.parseStyle(node))
+
+  if (node.name !== 'table' && parseInt(styleObj.width) > windowWidth) {
+    styleObj['max-width'] = '100%'
+    styleObj['box-sizing'] = 'border-box'
+  }
+
+  // #ifndef APP-PLUS-NVUE
+  if (config.blockTags[node.name]) {
+    if (!editable) {
+      node.name = 'div'
+    }
+  } else if (!config.trustTags[node.name] && !this.xml) {
+    // 未知标签转为 span,避免无法显示
+    node.name = 'span'
+  }
+
+  if (node.name === 'a' || node.name === 'ad'
+    // #ifdef H5 || APP-PLUS
+    || node.name === 'iframe' // eslint-disable-line
+    // #endif
+  ) {
+    this.expose()
+  } else if (node.name === 'video') {
+    if ((styleObj.height || '').includes('auto')) {
+      styleObj.height = undefined
+    }
+    /* #ifdef APP-PLUS */
+    let str = '<video style="width:100%;height:100%"'
+    if (editable) {
+      attrs.controls = ''
+    }
+    for (const item in attrs) {
+      if (attrs[item]) {
+        str += ' ' + item + '="' + attrs[item] + '"'
+      }
+    }
+    if (this.options.pauseVideo) {
+      str += ' onplay="this.dispatchEvent(new CustomEvent(\'vplay\',{bubbles:!0}));for(var e=document.getElementsByTagName(\'video\'),t=0;t<e.length;t++)e[t]!=this&&e[t].pause()"'
+    }
+    str += '>'
+    for (let i = 0; i < node.src.length; i++) {
+      str += '<source src="' + node.src[i] + '">'
+    }
+    str += '</video>'
+    node.html = str
+    /* #endif */
+  } else if ((node.name === 'ul' || node.name === 'ol') && (node.c || editable)) {
+    // 列表处理
+    const types = {
+      a: 'lower-alpha',
+      A: 'upper-alpha',
+      i: 'lower-roman',
+      I: 'upper-roman'
+    }
+    if (types[attrs.type]) {
+      attrs.style += ';list-style-type:' + types[attrs.type]
+      attrs.type = undefined
+    }
+    for (let i = children.length; i--;) {
+      if (children[i].name === 'li') {
+        children[i].c = 1
+      }
+    }
+  } else if (node.name === 'table') {
+    // 表格处理
+    // cellpadding、cellspacing、border 这几个常用表格属性需要通过转换实现
+    let padding = parseFloat(attrs.cellpadding)
+    let spacing = parseFloat(attrs.cellspacing)
+    const border = parseFloat(attrs.border)
+    const bordercolor = styleObj['border-color']
+    const borderstyle = styleObj['border-style']
+    if ((node.c || editable)) {
+      // padding 和 spacing 默认 2
+      if (isNaN(padding)) {
+        padding = 2
+      }
+      if (isNaN(spacing)) {
+        spacing = 2
+      }
+    }
+    if (border) {
+      attrs.style += `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}`
+    }
+    if (node.flag && (node.c || editable)) {
+      // 有 colspan 或 rowspan 且含有链接的表格通过 grid 布局实现
+      styleObj.display = 'grid'
+      if (spacing) {
+        styleObj['grid-gap'] = spacing + 'px'
+        styleObj.padding = spacing + 'px'
+      } else if (border) {
+        // 无间隔的情况下避免边框重叠
+        attrs.style += ';border-left:0;border-top:0'
+      }
+
+      const width = [] // 表格的列宽
+      const trList = [] // tr 列表
+      const cells = [] // 保存新的单元格
+      const map = {}; // 被合并单元格占用的格子
+
+      (function traversal (nodes) {
+        for (let i = 0; i < nodes.length; i++) {
+          if (nodes[i].name === 'tr') {
+            trList.push(nodes[i])
+          } else {
+            traversal(nodes[i].children || [])
+          }
+        }
+      })(children)
+
+      for (let row = 1; row <= trList.length; row++) {
+        let col = 1
+        for (let j = 0; j < trList[row - 1].children.length; j++) {
+          const td = trList[row - 1].children[j]
+          if (td.name === 'td' || td.name === 'th') {
+            // 这个格子被上面的单元格占用,则列号++
+            while (map[row + '.' + col]) {
+            col++
+          }
+          if (editable) {
+            td.r = row
+          }
+            let style = td.attrs.style || ''
+            let start = style.indexOf('width') ? style.indexOf(';width') : 0
+            // 提取出 td 的宽度
+            if (start !== -1) {
+              let end = style.indexOf(';', start + 6)
+              if (end === -1) {
+                end = style.length
+              }
+              if (!td.attrs.colspan) {
+                width[col] = style.substring(start ? start + 7 : 6, end)
+              }
+              style = style.substr(0, start) + style.substr(end)
+            }
+            // 设置竖直对齐
+            style += ';display:flex'
+            start = style.indexOf('vertical-align')
+            if (start !== -1) {
+              const val = style.substr(start + 15, 10)
+              if (val.includes('middle')) {
+                style += ';align-items:center'
+              } else if (val.includes('bottom')) {
+                style += ';align-items:flex-end'
+              }
+            } else {
+              style += ';align-items:center'
+            }
+            // 设置水平对齐
+            start = style.indexOf('text-align')
+            if (start !== -1) {
+              const val = style.substr(start + 11, 10)
+              if (val.includes('center')) {
+                style += ';justify-content: center'
+              } else if (val.includes('right')) {
+                style += ';justify-content: right'
+              }
+            }
+            style = (border ? `;border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'}` + (spacing ? '' : ';border-right:0;border-bottom:0') : '') + (padding ? `;padding:${padding}px` : '') + ';' + style
+            // 处理列合并
+            if (td.attrs.colspan) {
+              style += `;grid-column-start:${col};grid-column-end:${col + parseInt(td.attrs.colspan)}`
+              if (!td.attrs.rowspan) {
+                style += `;grid-row-start:${row};grid-row-end:${row + 1}`
+              }
+              col += parseInt(td.attrs.colspan) - 1
+            }
+            // 处理行合并
+            if (td.attrs.rowspan) {
+              style += `;grid-row-start:${row};grid-row-end:${row + parseInt(td.attrs.rowspan)}`
+              if (!td.attrs.colspan) {
+                style += `;grid-column-start:${col};grid-column-end:${col + 1}`
+              }
+              // 记录下方单元格被占用
+              for (let rowspan = 1; rowspan < td.attrs.rowspan; rowspan++) {
+                for (let colspan = 0; colspan < (td.attrs.colspan || 1); colspan++) {
+                  map[(row + rowspan) + '.' + (col - colspan)] = 1
+                }
+              }
+            }
+            if (style) {
+              td.attrs.style = style
+            }
+            cells.push(td)
+            col++
+          }
+        }
+        if (row === 1) {
+          let temp = ''
+          for (let i = 1; i < col; i++) {
+            temp += (width[i] ? width[i] : 'auto') + ' '
+          }
+          styleObj['grid-template-columns'] = temp
+        }
+      }
+      node.children = cells
+    } else {
+      // 没有使用合并单元格的表格通过 table 布局实现
+      if ((node.c || editable)) {
+        styleObj.display = 'table'
+      }
+      if (!isNaN(spacing)) {
+        styleObj['border-spacing'] = spacing + 'px'
+      }
+      if (border || padding) {
+        // 遍历
+        (function traversal (nodes) {
+          for (let i = 0; i < nodes.length; i++) {
+            const td = nodes[i]
+            if (td.name === 'th' || td.name === 'td') {
+              if (border) {
+                td.attrs.style = `border:${border}px ${borderstyle || 'solid'} ${bordercolor || 'gray'};${td.attrs.style || ''}`
+              }
+              if (padding) {
+                td.attrs.style = `padding:${padding}px;${td.attrs.style || ''}`
+              }
+            } else if (td.children) {
+              traversal(td.children)
+            }
+          }
+        })(children)
+      }
+    }
+    // 给表格添加一个单独的横向滚动层
+    if (this.options.scrollTable && !(attrs.style || '').includes('inline')) {
+      const table = Object.assign({}, node)
+      node.name = 'div'
+      node.attrs = {
+        style: 'overflow:auto'
+      }
+      node.children = [table]
+      attrs = table.attrs
+    }
+  } else if ((node.name === 'td' || node.name === 'th') && (attrs.colspan || attrs.rowspan)) {
+    for (let i = this.stack.length; i--;) {
+      if (this.stack[i].name === 'table') {
+        this.stack[i].flag = 1 // 指示含有合并单元格
+        break
+      }
+    }
+  } else if (node.name === 'ruby') {
+    // 转换 ruby
+    node.name = 'span'
+    for (let i = 0; i < children.length - 1; i++) {
+      if (children[i].type === 'text' && children[i + 1].name === 'rt') {
+        children[i] = {
+          name: 'div',
+          attrs: {
+            style: 'display:inline-block;text-align:center'
+          },
+          children: [{
+            name: 'div',
+            attrs: {
+              style: 'font-size:50%;' + (children[i + 1].attrs.style || '')
+            },
+            children: children[i + 1].children
+          }, children[i]]
+        }
+        children.splice(i + 1, 1)
+      }
+    }
+  } else if (!editable && node.c ) {
+    (function traversal (node) {
+      node.c = 2
+      for (let i = node.children.length; i--;) {
+        const child = node.children[i]
+        // #ifdef (MP-WEIXIN || MP-QQ || APP-PLUS || MP-360) && VUE3
+        if (child.name && (config.inlineTags[child.name] || ((child.attrs.style || '').includes('inline') && child.children)) && !child.c) {
+          traversal(child)
+        }
+        // #endif
+        if (!child.c || child.name === 'table') {
+          node.c = 1
+        }
+      }
+    })(node)
+  }
+
+  if ((styleObj.display || '').includes('flex') && !(node.c || editable)) {
+    for (let i = children.length; i--;) {
+      const item = children[i]
+      if (item.f) {
+        item.attrs.style = (item.attrs.style || '') + item.f
+        item.f = undefined
+      }
+    }
+  }
+  // flex 布局时部分样式需要提取到 rich-text 外层
+  const flex = parent && ((parent.attrs.style || '').includes('flex') || (parent.attrs.style || '').includes('grid'))
+    // #ifdef MP-WEIXIN
+    // 检查基础库版本 virtualHost 是否可用
+    && !((node.c || editable) && wx.getNFCAdapter) // eslint-disable-line
+    // #endif
+    // #ifndef MP-WEIXIN || MP-QQ || MP-BAIDU || MP-TOUTIAO
+    && !node.c // eslint-disable-line
+  // #endif
+  if (flex) {
+    node.f = ';max-width:100%'
+  }
+
+  if (children.length >= 50 && (node.c || editable) && !(styleObj.display || '').includes('flex')) {
+    mergeNodes(children)
+  }
+  // #endif
+
+  for (const key in styleObj) {
+    if (styleObj[key]) {
+      const val = `;${key}:${styleObj[key].replace(' !important', '')}`
+      /* #ifndef APP-PLUS-NVUE */
+      if (flex && ((key.includes('flex') && key !== 'flex-direction') || key === 'align-self' || key.includes('grid') || styleObj[key][0] === '-' || (key.includes('width') && val.includes('%')))) {
+        node.f += val
+        if (key === 'width') {
+          attrs.style += ';width:100%'
+        }
+      } else /* #endif */ {
+        attrs.style += val
+      }
+    }
+  }
+  attrs.style = attrs.style.substr(1) || undefined
+  // #ifdef (MP-WEIXIN || MP-QQ) && VUE3
+  for (const key in attrs) {
+    if (!attrs[key]) {
+      delete attrs[key]
+    }
+  }
+  // #endif
+}
+
+/**
+ * @description 解析到文本
+ * @param {String} text 文本内容
+ */
+Parser.prototype.onText = function (text) {
+  if (!this.pre) {
+    // 合并空白符
+    let trim = ''
+    let flag
+    for (let i = 0, len = text.length; i < len; i++) {
+      if (!blankChar[text[i]]) {
+        trim += text[i]
+      } else {
+        if (trim[trim.length - 1] !== ' ') {
+          trim += ' '
+        }
+        if (text[i] === '\n' && !flag) {
+          flag = true
+        }
+      }
+    }
+    // 去除含有换行符的空串
+    if (trim === ' ') {
+      if (flag) return
+      // #ifdef VUE3
+      else {
+        const parent = this.stack[this.stack.length - 1]
+        if (parent && parent.name[0] === 't') return
+      }
+      // #endif
+    }
+    text = trim
+  }
+  const node = Object.create(null)
+  node.type = 'text'
+  // #ifdef (MP-BAIDU || MP-ALIPAY || MP-TOUTIAO) && VUE3
+  node.attrs = {}
+  // #endif
+  node.text = decodeEntity(text)
+  if (this.hook(node)) {
+    // #ifdef MP-WEIXIN
+    if (this.options.selectable === 'force' && system.includes('iOS') && !uni.canIUse('rich-text.user-select')) {
+      this.expose()
+    }
+    // #endif
+    const siblings = this.stack.length ? this.stack[this.stack.length - 1].children : this.nodes
+    siblings.push(node)
+  }
+}
+
+/**
+ * @description html 词法分析器
+ * @param {Object} handler 高层处理器
+ */
+function Lexer (handler) {
+  this.handler = handler
+}
+
+/**
+ * @description 执行解析
+ * @param {String} content 要解析的文本
+ */
+Lexer.prototype.parse = function (content) {
+  this.content = content || ''
+  this.i = 0 // 标记解析位置
+  this.start = 0 // 标记一个单词的开始位置
+  this.state = this.text // 当前状态
+  for (let len = this.content.length; this.i !== -1 && this.i < len;) {
+    this.state()
+  }
+}
+
+/**
+ * @description 检查标签是否闭合
+ * @param {String} method 如果闭合要进行的操作
+ * @returns {Boolean} 是否闭合
+ * @private
+ */
+Lexer.prototype.checkClose = function (method) {
+  const selfClose = this.content[this.i] === '/'
+  if (this.content[this.i] === '>' || (selfClose && this.content[this.i + 1] === '>')) {
+    if (method) {
+      this.handler[method](this.content.substring(this.start, this.i))
+    }
+    this.i += selfClose ? 2 : 1
+    this.start = this.i
+    this.handler.onOpenTag(selfClose)
+    if (this.handler.tagName === 'script') {
+      this.i = this.content.indexOf('</', this.i)
+      if (this.i !== -1) {
+        this.i += 2
+        this.start = this.i
+      }
+      this.state = this.endTag
+    } else {
+      this.state = this.text
+    }
+    return true
+  }
+  return false
+}
+
+/**
+ * @description 文本状态
+ * @private
+ */
+Lexer.prototype.text = function () {
+  this.i = this.content.indexOf('<', this.i) // 查找最近的标签
+  if (this.i === -1) {
+    // 没有标签了
+    if (this.start < this.content.length) {
+      this.handler.onText(this.content.substring(this.start, this.content.length))
+    }
+    return
+  }
+  const c = this.content[this.i + 1]
+  if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
+    // 标签开头
+    if (this.start !== this.i) {
+      this.handler.onText(this.content.substring(this.start, this.i))
+    }
+    this.start = ++this.i
+    this.state = this.tagName
+  } else if (c === '/' || c === '!' || c === '?') {
+    if (this.start !== this.i) {
+      this.handler.onText(this.content.substring(this.start, this.i))
+    }
+    const next = this.content[this.i + 2]
+    if (c === '/' && ((next >= 'a' && next <= 'z') || (next >= 'A' && next <= 'Z'))) {
+      // 标签结尾
+      this.i += 2
+      this.start = this.i
+      this.state = this.endTag
+      return
+    }
+    // 处理注释
+    let end = '-->'
+    if (c !== '!' || this.content[this.i + 2] !== '-' || this.content[this.i + 3] !== '-') {
+      end = '>'
+    }
+    this.i = this.content.indexOf(end, this.i)
+    if (this.i !== -1) {
+      this.i += end.length
+      this.start = this.i
+    }
+  } else {
+    this.i++
+  }
+}
+
+/**
+ * @description 标签名状态
+ * @private
+ */
+Lexer.prototype.tagName = function () {
+  if (blankChar[this.content[this.i]]) {
+    // 解析到标签名
+    this.handler.onTagName(this.content.substring(this.start, this.i))
+    while (blankChar[this.content[++this.i]]);
+    if (this.i < this.content.length && !this.checkClose()) {
+      this.start = this.i
+      this.state = this.attrName
+    }
+  } else if (!this.checkClose('onTagName')) {
+    this.i++
+  }
+}
+
+/**
+ * @description 属性名状态
+ * @private
+ */
+Lexer.prototype.attrName = function () {
+  let c = this.content[this.i]
+  if (blankChar[c] || c === '=') {
+    // 解析到属性名
+    this.handler.onAttrName(this.content.substring(this.start, this.i))
+    let needVal = c === '='
+    const len = this.content.length
+    while (++this.i < len) {
+      c = this.content[this.i]
+      if (!blankChar[c]) {
+        if (this.checkClose()) return
+        if (needVal) {
+          // 等号后遇到第一个非空字符
+          this.start = this.i
+          this.state = this.attrVal
+          return
+        }
+        if (this.content[this.i] === '=') {
+          needVal = true
+        } else {
+          this.start = this.i
+          this.state = this.attrName
+          return
+        }
+      }
+    }
+  } else if (!this.checkClose('onAttrName')) {
+    this.i++
+  }
+}
+
+/**
+ * @description 属性值状态
+ * @private
+ */
+Lexer.prototype.attrVal = function () {
+  const c = this.content[this.i]
+  const len = this.content.length
+  if (c === '"' || c === "'") {
+    // 有冒号的属性
+    this.start = ++this.i
+    this.i = this.content.indexOf(c, this.i)
+    if (this.i === -1) return
+    this.handler.onAttrVal(this.content.substring(this.start, this.i))
+  } else {
+    // 没有冒号的属性
+    for (; this.i < len; this.i++) {
+      if (blankChar[this.content[this.i]]) {
+        this.handler.onAttrVal(this.content.substring(this.start, this.i))
+        break
+      } else if (this.checkClose('onAttrVal')) return
+    }
+  }
+  while (blankChar[this.content[++this.i]]);
+  if (this.i < len && !this.checkClose()) {
+    this.start = this.i
+    this.state = this.attrName
+  }
+}
+
+/**
+ * @description 结束标签状态
+ * @returns {String} 结束的标签名
+ * @private
+ */
+Lexer.prototype.endTag = function () {
+  const c = this.content[this.i]
+  if (blankChar[c] || c === '>' || c === '/') {
+    this.handler.onCloseTag(this.content.substring(this.start, this.i))
+    if (c !== '>') {
+      this.i = this.content.indexOf('>', this.i)
+      if (this.i === -1) return
+    }
+    this.start = ++this.i
+    this.state = this.text
+  } else {
+    this.i++
+  }
+}
+
+export default Parser

+ 129 - 0
components/mp-html/components/mp-html/style/index.js

@@ -0,0 +1,129 @@
+/**
+ * @fileoverview style 插件
+ */
+// #ifndef APP-PLUS-NVUE
+import Parser from './parser'
+// #endif
+
+function Style () {
+  this.styles = []
+}
+
+// #ifndef APP-PLUS-NVUE
+Style.prototype.onParse = function (node, vm) {
+  // 获取样式
+  if (node.name === 'style' && node.children.length && node.children[0].type === 'text') {
+    this.styles = this.styles.concat(new Parser().parse(node.children[0].text))
+  } else if (node.name) {
+    // 匹配样式(对非文本标签)
+    // 存储不同优先级的样式 name < class < id < 后代
+    let matched = ['', '', '', '']
+    for (let i = 0, len = this.styles.length; i < len; i++) {
+      const item = this.styles[i]
+      let res = match(node, item.key || item.list[item.list.length - 1])
+      let j
+      if (res) {
+        // 后代选择器
+        if (!item.key) {
+          j = item.list.length - 2
+          for (let k = vm.stack.length; j >= 0 && k--;) {
+            // 子选择器
+            if (item.list[j] === '>') {
+              // 错误情况
+              if (j < 1 || j > item.list.length - 2) break
+              if (match(vm.stack[k], item.list[j - 1])) {
+                j -= 2
+              } else {
+                j++
+              }
+            } else if (match(vm.stack[k], item.list[j])) {
+              j--
+            }
+          }
+          res = 4
+        }
+        if (item.key || j < 0) {
+          // 添加伪类
+          if (item.pseudo && node.children) {
+            let text
+            item.style = item.style.replace(/content:([^;]+)/, (_, $1) => {
+              text = $1.replace(/['"]/g, '')
+                // 处理 attr 函数
+                .replace(/attr\((.+?)\)/, (_, $1) => node.attrs[$1.trim()] || '')
+                // 编码 \xxx
+                .replace(/\\(\w{4})/, (_, $1) => String.fromCharCode(parseInt($1, 16)))
+              return ''
+            })
+            const pseudo = {
+              name: 'span',
+              attrs: {
+                style: item.style
+              },
+              children: [{
+                type: 'text',
+                text
+              }]
+            }
+            if (item.pseudo === 'before') {
+              node.children.unshift(pseudo)
+            } else {
+              node.children.push(pseudo)
+            }
+          } else {
+            matched[res - 1] += item.style + (item.style[item.style.length - 1] === ';' ? '' : ';')
+          }
+        }
+      }
+    }
+    matched = matched.join('')
+    if (matched.length > 2) {
+      node.attrs.style = matched + (node.attrs.style || '')
+    }
+  }
+}
+
+/**
+ * @description 匹配样式
+ * @param {object} node 要匹配的标签
+ * @param {string|string[]} keys 选择器
+ * @returns {number} 0:不匹配;1:name 匹配;2:class 匹配;3:id 匹配
+ */
+function match (node, keys) {
+  function matchItem (key) {
+    if (key[0] === '#') {
+      // 匹配 id
+      if (node.attrs.id && node.attrs.id.trim() === key.substr(1)) return 3
+    } else if (key[0] === '.') {
+      // 匹配 class
+      key = key.substr(1)
+      const selectors = (node.attrs.class || '').split(' ')
+      for (let i = 0; i < selectors.length; i++) {
+        if (selectors[i].trim() === key) return 2
+      }
+    } else if (node.name === key) {
+      // 匹配 name
+      return 1
+    }
+    return 0
+  }
+
+  // 多选择器交集
+  if (keys instanceof Array) {
+    let res = 0
+    for (let j = 0; j < keys.length; j++) {
+      const tmp = matchItem(keys[j])
+      // 任意一个不匹配就失败
+      if (!tmp) return 0
+      // 优先级最大的一个作为最终优先级
+      if (tmp > res) {
+        res = tmp
+      }
+    }
+    return res
+  }
+
+  return matchItem(keys)
+}
+// #endif
+
+export default Style

+ 175 - 0
components/mp-html/components/mp-html/style/parser.js

@@ -0,0 +1,175 @@
+const blank = {
+  ' ': true,
+  '\n': true,
+  '\t': true,
+  '\r': true,
+  '\f': true
+}
+
+function Parser () {
+  this.styles = []
+  this.selectors = []
+}
+
+/**
+ * @description 解析 css 字符串
+ * @param {string} content css 内容
+ */
+Parser.prototype.parse = function (content) {
+  new Lexer(this).parse(content)
+  return this.styles
+}
+
+/**
+ * @description 解析到一个选择器
+ * @param {string} name 名称
+ */
+Parser.prototype.onSelector = function (name) {
+  // 不支持的选择器
+  if (name.includes('[') || name.includes('*') || name.includes('@')) return
+  const selector = {}
+  // 伪类
+  if (name.includes(':')) {
+    const info = name.split(':')
+    const pseudo = info.pop()
+    if (pseudo === 'before' || pseudo === 'after') {
+      selector.pseudo = pseudo
+      name = info[0]
+    } else return
+  }
+
+  // 分割交集选择器
+  function splitItem (str) {
+    const arr = []
+    let i, start
+    for (i = 1, start = 0; i < str.length; i++) {
+      if (str[i] === '.' || str[i] === '#') {
+        arr.push(str.substring(start, i))
+        start = i
+      }
+    }
+    if (!arr.length) {
+      return str
+    } else {
+      arr.push(str.substring(start, i))
+      return arr
+    }
+  }
+
+  // 后代选择器
+  if (name.includes(' ')) {
+    selector.list = []
+    const list = name.split(' ')
+    for (let i = 0; i < list.length; i++) {
+      if (list[i].length) {
+        // 拆分子选择器
+        const arr = list[i].split('>')
+        for (let j = 0; j < arr.length; j++) {
+          selector.list.push(splitItem(arr[j]))
+          if (j < arr.length - 1) {
+            selector.list.push('>')
+          }
+        }
+      }
+    }
+  } else {
+    selector.key = splitItem(name)
+  }
+
+  this.selectors.push(selector)
+}
+
+/**
+ * @description 解析到选择器内容
+ * @param {string} content 内容
+ */
+Parser.prototype.onContent = function (content) {
+  // 并集选择器
+  for (let i = 0; i < this.selectors.length; i++) {
+    this.selectors[i].style = content
+  }
+  this.styles = this.styles.concat(this.selectors)
+  this.selectors = []
+}
+
+/**
+ * @description css 词法分析器
+ * @param {object} handler 高层处理器
+ */
+function Lexer (handler) {
+  this.selector = ''
+  this.style = ''
+  this.handler = handler
+}
+
+Lexer.prototype.parse = function (content) {
+  this.i = 0
+  this.content = content
+  this.state = this.blank
+  for (let len = content.length; this.i < len; this.i++) {
+    this.state(content[this.i])
+  }
+}
+
+Lexer.prototype.comment = function () {
+  this.i = this.content.indexOf('*/', this.i) + 1
+  if (!this.i) {
+    this.i = this.content.length
+  }
+}
+
+Lexer.prototype.blank = function (c) {
+  if (!blank[c]) {
+    if (c === '/' && this.content[this.i + 1] === '*') {
+      this.comment()
+      return
+    }
+    this.selector += c
+    this.state = this.name
+  }
+}
+
+Lexer.prototype.name = function (c) {
+  if (c === '/' && this.content[this.i + 1] === '*') {
+    this.comment()
+    return
+  }
+  if (c === '{' || c === ',' || c === ';') {
+    this.handler.onSelector(this.selector.trimEnd())
+    this.selector = ''
+    if (c !== '{') {
+      while (blank[this.content[++this.i]]);
+    }
+    if (this.content[this.i] === '{') {
+      this.floor = 1
+      this.state = this.val
+    } else {
+      this.selector += this.content[this.i]
+    }
+  } else if (blank[c]) {
+    this.selector += ' '
+  } else {
+    this.selector += c
+  }
+}
+
+Lexer.prototype.val = function (c) {
+  if (c === '/' && this.content[this.i + 1] === '*') {
+    this.comment()
+    return
+  }
+  if (c === '{') {
+    this.floor++
+  } else if (c === '}') {
+    this.floor--
+    if (!this.floor) {
+      this.handler.onContent(this.style)
+      this.style = ''
+      this.state = this.blank
+      return
+    }
+  }
+  this.style += c
+}
+
+export default Parser

文件差異過大導致無法顯示
+ 0 - 0
components/mp-html/static/app-plus/mp-html/js/handler.js


文件差異過大導致無法顯示
+ 0 - 0
components/mp-html/static/app-plus/mp-html/js/uni.webview.min.js


文件差異過大導致無法顯示
+ 0 - 0
components/mp-html/static/app-plus/mp-html/local.html


+ 735 - 0
components/r-canvas/r-canvas.js

@@ -0,0 +1,735 @@
+export default{
+	data(){
+		return{
+			system_info:{}, //system info
+			canvas_width:0, //canvas width px
+			canvas_height:0, //canvas height px
+			ctx:null, //canvas object
+			canvas_id:null, //canvas id
+			hidden:false,//Whether to hide canvas
+			scale:1,//canvas scale
+			r_canvas_scale:1,
+			if_ctx:true
+		}
+	},
+	methods:{
+		/**
+		 * save r-canvas.vue object
+		 * @param {Object} that
+		 */
+		// saveThis(that){
+		// 	rCanvasThis = that
+		// },
+		/**
+		 * Draw round rect text
+		 * @param {Object} config
+		 * @param {Number} config.x x坐标
+		 * @param {Number} config.y y坐标
+		 * @param {Number} config.w 宽度
+		 * @param {Number} config.h 高度
+		 * @param {Number} config.radius 圆角弧度
+		 * @param {String} config.fill_color 矩形颜色
+		 */
+		fillRoundRect(config) {
+			return new Promise((resolve,reject)=>{
+				let x = this.compatibilitySize(parseFloat(config.x)*this.scale)
+				let y = this.compatibilitySize(parseFloat(config.y)*this.scale)
+				let w = this.compatibilitySize(parseFloat(config.w)*this.scale)
+				let h = this.compatibilitySize(parseFloat(config.h)*this.scale)
+				let radius = config.radius?parseFloat(config.radius)*this.scale:10*this.scale
+				
+				let fill_color = config.fill_color || "black"
+				// The diameter of the circle must be less than the width and height of the rectangle
+				if (2 * radius > w || 2 * radius > h) { 
+					reject("The diameter of the circle must be less than the width and height of the rectangle")
+					return false; 
+				}
+				this.ctx.save();
+				this.ctx.translate(x, y);
+				//  
+				this.drawRoundRectPath({
+					w: w, 
+					h: h, 
+					radius: radius
+				});
+				this.ctx.fillStyle = fill_color
+				this.ctx.fill();
+				this.ctx.restore();
+				resolve()
+			})
+		},
+		/**
+		 * Draws the sides of a rounded rectangle
+		 * @param {Object} config
+		 * @param {Number} config.w 宽度
+		 * @param {Number} config.h 高度
+		 * @param {Number} config.radius 圆角弧度
+		 */
+		drawRoundRectPath(config) {
+			this.ctx.beginPath(0);
+			this.ctx.arc(config.w - config.radius, config.h - config.radius, config.radius, 0, Math.PI / 2);
+			this.ctx.lineTo(config.radius, config.h);
+			this.ctx.arc(config.radius, config.h - config.radius, config.radius, Math.PI / 2, Math.PI);
+			this.ctx.lineTo(0, config.radius);
+			this.ctx.arc(config.radius, config.radius, config.radius, Math.PI, Math.PI * 3 / 2);
+			this.ctx.lineTo(config.w - config.radius, 0);
+			this.ctx.arc(config.w - config.radius, config.radius, config.radius, Math.PI * 3 / 2, Math.PI * 2);
+			this.ctx.lineTo(config.w, config.h - config.radius);
+			this.ctx.closePath();
+		},
+		/**
+		 * Draw special Text,line wrapping is not supported
+		 * @param {Object} config
+		 * @param {String} config.text 文字
+		 * @param {Number} config.x x坐标
+		 * @param {Number} config.y y坐标
+		 * @param {String} config.font_color 文字颜色
+		 * @param {String} config.font_family 文字字体
+		 * @param {Number} config.font_size 文字大小(px)
+		 */
+		drawSpecialText(params){
+			let general = params.general
+			let list = params.list
+			return new Promise(async (resolve,reject)=>{
+				if(!general){
+					reject("general cannot be empty:101")
+					return;
+				}else if(list && list.length>0){
+					for(let i in list){
+						if(i != 0){
+							let font_size = list[i-1].font_size?parseFloat(list[i-1].font_size):20
+							this.ctx.setFontSize(font_size)
+							general.x = parseFloat(general.x) + this.ctx.measureText(list[i-1].text).width
+						}
+						list[i].x = general.x
+						list[i].y = general.y + (list[i].margin_top?parseFloat(list[i].margin_top):0)
+						await this.drawText(list[i])
+					}
+					resolve()
+				}else{
+					reject("The length of config arr is less than 0")
+					return;
+				}
+				
+			})
+		},
+		/**
+		 * array delete empty
+		 * @param {Object} arr
+		 */
+		arrDeleteEmpty(arr){
+			let newArr = []
+			for(let i in arr){
+				if(arr[i]){
+					newArr.push(arr[i])
+				}
+			}
+			return newArr
+		},
+		/**
+		 * Draw Text,support line
+		 * @param {Object} config
+		 * @param {String} config.text 文字
+		 * @param {Number} config.max_width 文字最大宽度(大于宽度自动换行)
+		 * @param {Number} config.line_height 文字上下行间距
+		 * @param {Number} config.x x坐标
+		 * @param {Number} config.y y坐标
+		 * @param {String} config.font_color 文字颜色
+		 * @param {String} config.font_family 文字字体 默认值:Arial
+		 * @param {String} config.text_align 文字对齐方式(left/center/right)
+		 * @param {Number} config.font_size 文字大小(px)
+		 * @param {Boolean} config.line_through_height 中划线大小
+		 * @param {Boolean} config.line_through_color 中划线颜色
+		 * @param {String} config.font_style 规定文字样式
+		 * @param {String} config.font_variant 规定字体变体
+		 * @param {String} config.font_weight 规定字体粗细
+		 * @param {String} config.line_through_cap 线末端类型
+		 * @param {String} config.line_clamp 最大行数
+		 * @param {String} config.line_clamp_hint 超过line_clamp后,尾部显示的自定义标识 如 ...
+		 * @param {String} config.is_line_break 是否开启换行符换行
+		 * 
+		 */
+		drawText(config,configuration = {}){
+			
+			configuration['line_num'] = configuration.line_num?configuration.line_num:0
+			configuration['text_width'] = configuration.text_width?configuration.text_width:0
+			
+			return new Promise(async (resolve,reject)=>{
+				
+				if(config.text){
+					
+					let draw_width = 0,draw_height = 0,draw_x = config.x,draw_y = config.y
+					let font_size = config.font_size?(parseFloat(config.font_size)*this.scale):(20*this.scale)
+					let font_color = config.font_color || "#000"
+					let font_family = config.font_family || "Arial"
+					let line_height = config.line_height || config.font_size || 20
+					let text_align = config.text_align || "left"
+					let font_weight = config.font_weight || "normal"
+					let font_variant = config.font_variant || "normal"
+					let font_style = config.font_style || "normal"
+					let line_clamp_hint = config.line_clamp_hint || '...'
+					let lineBreakJoinText = ""
+					let max_width = config.max_width?parseFloat(config.max_width)*this.scale:0
+					// checkout is line break
+					if(config.is_line_break){
+						let splitTextArr = config.text.split(/[\n]/g)
+						if(splitTextArr && splitTextArr.length > 0){
+							let newSplitTextArr = this.arrDeleteEmpty(splitTextArr)
+							if(newSplitTextArr && newSplitTextArr.length > 0){
+								lineBreakJoinText = newSplitTextArr.slice(1).join("\n")
+								config.text = newSplitTextArr[0]
+							}else{
+								reject("Text cannot be empty:103")
+								return
+							}
+						}else{
+							reject("Text cannot be empty:102")
+							return
+						}
+					}
+					
+					this.ctx.setFillStyle(font_color) // color
+					this.ctx.textAlign = text_align;
+					this.ctx.font = `${font_style} ${font_variant} ${font_weight} ${parseInt(font_size)}px ${font_family}`
+					if(configuration.text_width >= this.ctx.measureText(config.text).width){
+						draw_width = configuration.text_width
+					}else if(max_width > 0){
+						draw_width = max_width < this.ctx.measureText(config.text).width ? this.resetCompatibilitySize(max_width) : this.resetCompatibilitySize(this.ctx.measureText(config.text).width)
+					}else{
+						draw_width = this.ctx.measureText(config.text).width
+					}
+					configuration.text_width = draw_width / this.scale
+					if( max_width && this.compatibilitySize(this.ctx.measureText(config.text).width) > this.compatibilitySize(max_width)){
+						let current_text = ""
+						let text_arr = config.text.split("")
+						for(let i in text_arr){
+							if( this.compatibilitySize(this.ctx.measureText(current_text+text_arr[i]).width) > this.compatibilitySize(max_width) ){
+								// Hyphenation that is greater than the drawable width continues to draw
+								if(config.line_clamp && parseInt(config.line_clamp) == 1){
+									// Subtracting the current_text tail width from the line_clamp_hint width
+									let current_text_arr = current_text.split('')
+									let json_current_text = ''
+									while(true){
+										current_text_arr = current_text_arr.slice(1)
+										json_current_text = current_text_arr.join('')
+										if(this.compatibilitySize(this.ctx.measureText(json_current_text).width) <= this.compatibilitySize(this.ctx.measureText(line_clamp_hint).width)){
+											current_text = current_text.replace(json_current_text,'')
+											break;
+										}
+									}
+									configuration.line_num += 1
+									this.ctx.setFontSize(parseInt(this.compatibilitySize(font_size))) // font size
+									this.ctx.fillText(current_text + line_clamp_hint, this.compatibilitySize(parseFloat(config.x)*this.scale), this.compatibilitySize(parseFloat(config.y)*this.scale));
+								}else{
+									configuration.line_num += 1
+									this.ctx.setFontSize(parseInt(this.compatibilitySize(font_size))) // font size
+									this.ctx.fillText(current_text, this.compatibilitySize(parseFloat(config.x)*this.scale), this.compatibilitySize(parseFloat(config.y)*this.scale));
+									config.text = text_arr.slice(i).join("")
+									config.y = config.y + line_height
+									if(config.line_clamp){
+										config.line_clamp = parseInt(config.line_clamp) - 1
+									}
+									await this.drawText(config,configuration)
+								}
+								
+								break;
+							}else{
+								current_text = current_text+text_arr[i]
+							}
+						}
+					}else{
+						if(config.line_through_height){
+							let x = parseFloat(config.x)*this.scale
+							let w
+							let y = parseFloat(config.y)*this.scale - (font_size / 2.6) 
+							if(text_align == "left"){
+								w = this.ctx.measureText(config.text).width/1.1 + parseFloat(config.x)*this.scale
+							}else if(text_align == "right"){
+								w = parseFloat(config.x)*this.scale - this.ctx.measureText(config.text).width/1.1
+							}else if(text_align == "center"){
+								x = parseFloat(config.x)*this.scale - this.ctx.measureText(config.text).width / 1.1 / 2
+								w = parseFloat(config.x)*this.scale + this.ctx.measureText(config.text).width / 1.1 / 2
+							}
+							this.drawLineTo({
+								x:x,
+								y:y,
+								w:w,
+								h:y,
+								line_width:config.line_through_height,
+								line_color:config.line_through_color,
+								line_cap:config.line_through_cap
+							})
+						}
+						configuration.line_num += 1
+						this.ctx.setFontSize(parseInt(this.compatibilitySize(font_size))) // font size
+						this.ctx.fillText(config.text, this.compatibilitySize(parseFloat(config.x)*this.scale), this.compatibilitySize(parseFloat(config.y)*this.scale));
+						if(config.line_clamp){
+							config.line_clamp = parseInt(config.line_clamp) - 1
+						}
+					}
+					if(lineBreakJoinText){
+						await this.drawText({...config,text:lineBreakJoinText,y:config.y + line_height},configuration)
+					}
+					draw_height = config.font_size * configuration.line_num
+					draw_width = configuration.text_width
+					resolve({draw_width,draw_height,draw_x,draw_y})
+				}else{
+					reject("Text cannot be empty:101")
+				}
+			})
+		},
+		/**
+		 * Draw Line
+		 * @param {Object} config
+		 * @param {Object} config.x x坐标
+		 * @param {Object} config.y y坐标
+		 * @param {Object} config.w 线的宽度
+		 * @param {Object} config.h 线的高度
+		 * @param {Object} config.line_width 线的宽度
+		 * @param {Object} config.line_color 线条颜色
+		 */
+		drawLineTo(config){
+			let x = this.compatibilitySize(config.x)
+			let y = this.compatibilitySize(config.y)
+			let w = this.compatibilitySize(config.w)
+			let h = this.compatibilitySize(config.h)
+			let line_width = config.line_width?parseFloat(config.line_width)*this.scale:1*this.scale
+			let line_color = config.line_color || "black"
+			let line_cap = config.line_cap || "butt"
+			this.ctx.beginPath()
+			this.ctx.lineCap = line_cap
+			this.ctx.lineWidth = line_width
+			this.ctx.strokeStyle = line_color
+			this.ctx.moveTo(x,y)
+			this.ctx.lineTo(w,h)
+			this.ctx.stroke()
+		},
+		/** 
+		 * Compatibility px
+		 * @param {Object} size
+		 */
+		compatibilitySize(size) {
+		  let canvasSize = (parseFloat(size) / 750) * this.system_info.windowWidth
+		  canvasSize = parseFloat(canvasSize * 2)
+		  return canvasSize
+		},
+		/**
+		 * Restore compatibility px
+		 * @param {Object} size
+		 */
+		resetCompatibilitySize(size) {
+		  let canvasSize = (parseFloat(size/2)/this.system_info.windowWidth) * 750
+		  return canvasSize
+		},
+		/**
+		 * Init canvas
+		 */
+		init(config){
+			return new Promise(async (resolve,reject)=>{
+				if(!config.canvas_id){
+					reject("Canvas ID cannot be empty, please refer to the usage example")
+					return;
+				}
+				this.hidden = config.hidden
+				this.canvas_id = config.canvas_id
+				let system_info = await uni.getSystemInfoSync()
+				this.system_info = system_info
+				this.scale = config.scale&&parseFloat(config.scale)>0?parseInt(config.scale):1
+				this.canvas_width = (config.canvas_width ? this.compatibilitySize(config.canvas_width) : system_info.windowWidth) * this.scale
+				this.canvas_height = (config.canvas_height ? this.compatibilitySize(config.canvas_height) : system_info.windowHeight) * this.scale,
+				this.r_canvas_scale = 1/this.scale
+				this.ctx = uni.createCanvasContext(this.canvas_id,this)
+				this.setCanvasConfig({
+					global_alpha:config.global_alpha?parseFloat(config.global_alpha):1,
+					backgroundColor:config.background_color?config.background_color:"#fff"
+				})
+				resolve()
+			})
+		},
+		/**
+		 * clear canvas all path
+		 */
+		clearCanvas(){
+			return new Promise(async (resolve,reject)=>{
+				if(!this.ctx){
+					reject("canvas is not initialized:101")
+					return
+				}else{
+					this.ctx.clearRect(0,0,parseFloat(this.canvas_width)*this.scale,parseFloat(this.canvas_height)*this.scale)
+					await this.draw()
+					resolve()
+				}
+			})
+		},
+		/**
+		 * Set canvas config
+		 * @param {Object} config
+		 */
+		setCanvasConfig(config){
+			this.ctx.globalAlpha = config.global_alpha
+			this.ctx.fillStyle = config.backgroundColor
+			this.ctx.fillRect(0, 0, parseFloat(this.canvas_width)*this.scale, parseFloat(this.canvas_height)*this.scale)
+		},
+		/**
+		 * set canvas width
+		 * @param {Object} width
+		 */
+		setCanvasWidth(width){
+			if(!width){
+				uni.showToast({
+					title:'setCanvasWidth:width error',
+					icon:'none'
+				})
+			}
+			this.canvas_width = this.compatibilitySize(parseFloat(width)) * this.scale
+			this.ctx.width = this.canvas_width
+		},
+		/**
+		 * set canvas height
+		 * @param {Object} height
+		 */
+		setCanvasHeight(height){
+			if(!height){
+				uni.showToast({
+					title:'setCanvasWidth:height error',
+					icon:'none'
+				})
+			}
+			this.canvas_height = this.compatibilitySize(parseFloat(height)) * this.scale
+			this.ctx.height = this.canvas_height
+		},
+		/**
+		 * Draw to filepath
+		 */
+		draw(callback){
+			return new Promise((resolve,reject)=>{
+				let stop = setTimeout(()=>{
+					this.ctx.draw(false,setTimeout(()=>{
+					    uni.canvasToTempFilePath({
+					    	canvasId: this.canvas_id,
+					    	quality: 1,
+					    	success: (res)=>{
+					    		console.log('res',res)
+					    		resolve(res)
+					    		callback && callback(res)
+					    	},
+					    	fail:(err)=>{
+					    		reject(JSON.stringify(err)|| "Failed to generate poster:101")
+					    	}
+					    },this)
+					},300))
+					clearTimeout(stop)
+				},300)
+			})
+		},
+		/**
+		 * draw rect
+		 * @param {Number} config.x x坐标
+		 * @param {Number} config.y y坐标
+		 * @param {Number} config.w 图形宽度(px)
+		 * @param {Number} config.h 图形高度(px)
+		 * @param {Number} config.color 图形颜色
+		 * @param {Number} config.is_radius 是否开启圆图(1.1.6及以下版本废弃,请使用border_radius)
+		 * @param {Number} config.border_width 边框大小
+		 * @param {Number} config.border_color 边框颜色
+		 * 
+		 */
+		drawRect(config){
+			return new Promise(async (resolve,reject)=>{
+				if(!config.border_width || config.border_width <=0){
+					config.border_width = 0
+				}else{
+					config.border_width = parseFloat(config.border_width)
+				}
+				if(parseFloat(config.border_width) > 0){
+					let sub_config = JSON.parse(JSON.stringify(config))
+					sub_config.border_width = 0
+					sub_config.w = config.w + config.border_width
+					sub_config.h = config.h + config.border_width
+					sub_config.color = config.border_color || 'black'
+					if(sub_config.border_radius){
+						sub_config.border_radius = parseFloat(sub_config.border_radius) + parseFloat(config.border_width) / 2
+					} 
+					await this.drawRect(sub_config)
+				}
+				
+				let color = config.color || 'white'
+				config.x =  (parseFloat(config.x) + config.border_width / 2)
+				config.y = (parseFloat(config.y) + config.border_width / 2)
+				config['color'] = color
+				this.ctx.fillStyle = color;
+				if(config.is_radius || config.border_radius){
+					this.setNativeBorderRadius(config)
+					this.ctx.fill()
+				}else{
+					console.log('config.border_width',config.border_width)
+					this.ctx.fillRect(this.compatibilitySize(config.x*this.scale),this.compatibilitySize(config.y*this.scale),this.compatibilitySize(parseFloat(config.w)*this.scale),this.compatibilitySize(parseFloat(config.h)*this.scale))
+				}
+				resolve()
+			})
+		},
+		/**
+		 * Draw image
+		 * @param {Object} config
+		 * @param {String} config.url 图片链接
+		 * @param {Number} config.x x坐标
+		 * @param {Number} config.y y坐标
+		 * @param {Number} config.w 图片宽度(px)
+		 * @param {Number} config.h 图片高度(px)
+		 * @param {Number} config.border_width 边大小
+		 * @param {Number} config.border_color 边颜色
+		 * @param {Number} config.is_radius 是否开启圆图(1.1.6及以下版本废弃,请使用border_radius)
+		 * @param {Number} config.border_radius 圆角弧度
+		 */
+		drawImage(config){
+			return new Promise(async (resolve,reject)=>{
+				if(config.url){
+					let type = 0 // 1、network image  2、native image  3、base64 image
+					let image_url
+					let reg = /^https?/ig;
+					if(reg.test(config.url)){
+						type = 1
+					}else{
+						if((config.url.indexOf("data:image/png;base64") != -1) || config.url.indexOf("data:image/jpeg;base64") != -1 || config.url.indexOf("data:image/gif;base64") != -1){
+							type = 3
+						}else{
+							type = 2
+						}
+					}
+					if(type == 1){
+						// network image
+						await this.downLoadNetworkFile(config.url).then(res=>{ // two function
+							image_url = res
+						}).catch(err=>{
+							reject(err)
+							return;
+						})
+					}else if(type == 2){
+						// native image
+						const imageInfoResult = await uni.getImageInfo({
+							src: config.url
+						});
+						try{
+							if(imageInfoResult.length <= 1){
+								reject(imageInfoResult[0].errMsg + ':404')
+								return
+							}
+						}catch(e){
+							reject(e+':500')
+							return
+						}
+						let base64 = await this.urlToBase64({url:imageInfoResult[1].path})
+						// #ifdef MP-WEIXIN
+						await this.base64ToNative({url:base64}).then(res=>{
+							image_url = res
+						}).catch(err=>{
+							reject(JSON.stringify(err)+":501")
+							return;
+						})
+						// #endif
+						// #ifndef MP-WEIXIN
+						image_url = base64
+						// #endif
+						
+					}else if(type == 3){
+						// #ifdef MP-WEIXIN
+						await this.base64ToNative({url:config.url}).then(res=>{
+							image_url = res
+						}).catch(err=>{
+							reject(JSON.stringify(err)+":500")
+							return;
+						})
+						// #endif
+						// #ifndef MP-WEIXIN
+						image_url = config.url
+						// #endif
+					}else{
+						reject("Other Type Errors:101")
+						return
+					}
+					if(config.border_width){
+						let border_radius = 0
+						if(config.border_radius){
+							let multiple = config.w / config.border_radius
+							border_radius = (parseFloat(config.w) + parseFloat(config.border_width)) / multiple
+						}
+						// drawRect
+						await this.drawRect({
+							x:parseFloat(config.x) - parseFloat(config.border_width)/2,
+							y:parseFloat(config.y) - parseFloat(config.border_width)/2,
+							w:parseFloat(config.w) + parseFloat(config.border_width),
+							h:parseFloat(config.h) + parseFloat(config.border_width),
+							color:config.border_color,
+							border_radius:border_radius,
+							border_width:config.border_width,
+							is_radius:config.is_radius
+						})
+					}
+					
+					
+
+					if(config.border_radius){
+						config.color =  config.color?config.color:'rgba(0,0,0,0)'
+						
+						// 圆角有白边,+0.5的误差
+						config.w = config.w + 0.3
+						config.h = config.h + 0.3
+						
+						this.setNativeBorderRadius(config)
+					}else if(config.is_radius){
+						//已废弃 is_radius
+						this.ctx.setStrokeStyle("rgba(0,0,0,0)")
+						this.ctx.save()
+						this.ctx.beginPath()
+						this.ctx.arc(this.compatibilitySize(parseFloat(config.x)*this.scale+parseFloat(config.w)*this.scale/2), this.compatibilitySize(parseFloat(config.y)*this.scale+parseFloat(config.h)*this.scale/2), this.compatibilitySize(parseFloat(config.w)*this.scale/2), 0, 2 * Math.PI, false)
+						this.ctx.stroke();
+						this.ctx.clip()
+					}
+					
+					await this.ctx.drawImage(image_url,this.compatibilitySize(parseFloat(config.x)*this.scale),this.compatibilitySize(parseFloat(config.y)*this.scale),this.compatibilitySize(parseFloat(config.w)*this.scale),this.compatibilitySize(parseFloat(config.h)*this.scale))
+					this.ctx.restore() //Restore previously saved drawing context
+					resolve()
+				}else{
+					let err_msg = "Links cannot be empty:101"
+					reject(err_msg)
+				}
+			})
+		},
+		/**
+		 * base64 to native available path
+		 * @param {Object} config
+		 */
+		base64ToNative(config){
+			return new Promise((resolve,reject)=>{
+				let fileName = new Date().getTime()
+				var filePath = `${wx.env.USER_DATA_PATH}/${fileName}_rCanvas.png`
+				wx.getFileSystemManager().writeFile({
+					filePath: filePath,
+					data: config.url.replace(/^data:\S+\/\S+;base64,/, ''),
+					encoding: 'base64',
+					success: function() {
+						resolve(filePath)
+					},
+					fail: function(error) {
+						reject(error)
+					}
+				})
+			})
+		},
+		/**
+		 * native url to base64
+		 * @param {Object} config
+		 */
+		urlToBase64(config){
+			return new Promise(async (resolve,reject)=>{
+				if (typeof window != 'undefined') {
+					await this.downLoadNetworkFile(config.url).then(res=>{ // two function
+						resolve(res)
+					}).catch(err=>{
+						reject(err)
+					})
+				}else if (typeof plus != 'undefined') {
+					plus.io.resolveLocalFileSystemURL(config.url,(obj)=>{
+						obj.file((file)=>{
+							let fileReader = new plus.io.FileReader()
+							fileReader.onload = (res)=>{
+								resolve(res.target.result)
+							}
+							fileReader.onerror = (err)=>{
+								reject(err)
+							}
+							fileReader.readAsDataURL(file)
+						}, (err)=>{
+							reject(err)
+						})
+					},(err)=>{
+						reject(err)
+					})
+				}else if(typeof wx != 'undefined'){
+					wx.getFileSystemManager().readFile({
+						filePath: config.url,
+						encoding: 'base64',
+						success: function(res) {
+							resolve('data:image/png;base64,' + res.data)
+						},
+						fail: function(error) {
+							reject(error)
+						}
+					})
+				}
+			})
+		},
+		setNativeBorderRadius(config){
+			let border_radius = config.border_radius?(parseFloat(config.border_radius)*this.scale):(20*this.scale)
+			if ((parseFloat(config.w)*this.scale) < 2 * border_radius) border_radius = (parseFloat(config.w)*this.scale) / 2;
+			if ((parseFloat(config.h)*this.scale) < 2 * border_radius) border_radius = (parseFloat(config.h)*this.scale) / 2;
+			this.ctx.beginPath();
+			this.ctx.moveTo(this.compatibilitySize((parseFloat(config.x)*this.scale) + border_radius), this.compatibilitySize((parseFloat(config.y)*this.scale)));
+			this.ctx.arcTo(this.compatibilitySize((parseFloat(config.x)*this.scale) + (parseFloat(config.w)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale)), this.compatibilitySize((parseFloat(config.x)*this.scale) + (parseFloat(config.w)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale) + (parseFloat(config.h)*this.scale)), this.compatibilitySize(border_radius));
+			this.ctx.arcTo(this.compatibilitySize((parseFloat(config.x)*this.scale) + (parseFloat(config.w)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale) + (parseFloat(config.h)*this.scale)), this.compatibilitySize((parseFloat(config.x)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale) + (parseFloat(config.h)*this.scale)), this.compatibilitySize(border_radius));
+			this.ctx.arcTo((this.compatibilitySize(parseFloat(config.x)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale) + (parseFloat(config.h)*this.scale)), this.compatibilitySize((parseFloat(config.x)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale)), this.compatibilitySize(border_radius));
+			this.ctx.arcTo(this.compatibilitySize((parseFloat(config.x)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale)), this.compatibilitySize((parseFloat(config.x)*this.scale) + (parseFloat(config.w)*this.scale)), this.compatibilitySize((parseFloat(config.y)*this.scale)), this.compatibilitySize(border_radius));
+			this.ctx.closePath();
+			this.ctx.strokeStyle = config.color || config.border_color || 'rgba(0,0,0,0)'; // 设置绘制边框的颜色
+			this.ctx.stroke();
+			this.ctx.save()
+			this.ctx.clip();
+			
+		},
+		/**
+		 * Download network file
+		 * @param {Object} url : download url
+		 */
+		downLoadNetworkFile(url){
+			return new Promise((resolve,reject)=>{
+				uni.downloadFile({
+					url,
+					success:(res)=>{
+						if(res.statusCode == 200){
+							resolve(res.tempFilePath)
+						}else{
+							reject("Download Image Fail:102")
+						}
+					},
+					fail:(err)=>{
+						reject("Download Image Fail:101")
+					}
+				})
+			})
+		},
+		/**
+		 * Save image to natice
+		 * @param {Object} filePath : native imageUrl
+		 */
+		saveImage(filePath){
+			return new Promise((resolve,reject)=>{
+				if(!filePath){
+					reject("FilePath cannot be null:101")
+					return;
+				}
+				
+				// #ifdef H5
+					var createA = document.createElement("a");
+					createA.download = filePath;
+					createA.href = filePath;
+					document.body.appendChild(createA);
+					createA.click();
+					createA.remove();
+					resolve()
+				// #endif
+				
+				// #ifndef H5
+				uni.saveImageToPhotosAlbum({
+					filePath: filePath,
+					success:(res)=>{
+						resolve(res)
+					}, 
+					fail:(err)=>{
+						reject(err)
+					}
+				})
+				// #endif
+			})
+		}
+	}
+}

+ 26 - 0
components/r-canvas/r-canvas.vue

@@ -0,0 +1,26 @@
+<template>
+	<view>
+		<view class="r-canvas-component" :style="{width:canvas_width/scale+'px',height:canvas_height/scale+'px'}" :class="{'hidden':hidden}">
+			<canvas class="r-canvas" v-if="canvas_id" :canvas-id="canvas_id" :id="canvas_id" :style="{width:canvas_width+'px',height:canvas_height+'px','transform': `scale(${r_canvas_scale})`}"></canvas>
+		</view>
+	</view>
+</template>
+
+<script>
+	import rCanvasJS from "./r-canvas.js"
+	export default {
+		mixins:[rCanvasJS]
+	}
+</script>
+<style>
+.r-canvas{
+	transform-origin: 0 0;
+}
+.r-canvas-component{
+	overflow: hidden;
+}
+.r-canvas-component.hidden{
+	position: fixed;
+	top:-5000upx;
+}
+</style>

+ 784 - 0
components/t-color-picker/t-color-picker.vue

@@ -0,0 +1,784 @@
+<template>
+	<view v-show="show" class="t-wrapper" @touchmove.stop.prevent="moveHandle">
+		<view class="t-mask" :class="{active:active}" @click.stop="close"></view>
+		<view class="t-box" :class="{active:active}">
+			<view class="t-header">
+				<view class="t-header-button" @click="close">取消</view>
+				<view class="t-header-button" @click="confirm">确认</view>
+			</view>
+			<view class="t-color__box" :style="{ background: 'rgb(' + bgcolor.r + ',' + bgcolor.g + ',' + bgcolor.b + ')'}">
+				<view class="t-background boxs" @touchstart="touchstart($event, 0)" @touchmove="touchmove($event, 0)" @touchend="touchend($event, 0)">
+					<view class="t-color-mask"></view>
+					<view class="t-pointer" :style="{ top: site[0].top - 8 + 'px', left: site[0].left - 8 + 'px' }"></view>
+				</view>
+			</view>
+			<view class="t-control__box">
+				<view class="t-control__color">
+					<view class="t-control__color-content" :style="{ background: 'rgba(' + rgba.r + ',' + rgba.g + ',' + rgba.b + ',' + rgba.a + ')' }"></view>
+				</view>
+				<view class="t-control-box__item">
+					<view class="t-controller boxs" @touchstart="touchstart($event, 1)" @touchmove="touchmove($event, 1)" @touchend="touchend($event, 1)">
+						<view class="t-hue">
+							<view class="t-circle" :style="{ left: site[1].left - 12 + 'px' }"></view>
+						</view>
+					</view>
+					<view class="t-controller boxs" @touchstart="touchstart($event, 2)" @touchmove="touchmove($event, 2)" @touchend="touchend($event, 2)">
+						<view class="t-transparency">
+							<view class="t-circle" :style="{ left: site[2].left - 12 + 'px' }"></view>
+						</view>
+					</view>
+				</view>
+			</view>
+			<view class="t-result__box">
+				<view v-if="mode" class="t-result__item">
+					<view class="t-result__box-input">{{hex}}</view>
+					<view class="t-result__box-text">HEX</view>
+				</view>
+				<template v-else>
+					<view class="t-result__item">
+						<view class="t-result__box-input">{{rgba.r}}</view>
+						<view class="t-result__box-text">R</view>
+					</view>
+					<view class="t-result__item">
+						<view class="t-result__box-input">{{rgba.g}}</view>
+						<view class="t-result__box-text">G</view>
+					</view>
+					<view class="t-result__item">
+						<view class="t-result__box-input">{{rgba.b}}</view>
+						<view class="t-result__box-text">B</view>
+					</view>
+					<view class="t-result__item">
+						<view class="t-result__box-input">{{rgba.a}}</view>
+						<view class="t-result__box-text">A</view>
+					</view>
+				</template>
+
+				<view class="t-result__item t-select" @click="select">
+					<view class="t-result__box-input">
+						<view>切换</view>
+						<view>模式</view>
+					</view>
+				</view>
+			</view>
+			<view class="t-alternative">
+				<view class="t-alternative__item" v-for="(item,index) in colorList" :key="index">
+					<view class="t-alternative__item-content" :style="{ background: 'rgba(' + item.r + ',' + item.g + ',' + item.b + ',' + item.a + ')' }"
+					 @click="selectColor(item)">
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+	export default {
+		props: {
+			color: {
+				type: Object,
+				default () {
+					return {
+						r: 0,
+						g: 0,
+						b: 0,
+						a: 0
+					}
+				}
+			},
+			spareColor: {
+				type: Array,
+				default () {
+					return []
+				}
+			}
+		},
+		data() {
+			return {
+				show: false,
+				active: false,
+				// rgba 颜色
+				rgba: {
+					r: 0,
+					g: 0,
+					b: 0,
+					a: 1
+				},
+				// hsb 颜色
+				hsb: {
+					h: 0,
+					s: 0,
+					b: 0
+				},
+				site: [{
+					top: 0,
+					left: 0
+				}, {
+					left: 0
+				}, {
+					left: 0
+				}],
+				index: 0,
+				bgcolor: {
+					r: 255,
+					g: 0,
+					b: 0,
+					a: 1
+				},
+				hex: '#000000',
+				mode: true,
+				colorList: [{
+					r: 244,
+					g: 67,
+					b: 54,
+					a: 1
+				}, {
+					r: 233,
+					g: 30,
+					b: 99,
+					a: 1
+				}, {
+					r: 156,
+					g: 39,
+					b: 176,
+					a: 1
+				}, {
+					r: 103,
+					g: 58,
+					b: 183,
+					a: 1
+				}, {
+					r: 63,
+					g: 81,
+					b: 181,
+					a: 1
+				}, {
+					r: 33,
+					g: 150,
+					b: 243,
+					a: 1
+				}, {
+					r: 3,
+					g: 169,
+					b: 244,
+					a: 1
+				}, {
+					r: 0,
+					g: 188,
+					b: 212,
+					a: 1
+				}, {
+					r: 0,
+					g: 150,
+					b: 136,
+					a: 1
+				}, {
+					r: 76,
+					g: 175,
+					b: 80,
+					a: 1
+				}, {
+					r: 139,
+					g: 195,
+					b: 74,
+					a: 1
+				}, {
+					r: 205,
+					g: 220,
+					b: 57,
+					a: 1
+				}, {
+					r: 255,
+					g: 235,
+					b: 59,
+					a: 1
+				}, {
+					r: 255,
+					g: 193,
+					b: 7,
+					a: 1
+				}, {
+					r: 255,
+					g: 152,
+					b: 0,
+					a: 1
+				}, {
+					r: 255,
+					g: 87,
+					b: 34,
+					a: 1
+				}, {
+					r: 121,
+					g: 85,
+					b: 72,
+					a: 1
+				}, {
+					r: 158,
+					g: 158,
+					b: 158,
+					a: 1
+				}, {
+					r: 0,
+					g: 0,
+					b: 0,
+					a: 0.5
+				}, {
+					r: 0,
+					g: 0,
+					b: 0,
+					a: 0
+				}, ]
+			};
+		},
+		created() {
+			this.rgba = this.color;
+			if (this.spareColor.length !== 0) {
+				this.colorList = this.spareColor;
+			}
+		},
+		methods: {
+			/**
+			 * 初始化
+			 */
+			init() {
+				// hsb 颜色
+				this.hsb = this.rgbToHex(this.rgba);
+				// this.setColor();
+				this.setValue(this.rgba);
+			},
+			moveHandle() {},
+			open() {
+				this.show = true;
+				this.$nextTick(() => {
+					this.init();
+					setTimeout(() => {
+						this.active = true;
+						setTimeout(() => {
+							this.getSelectorQuery();
+						}, 350)
+					}, 50)
+				})
+
+			},
+			close() {
+				this.active = false;
+				this.$nextTick(() => {
+					setTimeout(() => {
+						this.show = false;
+					}, 500)
+				})
+			},
+			confirm() {
+				this.close();
+				this.$emit('confirm', {
+					rgba: this.rgba,
+					hex: this.hex
+				})
+			},
+			// 选择模式
+			select() {
+				this.mode = !this.mode
+			},
+			// 常用颜色选择
+			selectColor(item) {
+				this.setColorBySelect(item)
+			},
+			touchstart(e, index) {
+				const {
+					pageX,
+					pageY
+				} = e.touches[0];
+				this.pageX = pageX;
+				this.pageY = pageY;
+				this.setPosition(pageX, pageY, index);
+			},
+			touchmove(e, index) {
+				const {
+					pageX,
+					pageY
+				} = e.touches[0];
+				this.moveX = pageX;
+				this.moveY = pageY;
+				this.setPosition(pageX, pageY, index);
+			},
+			touchend(e, index) {},
+			/**
+			 * 设置位置
+			 */
+			setPosition(x, y, index) {
+				this.index = index;
+				const {
+					top,
+					left,
+					width,
+					height
+				} = this.position[index];
+				// 设置最大最小值
+
+				this.site[index].left = Math.max(0, Math.min(parseInt(x - left), width));
+				if (index === 0) {
+					this.site[index].top = Math.max(0, Math.min(parseInt(y - top), height));
+					// 设置颜色
+					this.hsb.s = parseInt((100 * this.site[index].left) / width);
+					this.hsb.b = parseInt(100 - (100 * this.site[index].top) / height);
+					this.setColor();
+					this.setValue(this.rgba);
+				} else {
+					this.setControl(index, this.site[index].left);
+				}
+			},
+			/**
+			 * 设置 rgb 颜色
+			 */
+			setColor() {
+				const rgb = this.HSBToRGB(this.hsb);
+				this.rgba.r = rgb.r;
+				this.rgba.g = rgb.g;
+				this.rgba.b = rgb.b;
+			},
+			/**
+			 * 设置二进制颜色
+			 * @param {Object} rgb
+			 */
+			setValue(rgb) {
+				this.hex = '#' + this.rgbToHex(rgb);
+			},
+			setControl(index, x) {
+				const {
+					top,
+					left,
+					width,
+					height
+				} = this.position[index];
+
+				if (index === 1) {
+					this.hsb.h = parseInt((360 * x) / width);
+					this.bgcolor = this.HSBToRGB({
+						h: this.hsb.h,
+						s: 100,
+						b: 100
+					});
+					this.setColor()
+				} else {
+					this.rgba.a = (x / width).toFixed(1);
+				}
+				this.setValue(this.rgba);
+			},
+			/**
+			 * rgb 转 二进制 hex
+			 * @param {Object} rgb
+			 */
+			rgbToHex(rgb) {
+				let hex = [rgb.r.toString(16), rgb.g.toString(16), rgb.b.toString(16)];
+				hex.map(function(str, i) {
+					if (str.length == 1) {
+						hex[i] = '0' + str;
+					}
+				});
+				return hex.join('');
+			},
+			setColorBySelect(getrgb) {
+				const {
+					r,
+					g,
+					b,
+					a
+				} = getrgb;
+				let rgb = {}
+				rgb = {
+					r: r ? parseInt(r) : 0,
+					g: g ? parseInt(g) : 0,
+					b: b ? parseInt(b) : 0,
+					a: a ? a : 0,
+				};
+				this.rgba = rgb;
+				this.hsb = this.rgbToHsb(rgb);
+				this.changeViewByHsb();
+			},
+			changeViewByHsb() {
+				const [a, b, c] = this.position;
+				this.site[0].left = parseInt(this.hsb.s * a.width / 100);
+				this.site[0].top = parseInt((100 - this.hsb.b) * a.height / 100);
+				this.setColor(this.hsb.h);
+				this.setValue(this.rgba);
+				this.bgcolor = this.HSBToRGB({
+					h: this.hsb.h,
+					s: 100,
+					b: 100
+				});
+
+				this.site[1].left = this.hsb.h / 360 * b.width;
+				this.site[2].left = this.rgba.a * c.width;
+
+			},
+			/**
+			 * hsb 转 rgb
+			 * @param {Object} 颜色模式  H(hues)表示色相,S(saturation)表示饱和度,B(brightness)表示亮度
+			 */
+			HSBToRGB(hsb) {
+				let rgb = {};
+				let h = Math.round(hsb.h);
+				let s = Math.round((hsb.s * 255) / 100);
+				let v = Math.round((hsb.b * 255) / 100);
+				if (s == 0) {
+					rgb.r = rgb.g = rgb.b = v;
+				} else {
+					let t1 = v;
+					let t2 = ((255 - s) * v) / 255;
+					let t3 = ((t1 - t2) * (h % 60)) / 60;
+					if (h == 360) h = 0;
+					if (h < 60) {
+						rgb.r = t1;
+						rgb.b = t2;
+						rgb.g = t2 + t3;
+					} else if (h < 120) {
+						rgb.g = t1;
+						rgb.b = t2;
+						rgb.r = t1 - t3;
+					} else if (h < 180) {
+						rgb.g = t1;
+						rgb.r = t2;
+						rgb.b = t2 + t3;
+					} else if (h < 240) {
+						rgb.b = t1;
+						rgb.r = t2;
+						rgb.g = t1 - t3;
+					} else if (h < 300) {
+						rgb.b = t1;
+						rgb.g = t2;
+						rgb.r = t2 + t3;
+					} else if (h < 360) {
+						rgb.r = t1;
+						rgb.g = t2;
+						rgb.b = t1 - t3;
+					} else {
+						rgb.r = 0;
+						rgb.g = 0;
+						rgb.b = 0;
+					}
+				}
+				return {
+					r: Math.round(rgb.r),
+					g: Math.round(rgb.g),
+					b: Math.round(rgb.b)
+				};
+			},
+			rgbToHsb(rgb) {
+				let hsb = {
+					h: 0,
+					s: 0,
+					b: 0
+				};
+				let min = Math.min(rgb.r, rgb.g, rgb.b);
+				let max = Math.max(rgb.r, rgb.g, rgb.b);
+				let delta = max - min;
+				hsb.b = max;
+				hsb.s = max != 0 ? 255 * delta / max : 0;
+				if (hsb.s != 0) {
+					if (rgb.r == max) hsb.h = (rgb.g - rgb.b) / delta;
+					else if (rgb.g == max) hsb.h = 2 + (rgb.b - rgb.r) / delta;
+					else hsb.h = 4 + (rgb.r - rgb.g) / delta;
+				} else hsb.h = -1;
+				hsb.h *= 60;
+				if (hsb.h < 0) hsb.h = 0;
+				hsb.s *= 100 / 255;
+				hsb.b *= 100 / 255;
+				return hsb;
+			},
+			getSelectorQuery() {
+				const views = uni.createSelectorQuery().in(this);
+				views
+					.selectAll('.boxs')
+					.boundingClientRect(data => {
+						if (!data || data.length === 0) {
+							setTimeout(() => this.getSelectorQuery(), 20)
+							return
+						}
+						this.position = data;
+						// this.site[0].top = data[0].height;
+						// this.site[0].left = 0;
+						// this.site[1].left = data[1].width;
+						// this.site[2].left = data[2].width;
+						this.setColorBySelect(this.rgba);
+					})
+					.exec();
+			}
+		},
+		watch: {
+			spareColor(newVal) {
+				this.colorList = newVal;
+			}
+		}
+	};
+</script>
+
+<style>
+	.t-wrapper {
+		position: fixed;
+		top: 0;
+		bottom: 0;
+		left: 0;
+		width: 100%;
+		box-sizing: border-box;
+		z-index: 9999;
+	}
+
+	.t-box {
+		width: 100%;
+		position: absolute;
+		bottom: 0;
+		padding: 30upx 0;
+		padding-top: 0;
+		background: #fff;
+		transition: all 0.3s;
+		transform: translateY(100%);
+	}
+
+	.t-box.active {
+		transform: translateY(0%);
+	}
+
+	.t-header {
+		display: flex;
+		justify-content: space-between;
+		width: 100%;
+		height: 100upx;
+		border-bottom: 1px #eee solid;
+		box-shadow: 1px 0 2px rgba(0, 0, 0, 0.1);
+		background: #fff;
+	}
+
+	.t-header-button {
+		display: flex;
+		align-items: center;
+		width: 150upx;
+		height: 100upx;
+		font-size: 30upx;
+		color: #666;
+		padding-left: 20upx;
+	}
+
+	.t-header-button:last-child {
+		justify-content: flex-end;
+		padding-right: 20upx;
+	}
+
+	.t-mask {
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background: rgba(0, 0, 0, 0.6);
+		z-index: -1;
+		transition: all 0.3s;
+		opacity: 0;
+	}
+
+	.t-mask.active {
+		opacity: 1;
+	}
+
+	.t-color__box {
+		position: relative;
+		height: 400upx;
+		background: rgb(255, 0, 0);
+		overflow: hidden;
+		box-sizing: border-box;
+		margin: 0 20upx;
+		margin-top: 20upx;
+		box-sizing: border-box;
+	}
+
+	.t-background {
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
+	}
+
+	.t-color-mask {
+		position: absolute;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		width: 100%;
+		height: 400upx;
+		background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
+	}
+
+	.t-pointer {
+		position: absolute;
+		bottom: -8px;
+		left: -8px;
+		z-index: 2;
+		width: 15px;
+		height: 15px;
+		border: 1px #fff solid;
+		border-radius: 50%;
+	}
+
+	.t-show-color {
+		width: 100upx;
+		height: 50upx;
+	}
+
+	.t-control__box {
+		margin-top: 50upx;
+		width: 100%;
+		display: flex;
+		padding-left: 20upx;
+		box-sizing: border-box;
+	}
+
+	.t-control__color {
+		flex-shrink: 0;
+		width: 100upx;
+		height: 100upx;
+		border-radius: 50%;
+		background-color: #fff;
+		background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
+			linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
+		background-size: 36upx 36upx;
+		background-position: 0 0, 18upx 18upx;
+		border: 1px #eee solid;
+		overflow: hidden;
+	}
+
+	.t-control__color-content {
+		width: 100%;
+		height: 100%;
+	}
+
+	.t-control-box__item {
+		display: flex;
+		flex-direction: column;
+		justify-content: space-between;
+		width: 100%;
+		padding: 0 30upx;
+	}
+
+	.t-controller {
+		position: relative;
+		width: 100%;
+		height: 16px;
+		background-color: #fff;
+		background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
+			linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
+		background-size: 32upx 32upx;
+		background-position: 0 0, 16upx 16upx;
+	}
+
+	.t-hue {
+		width: 100%;
+		height: 100%;
+		background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
+	}
+
+	.t-transparency {
+		width: 100%;
+		height: 100%;
+		background: linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0));
+	}
+
+	.t-circle {
+		position: absolute;
+		/* right: -10px; */
+		top: -2px;
+		width: 20px;
+		height: 20px;
+		box-sizing: border-box;
+		border-radius: 50%;
+		background: #fff;
+		box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.1);
+	}
+
+	.t-result__box {
+		margin-top: 20upx;
+		padding: 10upx;
+		width: 100%;
+		display: flex;
+		box-sizing: border-box;
+	}
+
+	.t-result__item {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		padding: 10upx;
+		width: 100%;
+		box-sizing: border-box;
+	}
+
+	.t-result__box-input {
+		padding: 10upx 0;
+		width: 100%;
+		font-size: 28upx;
+		box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
+		color: #999;
+		text-align: center;
+		background: #fff;
+	}
+
+	.t-result__box-text {
+		margin-top: 10upx;
+		font-size: 28upx;
+		line-height: 2;
+	}
+
+	.t-select {
+		flex-shrink: 0;
+		width: 150upx;
+		padding: 0 30upx;
+	}
+
+	.t-select .t-result__box-input {
+		border-radius: 10upx;
+		border: none;
+		color: #999;
+		box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.1);
+		background: #fff;
+	}
+
+	.t-select .t-result__box-input:active {
+		box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.1);
+	}
+
+	.t-alternative {
+		display: flex;
+		flex-wrap: wrap;
+		/* justify-content: space-between; */
+		width: 100%;
+		padding-right: 10upx;
+		box-sizing: border-box;
+	}
+
+	.t-alternative__item {
+		margin-left: 12upx;
+		margin-top: 10upx;
+		width: 50upx;
+		height: 50upx;
+		border-radius: 10upx;
+		background-color: #fff;
+		background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
+			linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
+		background-size: 36upx 36upx;
+		background-position: 0 0, 18upx 18upx;
+		border: 1px #eee solid;
+		overflow: hidden;
+	}
+
+	.t-alternative__item-content {
+		width: 50upx;
+		height: 50upx;
+		background: rgba(255, 0, 0, 0.5);
+	}
+
+	.t-alternative__item:active {
+		transition: all 0.3s;
+		transform: scale(1.1);
+	}
+</style>

+ 89 - 0
components/wave/wave.vue

@@ -0,0 +1,89 @@
+<template>
+	<view class="wave-wrap waveAnimation">
+		<view class="waveWrapperInner bgTop"><view class="wave waveTop"></view></view>
+		<view class="waveWrapperInner bgMiddle"><view class="wave waveMiddle"></view></view>
+		<view class="waveWrapperInner bgBottom"><view class="wave waveBottom"></view></view>
+	</view>
+</template>
+
+<script>
+export default {
+	name: 'wave',
+	props: {
+		height: {
+			type: String,
+			default: '35rpx'
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.wave-wrap {
+	overflow: hidden;
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	top: 0;
+	margin: auto;
+}
+.waveWrapperInner {
+	position: absolute;
+	width: 100%;
+	overflow: hidden;
+	height: 100%;
+}
+.wave {
+	position: absolute;
+	left: 0;
+	width: 200%;
+	height: 100%;
+	background-repeat: repeat no-repeat;
+	background-position: 0 bottom;
+	transform-origin: center bottom;
+}
+
+.bgTop {
+	opacity: 0.4;
+}
+.waveTop {
+	background-size: 50% 45px;
+	background-image: url('~@/static/wave/wave-1.png');
+}
+.waveAnimation .waveTop {
+	animation: move_wave 4s linear infinite;
+}
+@keyframes move_wave {
+	0% {
+		transform: translateX(0) translateZ(0) scaleY(1);
+	}
+	50% {
+		transform: translateX(-25%) translateZ(0) scaleY(1);
+	}
+	100% {
+		transform: translateX(-50%) translateZ(0) scaleY(1);
+	}
+}
+.bgMiddle {
+	opacity: 0.6;
+}
+.waveMiddle {
+	background-size: 50% 40px;
+	background-image: url('~@/static/wave/wave-2.png');
+}
+.waveAnimation .waveMiddle {
+	animation: move_wave 3.5s linear infinite;
+}
+
+.bgBottom {
+	opacity: 0.95;
+}
+.waveBottom {
+	background-size: 50% 35px;
+	background-image: url('~@/static/wave/wave-1.png');
+}
+.waveAnimation .waveBottom {
+	animation: move_wave 2s linear infinite;
+}
+</style>

+ 20 - 0
config/ad.config.template.js

@@ -0,0 +1,20 @@
+/**
+ * 广告配置
+ */
+export default {
+	adpid: '', // uni-AD App广告位id,在uni-AD官网申请广告位
+	unitId: '', // 广告单元id,可在小程序管理后台的流量主模块新建 (非个人资质,小程序后台广告主开通申请)
+	frequency: 8, // 列表中,广告出现的频率(8=每8条数据出现一次广告)
+	// 首页广告
+	home: {
+		use: false,
+	},
+	// 文章列表广告
+	articles: {
+		use: false,
+	},
+	// 文章详情广告
+	articleDetail: {
+		use: false, // 是否启用
+	}
+}

+ 113 - 0
config/halo.config.template.js

@@ -0,0 +1,113 @@
+/**
+ *  功能:基础配置
+ *  作者:小莫唐尼
+ *  邮箱:studio@925i.cn
+ *  时间:2022年08月23日 15:19:14
+ *  版本:v0.1.0
+ *  修改记录:
+ *  修改内容:
+ *  修改人员:
+ *  修改时间:
+ */
+export default {
+	showCopyright: true, // 显示开源版权信息
+	showAbout: true, // 显示关于项目入口
+	uni_halo_logo: 'https://b.925i.cn/uni_halo/uni_halo_logo.png', // uni-halo的logo
+
+	apiUrl: '', // Api基础域名(您的halo博客基础域名或者是Halo后台管理系统api地址)
+	apiAuthorization: '', // Halo中-系统-博客设置-切换到高级选项-API设置-Access key 
+
+	title: '', // 博客标题
+	indexImageUrl: '', // 开屏首页图片	
+	miniCodeImageUrl: '', // 小程序码地址
+
+	author: {
+		name: '', // 昵称
+		avatar: '', // 头像地址
+		motto: '', // 格言
+	},
+
+	social: {
+		qq: "", // qq号
+		wechat: "", // 微信号
+		weibo: "", // 微博地址
+		email: "", // 邮箱地址
+		blog: "", // 博客地址
+		juejin: "", // 掘金地址
+		bilibili: "", // b站地址
+		gitee: "", // gitee地址
+		github: "", // github地址
+		csdn: "" // CSDN地址
+	},
+
+	defaultThumbnailUrl: '', // 默认封面图地址 
+	defaultImageUrl: '', // 默认图片地址 
+	defaultAvatarUrl: '', // 默认头像地址
+
+	loadingGifUrl: '', // 图片加载中的地址
+	loadingErrUrl: '', // 图片加载失败的地址
+	loadingEmptyUrl: '', // 加载图片为空地址
+
+	waveImageUrl: '', // 关于页面波浪图片
+
+	banner: { // 轮播图配置
+		type: 'article', //  轮播图数据源 list=下方配置 article=热门文章封面 
+		list: [],
+	},
+
+	quickNav: { // 快捷导航配置
+		use: true,
+		list: [{
+				icon: 'halocoloricon-classify',
+				text: '文章归档',
+				iconSize: 60,
+				color: 'blue',
+				type: 'page',
+				path: '/pagesA/archives/archives'
+			},
+			{
+				icon: 'halocoloricon-attent',
+				text: '恋爱日记',
+				iconSize: 60,
+				color: 'blue',
+				type: 'page',
+				path: '/pagesA/love/love'
+			},
+			{
+				icon: 'halocoloricon-calendar',
+				text: '个人日记',
+				iconSize: 60,
+				color: 'blue',
+				type: 'page',
+				path: '/pagesA/journal/journal'
+			},
+			{
+				icon: 'halocoloricon-message',
+				text: '留言板',
+				iconSize: 60,
+				color: 'blue',
+				type: 'page',
+				path: '/pagesA/leaving/leaving'
+			}
+		]
+	},
+
+	// 微信分享信息
+	wxShareConfig: {
+		title: '', // 小程序分享标题[非必填]
+		desc: '', // 小程序分享描述[非必填]
+		imageUrl: '', // 小程序分享时候图片地址[非必填]
+		path: '/pages/start/start', // 分享路径[非必填] - 基本不需要修改
+		copyLink: '/pages/start/start', // 复制链接[非必填]  - 基本不需要修改
+		query: {}, // 分享参数[非必填]  - 基本不需要填写
+	},
+
+	colors: [
+		'#39B449',
+		'#E44C41',
+		'#8698A2',
+		'#0080FE',
+		'#1CBCB4',
+		'#6638B5',
+	]
+}

+ 7 - 0
config/keys.js

@@ -0,0 +1,7 @@
+/**
+ * 配置key
+ */
+
+export default {
+	SHEET_LEAVING: 'leaving', // 留言板 
+}

+ 8 - 0
config/sheets.config.js

@@ -0,0 +1,8 @@
+/**
+ * 页面配置
+ */
+import AppKeys from './keys.js'
+
+export default {
+	[AppKeys.SHEET_LEAVING]: 65, // 留言板页面ID
+}

+ 20 - 0
index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <script>
+      var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
+        CSS.supports('top: constant(a)'))
+      document.write(
+        '<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
+        (coverSupport ? ', viewport-fit=cover' : '') + '" />')
+    </script>
+    <title></title>
+    <!--preload-links-->
+    <!--app-context-->
+  </head>
+  <body>
+    <div id="app"><!--app-html--></div>
+    <script type="module" src="/main.js"></script>
+  </body>
+</html>

+ 72 - 0
js_sdk/fy-showModal/index.js

@@ -0,0 +1,72 @@
+import FyShowModal from './showModal.js'
+
+export default class Fy{
+	/**
+	 * @author 大雄
+	 * @Date 2021年7月1日20:49:58
+	 * @description 二次封装showModel
+	*/
+	static showModal({ title = "提示",  content = "提示内容",  showCancel = true, backbutton = false, cancelText = "取消",  cancelColor = "#000000",  confirmText = "确定",  confirmColor = '#3CC51F', complete = false  } = {}) {
+	    return new Promise((resolve, reject) => {
+			// #ifdef APP-PLUS
+			if (this.get_app_platform() === 'android') { // android的使用自定义的模态框
+				new FyShowModal({ title, content, showCancel, backbutton, cancelText, cancelColor, confirmText, confirmColor,
+					success(res) {
+						if (res.confirm) {resolve(res) } else { reject(res) }
+					},
+					fail(err){
+						this.uniShowModal({ title, content, showCancel,  cancelText,  cancelColor,  confirmText,  confirmColor, complete }).then((res)=>resolve(res)).catch(err=>reject(err))
+					}
+				}).show();
+			} else { // ios直接用原生的
+				return this.uniShowModal({ title, content, showCancel,  cancelText,  cancelColor,  confirmText,  confirmColor, complete }).then((res)=>resolve(res)).catch(err=>reject(err))
+			}
+			// #endif
+			// #ifndef APP-PLUS
+			return this.uniShowModal({ title, content, showCancel,  cancelText,  cancelColor,  confirmText,  confirmColor, complete }).then((res)=>resolve(res)).catch(err=>reject(err))
+			// #endif
+	    })
+	}
+
+	// 原生showModal
+	static uniShowModal({ title,  content,  showCancel,  cancelText,  cancelColor,  confirmText,  confirmColor, complete  }) {
+		return new Promise((resolve, reject) => {
+			let appPlatform = null;
+			// #ifdef APP-PLUS
+			if (this.get_app_platform() === 'android' && showCancel) { // android的确认按钮在左边,需要统一到右边
+				appPlatform = 'android';
+				var tempConfirmText = confirmText;
+				var tempConfirmColor = confirmColor;
+				confirmText = cancelText;
+				cancelText = tempConfirmText;
+				confirmColor = cancelColor;
+				cancelColor = tempConfirmColor;
+			}
+			// #endif
+		    uni.showModal({ title, content, showCancel, cancelText, cancelColor, confirmText, confirmColor,
+		        success(res){
+		            if (complete) {
+		                resolve(res);
+		            } else if (res.confirm) {
+		                appPlatform === 'android' ? reject(res) : resolve(res)
+		            } else {
+						appPlatform === 'android' ? resolve(res) : reject(res)
+		            }
+		        },
+		        fail(err){ reject(err) }
+		    })
+		})
+	}
+
+	/**
+	 * @description 获取app平台(android | ios)
+	 * */
+	static get_app_platform() {
+		// #ifndef APP-PLUS
+		this.showModal({ content: '仅支持app' });
+		// #endif
+		// #ifdef APP-PLUS
+		return plus.os.name.toLowerCase(); 
+		// #endif
+	}
+}

+ 62 - 0
js_sdk/fy-showModal/router-interceptor.js

@@ -0,0 +1,62 @@
+/**
+ * @author 大雄
+ * @Date 2021年7月1日20:49:58
+ * @description 路由导航守卫(简单版,后续需要功能再完善)
+ */
+export default function() {
+	// 监听路由前进
+	function routerPush({ type = 'navigateTo' } = {}) {
+		routeWatchClearModal();
+	}
+	// 监听路由后退
+	function routerBack() {
+		routeWatchClearModal();
+	}
+
+	// 页面跳转后,销毁当前页面未关闭的弹框
+	function routeWatchClearModal() {
+		try {
+			var FyShowModalView = plus.nativeObj.View.getViewById("FyShowModalView");
+			if (FyShowModalView) {
+				FyShowModalView.clear();
+			}
+			var FyShowModalCancel = plus.nativeObj.View.getViewById("FyShowModalCancel");
+			if (FyShowModalCancel) {
+				FyShowModalCancel.clear();
+			}
+			var FyShowModalConfirm = plus.nativeObj.View.getViewById("FyShowModalConfirm");
+			if (FyShowModalConfirm) {
+				FyShowModalConfirm.clear();
+			}
+		} catch(err) {
+			console.log(err);
+		}
+	}
+	
+	uni.addInterceptor('navigateTo', {
+		success(e) {
+			routerPush({ type: 'navigateTo' });
+		}
+	})
+	uni.addInterceptor('redirectTo', {
+		success(e) {
+			routerPush({ type: 'redirectTo' });
+		}
+	})
+	uni.addInterceptor('reLaunch', {
+		success(e) {
+			routerPush({ type: 'reLaunch' });
+		}
+	})
+	uni.addInterceptor('switchTab', {
+		success(e) {
+			routerPush({ type: 'switchTab' });
+		}
+	})
+	uni.addInterceptor('navigateBack', {
+		success(e) {
+			routerBack();
+		}
+	})
+	
+}

+ 176 - 0
js_sdk/fy-showModal/showModal.js

@@ -0,0 +1,176 @@
+/**
+ * @description 替换android app的uni.showModal
+ */
+
+let modalIntance = null;
+export class fyShowModal {
+	constructor(options = {}) {
+		modalIntance = this;
+		
+		this.modalControl = null; // 模态框句柄
+		this.cancelModel = null;
+		this.confirmModel = null;
+
+		const { screenHeight, screenWidth } = uni.getSystemInfoSync();
+
+		this.modalPaddingTop = 12; // modal顶部的内边距
+		this.titleHeight = 34; // 标题的高度
+		this.contentHeight = 60; // 内容得高度
+		this.contentPaddingBottom = 10; // 内容的底部内边距
+		this.footerHeight = 50; // 底部按钮的高度
+
+		const modalHeight = this.modalPaddingTop + this.contentPaddingBottom + this.titleHeight + this.contentHeight + this.footerHeight; // 模态框内容高度
+
+		this.screenHeight = screenHeight;
+		this.modalWidth = options.contentWidth || screenWidth * 0.82; // 模态框内容宽度
+		this.modalHeight = modalHeight; // 模态框内容高度
+		this.modalLeft = (screenWidth - this.modalWidth) / 2; // 模态框距离左边距离
+		this.modalTop = (screenHeight / 2) - (modalHeight / 2) - 30; // 模态框距离顶部距离
+		this.titleTop = this.modalPaddingTop + this.modalTop; // title距离顶部的距离
+		this.contentTop = this.modalPaddingTop + this.modalTop + this.titleHeight; // content距离顶部的距离
+		this.contentLeft = this.modalLeft + (this.modalWidth * 0.1);
+		this.contentWidth = this.modalWidth * 0.8; // 内容的宽度
+		this.footerBorderTop = this.contentPaddingBottom + this.contentTop + this.contentHeight; // footer的边线距离顶部的距离
+		this.buttonWidth = this.modalWidth/2;
+		
+		// 物理返回键是否关闭弹框
+		this.backbutton = Boolean(options.backbutton);
+		
+		let opacity = options.opacity || 0.6; // mask透明度
+		let modal_title = options.title || '提示'; // 标题
+		let model_content = options.content || '提示内容'; // 提示内容
+		let maskClick = typeof options.maskClick === 'undefined' ? false : options.maskClick; // 是否可以点击mask关闭模态框
+		let cancelText = options.cancelText || '取消';
+		let confirmText = options.confirmText || '确定';
+		let cancelColor = options.cancelColor || '#000000';
+		let confirmColor = options.confirmColor || '#3CC51F';
+		let showCancel = typeof options.showCancel === 'undefined' ? true : options.showCancel; // 是否显示取消按钮
+		let align = options.align || 'center'; // 内容对齐方向
+		let successFn = () => {};
+		let failFn = () => {};
+		this.success = options.success || successFn; // 成功返回模态框
+		this.fail = options.fail || failFn; // 失败返回模态框
+
+		//#ifdef APP-PLUS
+		this.creatView({ height: `${this.screenHeight}px`, top: 0 }, opacity, maskClick, { 'title': modal_title, 'content': model_content, cancelText, confirmText, confirmColor, cancelColor, showCancel, align });
+		//#endif
+	}
+	//生成提示框view
+	creatView(style, opa, maskClick, modelInfo) {
+		try {
+			style = { left: '0px', width: '100%', ...style };
+			let view = new plus.nativeObj.View('FyShowModalView', style);
+			view.draw([
+				{ tag: 'rect', id: 'modal', color: `rgba(0,0,0,${opa})`, position: { top: '0px', left: '0px', width: '100%', height: '100%' } },
+				{ tag: 'rect', id: 'content', color: `rgb(255,255,255)`, rectStyles: { borderWidth: '0px', radius: '8px' }, position: { top: this.modalTop+'px', left: this.modalLeft+'px', width: this.modalWidth+'px', height: this.modalHeight + 'px' } },
+
+				{ tag: 'font', id: 'title', text: modelInfo.title, textStyles: { size: '18px', weight: 'bold', color: '#000000' }, position: { top: this.titleTop+'px', left: this.modalLeft+'px', width: this.modalWidth+'px', height: this.titleHeight+'px' } },
+
+				{ tag: 'font', id: 'text', text: modelInfo.content, textStyles: { size: '15px', color: '#666', whiteSpace: 'normal', align: modelInfo.align }, position: { top: this.contentTop+'px', left: this.contentLeft+'px', width: this.contentWidth+'px', height: this.contentHeight+'px' } },
+
+				{ tag: 'rect', id: 'line', color: '#efeff1', position: { top: this.footerBorderTop+'px', left: this.modalLeft+'px', width: this.modalWidth+'px', height: '1px' } },
+				{ tag: 'rect', id: 'line2', color: '#efeff1', position: { top: this.footerBorderTop+'px', left: '50%', width: modelInfo.showCancel ? '1px' : '0px', height: modelInfo.showCancel ? this.footerHeight+'px' : '0px' } }
+			]);
+
+			// 取消按钮
+			if (modelInfo.showCancel) {
+				let viewCancel = new plus.nativeObj.View('FyShowModalCancel', { width: this.buttonWidth+'px', height: this.footerHeight+'px', top: this.footerBorderTop + 'px', left: this.modalLeft+'px' });
+				viewCancel.draw([
+					{ tag: 'rect', id: 'cancelBackground', color: `rgba(255,255,255,0)`, rectStyles: { borderWidth: '0px', radius: '8px' }, position: { top: '0px', left: '0px', width: '100%', height: '100%' } },
+					{ tag: 'font', id: 'cancel', text: modelInfo.cancelText, textStyles: { color: modelInfo.cancelColor, size: '16px' } },
+				]);
+				viewCancel.addEventListener('click', (e) => {
+					viewconfirm.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'confirmBackground');
+					viewCancel.drawRect('#efeff1', {top:'0px',left:'0px',width:'100%',height:'100%'}, 'cancelBackground');
+					this.success({ confirm: false, cancel: true, mask: false })
+					this.hide();
+				}, false);
+				viewCancel.addEventListener('touchstart', (e)=>{
+					viewconfirm.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'confirmBackground');
+					viewCancel.drawRect({ color: '#efeff1', borderWidth: '0px', radius: '8px' }, {top:'0px',left:'0px',width:'100%',height:'100%'}, 'cancelBackground');
+				})
+				this.cancelModel = viewCancel;
+			}
+
+			// 确认
+			let viewconfirm = new plus.nativeObj.View('FyShowModalConfirm', { width: modelInfo.showCancel ? this.buttonWidth+'px' : this.modalWidth+'px', height: this.footerHeight+'px', top: this.footerBorderTop + 'px', left: modelInfo.showCancel ? '50%' : this.modalLeft+'px' });
+			// 绘制确认
+			viewconfirm.draw([
+				{ tag: 'rect', id: 'confirmBackground', color: `rgba(255,255,255,0)`, rectStyles: { borderWidth: '0px', radius: '8px' }, position: { top: '0px', left: '0px', width: '100%', height: '100%' } },
+				{ tag: 'font', id: 'confirm', text: modelInfo.confirmText, textStyles: { color: modelInfo.confirmColor, size: '16px' } },
+			]);
+			// 点击确认
+			viewconfirm.addEventListener('click', (e) => {
+				if (this.cancelModel) {
+					this.cancelModel.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'cancelBackground');
+				}
+				viewconfirm.drawRect('#efeff1', {top:'0px',left:'0px',width:'100%',height:'100%'}, 'confirmBackground');
+				this.success({ confirm: true, cancel: false, mask: false })
+				this.hide();
+			}, false);
+			viewconfirm.addEventListener('touchstart', (e)=>{
+				if (this.cancelModel) {
+					this.cancelModel.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'cancelBackground');
+				}
+				viewconfirm.drawRect({ color: '#efeff1', borderWidth: '0px', radius: '8px' }, {top:'0px',left:'0px',width:'100%',height:'100%'}, 'confirmBackground');
+			})
+
+			//点击蒙布
+			if (maskClick) {
+				view.addEventListener('click', (e) => {
+					this.success({ confirm: false, cancel: true, mask: true })
+					this.hide();
+					if (this.cancelModel) {
+						this.cancelModel.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'cancelBackground');
+					}
+					viewconfirm.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'confirmBackground');
+				}, false);
+			} else {
+				view.addEventListener('click', (e) => {
+					if (this.cancelModel) {
+						this.cancelModel.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'cancelBackground');
+					}
+					viewconfirm.drawRect({ color: 'rgba(255,255,255,0)', borderWidth: '0px', radius: '8px' }, {}, 'confirmBackground');
+				}, false);
+			}
+			this.modalControl = view;
+			this.confirmModel = viewconfirm;
+		} catch(err) {
+			this.fail(err);
+		}
+	}
+	// 显示模态框
+	show() {
+		this.modalControl.show();
+		if (this.cancelModel) {
+			this.cancelModel.show();
+		}
+		this.confirmModel.show();
+		
+		if (this.backbutton) {
+			plus.key.addEventListener('backbutton', this.handlerBackButton);
+		}
+	}
+	// 关闭模态框
+	hide() {
+		if (this.backbutton) {
+			plus.key.removeEventListener('backbutton', this.handlerBackButton);
+		}
+		this.modalControl.clear();
+		if (this.cancelModel) {
+			this.cancelModel.clear();
+		}
+		this.confirmModel.clear();
+	}
+	// 物理返回键方法
+	handlerBackButton() {
+		try {
+			modalIntance && modalIntance.success({ confirm: false, cancel: true, mask: false })
+			modalIntance && modalIntance.hide();
+		} catch(err) {
+			console.error(err)
+		}
+	}
+}
+
+export default fyShowModal;

+ 99 - 0
js_sdk/luch-request/luch-request/adapters/index.js

@@ -0,0 +1,99 @@
+import buildURL from '../helpers/buildURL'
+import buildFullPath from '../core/buildFullPath'
+import settle from '../core/settle'
+import { isUndefined } from "../utils"
+
+/**
+ * 返回可选值存在的配置
+ * @param {Array} keys - 可选值数组
+ * @param {Object} config2 - 配置
+ * @return {{}} - 存在的配置项
+ */
+const mergeKeys = (keys, config2) => {
+  let config = {}
+  keys.forEach(prop => {
+    if (!isUndefined(config2[prop])) {
+      config[prop] = config2[prop]
+    }
+  })
+  return config
+}
+export default (config) => {
+  return new Promise((resolve, reject) => {
+    let fullPath = buildURL(buildFullPath(config.baseURL, config.url), config.params)
+    const _config = {
+      url: fullPath,
+      header: config.header,
+      complete: (response) => {
+        config.fullPath = fullPath
+        response.config = config
+        try {
+          // 对可能字符串不是json 的情况容错
+          if (typeof response.data === 'string') {
+            response.data = JSON.parse(response.data)
+          }
+          // eslint-disable-next-line no-empty
+        } catch (e) {
+        }
+        settle(resolve, reject, response)
+      }
+    }
+    let requestTask
+    if (config.method === 'UPLOAD') {
+      delete _config.header['content-type']
+      delete _config.header['Content-Type']
+      let otherConfig = {
+        // #ifdef MP-ALIPAY
+        fileType: config.fileType,
+        // #endif
+        filePath: config.filePath,
+        name: config.name
+      }
+      const optionalKeys = [
+        // #ifdef APP-PLUS || H5
+        'files',
+        // #endif
+        // #ifdef H5
+        'file',
+        // #endif
+        // #ifdef H5 || APP-PLUS
+        'timeout',
+        // #endif
+        'formData'
+      ]
+      requestTask = uni.uploadFile({..._config, ...otherConfig, ...mergeKeys(optionalKeys, config)})
+    } else if (config.method === 'DOWNLOAD') {
+      // #ifdef H5 || APP-PLUS
+      if (!isUndefined(config['timeout'])) {
+        _config['timeout'] = config['timeout']
+      }
+      // #endif
+      requestTask = uni.downloadFile(_config)
+    } else {
+      const optionalKeys = [
+        'data',
+        'method',
+        // #ifdef H5 || APP-PLUS || MP-ALIPAY || MP-WEIXIN
+        'timeout',
+        // #endif
+        'dataType',
+        // #ifndef MP-ALIPAY
+        'responseType',
+        // #endif
+        // #ifdef APP-PLUS
+        'sslVerify',
+        // #endif
+        // #ifdef H5
+        'withCredentials',
+        // #endif
+        // #ifdef APP-PLUS
+        'firstIpv4',
+        // #endif
+      ]
+      requestTask = uni.request({..._config,...mergeKeys(optionalKeys, config)})
+    }
+    if (config.getTask) {
+      config.getTask(requestTask, config)
+    }
+  })
+}

+ 51 - 0
js_sdk/luch-request/luch-request/core/InterceptorManager.js

@@ -0,0 +1,51 @@
+'use strict'
+
+
+function InterceptorManager() {
+  this.handlers = []
+}
+
+/**
+ * Add a new interceptor to the stack
+ *
+ * @param {Function} fulfilled The function to handle `then` for a `Promise`
+ * @param {Function} rejected The function to handle `reject` for a `Promise`
+ *
+ * @return {Number} An ID used to remove interceptor later
+ */
+InterceptorManager.prototype.use = function use(fulfilled, rejected) {
+  this.handlers.push({
+    fulfilled: fulfilled,
+    rejected: rejected
+  })
+  return this.handlers.length - 1
+}
+
+/**
+ * Remove an interceptor from the stack
+ *
+ * @param {Number} id The ID that was returned by `use`
+ */
+InterceptorManager.prototype.eject = function eject(id) {
+  if (this.handlers[id]) {
+    this.handlers[id] = null
+  }
+}
+
+/**
+ * Iterate over all the registered interceptors
+ *
+ * This method is particularly useful for skipping over any
+ * interceptors that may have become `null` calling `eject`.
+ *
+ * @param {Function} fn The function to call for each interceptor
+ */
+InterceptorManager.prototype.forEach = function forEach(fn) {
+  this.handlers.forEach(h => {
+    if (h !== null) {
+      fn(h)
+    }
+  })
+}
+
+export default InterceptorManager

+ 200 - 0
js_sdk/luch-request/luch-request/core/Request.js

@@ -0,0 +1,200 @@
+/**
+ * @Class Request
+ * @description luch-request http请求插件
+ * @version 3.0.7
+ * @Author lu-ch
+ * @Date 2021-09-04
+ * @Email webwork.s@qq.com
+ * 文档: https://www.quanzhan.co/luch-request/
+ * github: https://github.com/lei-mu/luch-request
+ * DCloud: http://ext.dcloud.net.cn/plugin?id=392
+ * HBuilderX: beat-3.0.4 alpha-3.0.4
+ */
+
+
+import dispatchRequest from './dispatchRequest'
+import InterceptorManager from './InterceptorManager'
+import mergeConfig from './mergeConfig'
+import defaults from './defaults'
+import { isPlainObject } from '../utils'
+import clone from '../utils/clone'
+
+export default class Request {
+  /**
+   * @param {Object} arg - 全局配置
+   * @param {String} arg.baseURL - 全局根路径
+   * @param {Object} arg.header - 全局header
+   * @param {String} arg.method = [GET|POST|PUT|DELETE|CONNECT|HEAD|OPTIONS|TRACE] - 全局默认请求方式
+   * @param {String} arg.dataType = [json] - 全局默认的dataType
+   * @param {String} arg.responseType = [text|arraybuffer] - 全局默认的responseType。支付宝小程序不支持
+   * @param {Object} arg.custom - 全局默认的自定义参数
+   * @param {Number} arg.timeout - 全局默认的超时时间,单位 ms。默认60000。H5(HBuilderX 2.9.9+)、APP(HBuilderX 2.9.9+)、微信小程序(2.10.0)、支付宝小程序
+   * @param {Boolean} arg.sslVerify - 全局默认的是否验证 ssl 证书。默认true.仅App安卓端支持(HBuilderX 2.3.3+)
+   * @param {Boolean} arg.withCredentials - 全局默认的跨域请求时是否携带凭证(cookies)。默认false。仅H5支持(HBuilderX 2.6.15+)
+   * @param {Boolean} arg.firstIpv4 - 全DNS解析时优先使用ipv4。默认false。仅 App-Android 支持 (HBuilderX 2.8.0+)
+   * @param {Function(statusCode):Boolean} arg.validateStatus - 全局默认的自定义验证器。默认statusCode >= 200 && statusCode < 300
+   */
+  constructor(arg = {}) {
+    if (!isPlainObject(arg)) {
+      arg = {}
+      console.warn('设置全局参数必须接收一个Object')
+    }
+    this.config = clone({...defaults, ...arg})
+    this.interceptors = {
+      request: new InterceptorManager(),
+      response: new InterceptorManager()
+    }
+  }
+
+  /**
+   * @Function
+   * @param {Request~setConfigCallback} f - 设置全局默认配置
+   */
+  setConfig(f) {
+    this.config = f(this.config)
+  }
+
+  middleware(config) {
+    config = mergeConfig(this.config, config)
+    let chain = [dispatchRequest, undefined]
+    let promise = Promise.resolve(config)
+
+    this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
+      chain.unshift(interceptor.fulfilled, interceptor.rejected)
+    })
+
+    this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
+      chain.push(interceptor.fulfilled, interceptor.rejected)
+    })
+
+    while (chain.length) {
+      promise = promise.then(chain.shift(), chain.shift())
+    }
+
+    return promise
+  }
+
+  /**
+   * @Function
+   * @param {Object} config - 请求配置项
+   * @prop {String} options.url - 请求路径
+   * @prop {Object} options.data - 请求参数
+   * @prop {Object} [options.responseType = config.responseType] [text|arraybuffer] - 响应的数据类型
+   * @prop {Object} [options.dataType = config.dataType] - 如果设为 json,会尝试对返回的数据做一次 JSON.parse
+   * @prop {Object} [options.header = config.header] - 请求header
+   * @prop {Object} [options.method = config.method] - 请求方法
+   * @returns {Promise<unknown>}
+   */
+  request(config = {}) {
+    return this.middleware(config)
+  }
+
+  get(url, options = {}) {
+    return this.middleware({
+      url,
+      method: 'GET',
+      ...options
+    })
+  }
+
+  post(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'POST',
+      ...options
+    })
+  }
+
+  // #ifndef MP-ALIPAY
+  put(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'PUT',
+      ...options
+    })
+  }
+
+  // #endif
+
+  // #ifdef APP-PLUS || H5 || MP-WEIXIN || MP-BAIDU
+  delete(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'DELETE',
+      ...options
+    })
+  }
+
+  // #endif
+
+  // #ifdef H5 || MP-WEIXIN
+  connect(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'CONNECT',
+      ...options
+    })
+  }
+
+  // #endif
+
+  // #ifdef  H5 || MP-WEIXIN || MP-BAIDU
+  head(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'HEAD',
+      ...options
+    })
+  }
+
+  // #endif
+
+  // #ifdef APP-PLUS || H5 || MP-WEIXIN || MP-BAIDU
+  options(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'OPTIONS',
+      ...options
+    })
+  }
+
+  // #endif
+
+  // #ifdef H5 || MP-WEIXIN
+  trace(url, data, options = {}) {
+    return this.middleware({
+      url,
+      data,
+      method: 'TRACE',
+      ...options
+    })
+  }
+
+  // #endif
+
+  upload(url, config = {}) {
+    config.url = url
+    config.method = 'UPLOAD'
+    return this.middleware(config)
+  }
+
+  download(url, config = {}) {
+    config.url = url
+    config.method = 'DOWNLOAD'
+    return this.middleware(config)
+  }
+}
+
+
+/**
+ * setConfig回调
+ * @return {Object} - 返回操作后的config
+ * @callback Request~setConfigCallback
+ * @param {Object} config - 全局默认config
+ */

+ 20 - 0
js_sdk/luch-request/luch-request/core/buildFullPath.js

@@ -0,0 +1,20 @@
+'use strict'
+
+import isAbsoluteURL from '../helpers/isAbsoluteURL'
+import combineURLs from '../helpers/combineURLs'
+
+/**
+ * Creates a new URL by combining the baseURL with the requestedURL,
+ * only when the requestedURL is not already an absolute URL.
+ * If the requestURL is absolute, this function returns the requestedURL untouched.
+ *
+ * @param {string} baseURL The base URL
+ * @param {string} requestedURL Absolute or relative URL to combine
+ * @returns {string} The combined full path
+ */
+export default function buildFullPath(baseURL, requestedURL) {
+  if (baseURL && !isAbsoluteURL(requestedURL)) {
+    return combineURLs(baseURL, requestedURL)
+  }
+  return requestedURL
+}

+ 30 - 0
js_sdk/luch-request/luch-request/core/defaults.js

@@ -0,0 +1,30 @@
+/**
+ * 默认的全局配置
+ */
+
+
+export default {
+  baseURL: '',
+  header: {},
+  method: 'GET',
+  dataType: 'json',
+  // #ifndef MP-ALIPAY
+  responseType: 'text',
+  // #endif
+  custom: {},
+  // #ifdef H5 || APP-PLUS || MP-ALIPAY || MP-WEIXIN
+  timeout: 60000,
+  // #endif
+  // #ifdef APP-PLUS
+  sslVerify: true,
+  // #endif
+  // #ifdef H5
+  withCredentials: false,
+  // #endif
+  // #ifdef APP-PLUS
+  firstIpv4: false,
+  // #endif
+  validateStatus: function validateStatus(status) {
+    return status >= 200 && status < 300
+  }
+}

+ 6 - 0
js_sdk/luch-request/luch-request/core/dispatchRequest.js

@@ -0,0 +1,6 @@
+import adapter from '../adapters/index'
+
+
+export default (config) => {
+  return adapter(config)
+}

+ 103 - 0
js_sdk/luch-request/luch-request/core/mergeConfig.js

@@ -0,0 +1,103 @@
+import {deepMerge, isUndefined} from '../utils'
+
+/**
+ * 合并局部配置优先的配置,如果局部有该配置项则用局部,如果全局有该配置项则用全局
+ * @param {Array} keys - 配置项
+ * @param {Object} globalsConfig - 当前的全局配置
+ * @param {Object} config2 - 局部配置
+ * @return {{}}
+ */
+const mergeKeys = (keys, globalsConfig, config2) => {
+  let config = {}
+  keys.forEach(prop => {
+    if (!isUndefined(config2[prop])) {
+      config[prop] = config2[prop]
+    } else if (!isUndefined(globalsConfig[prop])) {
+      config[prop] = globalsConfig[prop]
+    }
+  })
+  return config
+}
+/**
+ *
+ * @param globalsConfig - 当前实例的全局配置
+ * @param config2 - 当前的局部配置
+ * @return - 合并后的配置
+ */
+export default (globalsConfig, config2 = {}) => {
+  const method = config2.method || globalsConfig.method || 'GET'
+  let config = {
+    baseURL: globalsConfig.baseURL || '',
+    method: method,
+    url: config2.url || '',
+    params: config2.params || {},
+    custom: {...(globalsConfig.custom || {}), ...(config2.custom || {})},
+    header: deepMerge(globalsConfig.header || {}, config2.header || {})
+  }
+  const defaultToConfig2Keys = ['getTask', 'validateStatus']
+  config = {...config, ...mergeKeys(defaultToConfig2Keys, globalsConfig, config2)}
+
+  // eslint-disable-next-line no-empty
+  if (method === 'DOWNLOAD') {
+    // #ifdef H5 || APP-PLUS
+    if (!isUndefined(config2.timeout)) {
+      config['timeout'] = config2['timeout']
+    } else if (!isUndefined(globalsConfig.timeout)) {
+      config['timeout'] = globalsConfig['timeout']
+    }
+    // #endif
+  } else if (method === 'UPLOAD') {
+    delete config.header['content-type']
+    delete config.header['Content-Type']
+    const uploadKeys = [
+      // #ifdef APP-PLUS || H5
+      'files',
+      // #endif
+      // #ifdef MP-ALIPAY
+      'fileType',
+      // #endif
+      // #ifdef H5
+      'file',
+      // #endif
+      'filePath',
+      'name',
+      // #ifdef H5 || APP-PLUS
+      'timeout',
+      // #endif
+      'formData',
+    ]
+    uploadKeys.forEach(prop => {
+      if (!isUndefined(config2[prop])) {
+        config[prop] = config2[prop]
+      }
+    })
+    // #ifdef H5 || APP-PLUS
+    if (isUndefined(config.timeout) && !isUndefined(globalsConfig.timeout)) {
+      config['timeout'] = globalsConfig['timeout']
+    }
+    // #endif
+  } else {
+    const defaultsKeys = [
+      'data',
+      // #ifdef H5 || APP-PLUS || MP-ALIPAY || MP-WEIXIN
+      'timeout',
+      // #endif
+      'dataType',
+      // #ifndef MP-ALIPAY
+      'responseType',
+      // #endif
+      // #ifdef APP-PLUS
+      'sslVerify',
+      // #endif
+      // #ifdef H5
+      'withCredentials',
+      // #endif
+      // #ifdef APP-PLUS
+      'firstIpv4',
+      // #endif
+    ]
+    config = {...config, ...mergeKeys(defaultsKeys, globalsConfig, config2)}
+  }
+
+  return config
+}

+ 16 - 0
js_sdk/luch-request/luch-request/core/settle.js

@@ -0,0 +1,16 @@
+/**
+ * Resolve or reject a Promise based on response status.
+ *
+ * @param {Function} resolve A function that resolves the promise.
+ * @param {Function} reject A function that rejects the promise.
+ * @param {object} response The response.
+ */
+export default function settle(resolve, reject, response) {
+  const validateStatus = response.config.validateStatus
+  const status = response.statusCode
+  if (status && (!validateStatus || validateStatus(status))) {
+    resolve(response)
+  } else {
+    reject(response)
+  }
+}

+ 69 - 0
js_sdk/luch-request/luch-request/helpers/buildURL.js

@@ -0,0 +1,69 @@
+'use strict'
+
+import * as utils from './../utils'
+
+function encode(val) {
+  return encodeURIComponent(val).
+    replace(/%40/gi, '@').
+    replace(/%3A/gi, ':').
+    replace(/%24/g, '$').
+    replace(/%2C/gi, ',').
+    replace(/%20/g, '+').
+    replace(/%5B/gi, '[').
+    replace(/%5D/gi, ']')
+}
+
+/**
+ * Build a URL by appending params to the end
+ *
+ * @param {string} url The base of the url (e.g., http://www.google.com)
+ * @param {object} [params] The params to be appended
+ * @returns {string} The formatted url
+ */
+export default function buildURL(url, params) {
+  /*eslint no-param-reassign:0*/
+  if (!params) {
+    return url
+  }
+
+  var serializedParams
+  if (utils.isURLSearchParams(params)) {
+    serializedParams = params.toString()
+  } else {
+    var parts = []
+
+    utils.forEach(params, function serialize(val, key) {
+      if (val === null || typeof val === 'undefined') {
+        return
+      }
+
+      if (utils.isArray(val)) {
+        key = key + '[]'
+      } else {
+        val = [val]
+      }
+
+      utils.forEach(val, function parseValue(v) {
+        if (utils.isDate(v)) {
+          v = v.toISOString()
+        } else if (utils.isObject(v)) {
+          v = JSON.stringify(v)
+        }
+        parts.push(encode(key) + '=' + encode(v))
+      })
+    })
+
+    serializedParams = parts.join('&')
+  }
+
+  if (serializedParams) {
+    var hashmarkIndex = url.indexOf('#')
+    if (hashmarkIndex !== -1) {
+      url = url.slice(0, hashmarkIndex)
+    }
+
+    url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams
+  }
+
+  return url
+}

部分文件因文件數量過多而無法顯示