Sfoglia il codice sorgente

以 bili 仓库 master 分支的 be9a09bd 版本为起点

reghao 2 anni fa
commit
72f882b458
100 ha cambiato i file con 8481 aggiunte e 0 eliminazioni
  1. 2 0
      .browserslistrc
  2. 5 0
      .editorconfig
  3. 2 0
      .env.development
  4. 2 0
      .env.production
  5. 200 0
      .eslintrc.js
  6. 22 0
      .gitignore
  7. 4 0
      Dockerfile
  8. 21 0
      LICENSE
  9. 81 0
      README.md
  10. 5 0
      babel.config.js
  11. 7 0
      build.sh
  12. BIN
      img/image-20200817121128341.png
  13. 64 0
      package.json
  14. 18 0
      public/index.html
  15. BIN
      public/logo.png
  16. 65 0
      src/App.vue
  17. 72 0
      src/api/account.js
  18. 48 0
      src/api/admin.js
  19. 45 0
      src/api/article.js
  20. 40 0
      src/api/audio.js
  21. 61 0
      src/api/cam.js
  22. 33 0
      src/api/collect.js
  23. 21 0
      src/api/comment.js
  24. 22 0
      src/api/content.js
  25. 44 0
      src/api/disk.js
  26. 60 0
      src/api/image.js
  27. 24 0
      src/api/map.js
  28. 22 0
      src/api/search.js
  29. 22 0
      src/api/status.js
  30. 16 0
      src/api/timeline.js
  31. 41 0
      src/api/user.js
  32. 168 0
      src/api/video.js
  33. 20 0
      src/api/visit.js
  34. 20 0
      src/assets/css/base.css
  35. 148 0
      src/assets/css/global.scss
  36. 155 0
      src/assets/css/login-auth.scss
  37. 62 0
      src/assets/css/reset.css
  38. 15 0
      src/assets/css/variable.scss
  39. 5 0
      src/assets/icon/iconfont.css
  40. BIN
      src/assets/icon/iconfont.eot
  41. 29 0
      src/assets/icon/iconfont.svg
  42. BIN
      src/assets/icon/iconfont.ttf
  43. BIN
      src/assets/icon/iconfont.woff
  44. BIN
      src/assets/img/icon/avatar.png
  45. BIN
      src/assets/img/icon/backtop.png
  46. BIN
      src/assets/img/icon/erweima.png
  47. BIN
      src/assets/img/icon/exit.png
  48. BIN
      src/assets/img/icon/github.png
  49. BIN
      src/assets/img/icon/history.png
  50. BIN
      src/assets/img/icon/like.png
  51. BIN
      src/assets/img/icon/logo.png
  52. BIN
      src/assets/img/icon/mylike.png
  53. BIN
      src/assets/img/icon/not-collection.png
  54. BIN
      src/assets/img/icon/not-history.png
  55. BIN
      src/assets/img/icon/not-result.png
  56. BIN
      src/assets/img/icon/play-icon.png
  57. BIN
      src/assets/img/icon/profile.png
  58. BIN
      src/assets/img/icon/recommmand-icon.png
  59. BIN
      src/assets/img/icon/search-result.png
  60. BIN
      src/assets/img/icon/speaker.png
  61. BIN
      src/assets/img/icon/weixin.png
  62. BIN
      src/assets/img/logo.png
  63. BIN
      src/assets/img/moviecover/py.jpg
  64. 4 0
      src/assets/js/const.js
  65. 200 0
      src/assets/js/mixin.js
  66. 96 0
      src/assets/js/utils.js
  67. 76 0
      src/components/LivePlayer.vue
  68. 139 0
      src/components/RichText.vue
  69. 257 0
      src/components/StampBadge.vue
  70. 219 0
      src/components/VideoPlayer.vue
  71. 106 0
      src/components/VideoPreviewPlayer.vue
  72. 65 0
      src/components/card/ArticleCard.vue
  73. 171 0
      src/components/card/AudioCard.vue
  74. 196 0
      src/components/card/HistoryVideoCard.vue
  75. 237 0
      src/components/card/HotSearch.vue
  76. 156 0
      src/components/card/ImageAlbumCard.vue
  77. 74 0
      src/components/card/PermissionDeniedCard.vue
  78. 111 0
      src/components/card/SideVideoCard.vue
  79. 85 0
      src/components/card/SiteNotice.vue
  80. 166 0
      src/components/card/StatusCard.vue
  81. 67 0
      src/components/card/TextCard.vue
  82. 139 0
      src/components/card/UserAvatarCard.vue
  83. 199 0
      src/components/card/VideoCard.vue
  84. 449 0
      src/components/comment/components/CommentForm.vue
  85. 301 0
      src/components/comment/components/CommentItem.vue
  86. 12 0
      src/components/comment/components/CommentList.js
  87. 264 0
      src/components/comment/components/EmojiSelector.vue
  88. 11 0
      src/components/comment/index.js
  89. 561 0
      src/components/comment/index.vue
  90. 49 0
      src/components/layout/FooterBar.vue
  91. 71 0
      src/components/layout/LoginBar.vue
  92. 371 0
      src/components/layout/NavBar.vue
  93. 129 0
      src/components/upload/EditArticle.vue
  94. 91 0
      src/components/upload/EditAudio.vue
  95. 346 0
      src/components/upload/EditImage.vue
  96. 447 0
      src/components/upload/EditVideo.vue
  97. 80 0
      src/components/upload/PublishArticle.vue
  98. 295 0
      src/components/upload/PublishAudio.vue
  99. 288 0
      src/components/upload/PublishFile.vue
  100. 262 0
      src/components/upload/PublishImage.vue

+ 2 - 0
.browserslistrc

@@ -0,0 +1,2 @@
+> 1%
+last 2 versions

+ 5 - 0
.editorconfig

@@ -0,0 +1,5 @@
+[*.{js,jsx,ts,tsx,vue}]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+insert_final_newline = true

+ 2 - 0
.env.development

@@ -0,0 +1,2 @@
+VUE_APP_SERVER_URL=//api.reghao.cn
+VUE_APP_OSS_URL=//oss.reghao.cn/

+ 2 - 0
.env.production

@@ -0,0 +1,2 @@
+VUE_APP_SERVER_URL=//api1.reghao.cn
+VUE_APP_OSS_URL=//oss1.reghao.cn/

+ 200 - 0
.eslintrc.js

@@ -0,0 +1,200 @@
+module.exports = {
+  root: true,
+  parserOptions: {
+    parser: 'babel-eslint',
+    sourceType: 'module'
+  },
+  env: {
+    browser: true,
+    node: true,
+    es6: true,
+  },
+  extends: ['plugin:vue/recommended', 'eslint:recommended'],
+
+  // add your custom rules here
+  //it is base on https://github.com/vuejs/eslint-config-vue
+  rules: {
+    "vue/max-attributes-per-line": [2, {
+      "singleline": 10,
+      "multiline": {
+        "max": 1,
+        "allowFirstLine": false
+      }
+    }],
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/multiline-html-element-content-newline":"off",
+    "vue/require-default-prop": "off",
+    "vue/name-property-casing": ["error", "PascalCase"],
+    "vue/no-v-html": "off",
+    'accessor-pairs': 2,
+    'arrow-spacing': [2, {
+      'before': true,
+      'after': true
+    }],
+    'block-spacing': [2, 'always'],
+    'brace-style': [2, '1tbs', {
+      'allowSingleLine': true
+    }],
+    'camelcase': [0, {
+      'properties': 'always'
+    }],
+    'comma-dangle': [2, 'never'],
+    'comma-spacing': [2, {
+      'before': false,
+      'after': true
+    }],
+    'comma-style': [2, 'last'],
+    'constructor-super': 2,
+    'curly': [2, 'multi-line'],
+    'dot-location': [2, 'property'],
+    'eol-last': 2,
+    'eqeqeq': ["error", "always", {"null": "ignore"}],
+    'generator-star-spacing': [2, {
+      'before': true,
+      'after': true
+    }],
+    'handle-callback-err': [2, '^(err|error)$'],
+    'indent': [2, 2, {
+      'SwitchCase': 1
+    }],
+    'jsx-quotes': [2, 'prefer-single'],
+    'key-spacing': [2, {
+      'beforeColon': false,
+      'afterColon': true
+    }],
+    'keyword-spacing': [2, {
+      'before': true,
+      'after': true
+    }],
+    'new-cap': [2, {
+      'newIsCap': true,
+      'capIsNew': false
+    }],
+    'new-parens': 2,
+    'no-array-constructor': 2,
+    'no-caller': 2,
+    'no-console': 'off',
+    'no-class-assign': 2,
+    'no-cond-assign': 2,
+    'no-const-assign': 2,
+    'no-control-regex': 0,
+    'no-delete-var': 2,
+    'no-dupe-args': 2,
+    'no-dupe-class-members': 2,
+    'no-dupe-keys': 2,
+    'no-duplicate-case': 2,
+    'no-empty-character-class': 2,
+    'no-empty-pattern': 2,
+    'no-eval': 2,
+    'no-ex-assign': 2,
+    'no-extend-native': 2,
+    'no-extra-bind': 2,
+    'no-extra-boolean-cast': 2,
+    'no-extra-parens': [2, 'functions'],
+    'no-fallthrough': 2,
+    'no-floating-decimal': 2,
+    'no-func-assign': 2,
+    'no-implied-eval': 2,
+    'no-inner-declarations': [2, 'functions'],
+    'no-invalid-regexp': 2,
+    'no-irregular-whitespace': 2,
+    'no-iterator': 2,
+    'no-label-var': 2,
+    'no-labels': [2, {
+      'allowLoop': false,
+      'allowSwitch': false
+    }],
+    'no-lone-blocks': 2,
+    'no-mixed-spaces-and-tabs': 2,
+    'no-multi-spaces': 2,
+    'no-multi-str': 2,
+    'no-multiple-empty-lines': [2, {
+      'max': 1
+    }],
+    'no-native-reassign': 2,
+    'no-negated-in-lhs': 2,
+    'no-new-object': 2,
+    'no-new-require': 2,
+    'no-new-symbol': 2,
+    'no-new-wrappers': 2,
+    'no-obj-calls': 2,
+    'no-octal': 2,
+    'no-octal-escape': 2,
+    'no-path-concat': 2,
+    'no-proto': 2,
+    'no-redeclare': 2,
+    'no-regex-spaces': 2,
+    'no-return-assign': [2, 'except-parens'],
+    'no-self-assign': 2,
+    'no-self-compare': 2,
+    'no-sequences': 2,
+    'no-shadow-restricted-names': 2,
+    'no-spaced-func': 2,
+    'no-sparse-arrays': 2,
+    'no-this-before-super': 2,
+    'no-throw-literal': 2,
+    'no-trailing-spaces': 2,
+    'no-undef': 2,
+    'no-undef-init': 2,
+    'no-unexpected-multiline': 2,
+    'no-unmodified-loop-condition': 2,
+    'no-unneeded-ternary': [2, {
+      'defaultAssignment': false
+    }],
+    'no-unreachable': 2,
+    'no-unsafe-finally': 2,
+    'no-unused-vars': [2, {
+      'vars': 'all',
+      'args': 'none'
+    }],
+    'no-useless-call': 2,
+    'no-useless-computed-key': 2,
+    'no-useless-constructor': 2,
+    'no-useless-escape': 0,
+    'no-whitespace-before-property': 2,
+    'no-with': 2,
+    'one-var': [2, {
+      'initialized': 'never'
+    }],
+    'operator-linebreak': [2, 'after', {
+      'overrides': {
+        '?': 'before',
+        ':': 'before'
+      }
+    }],
+    'padded-blocks': [2, 'never'],
+    'quotes': [2, 'single', {
+      'avoidEscape': true,
+      'allowTemplateLiterals': true
+    }],
+    'semi': [2, 'never'],
+    'semi-spacing': [2, {
+      'before': false,
+      'after': true
+    }],
+    'space-before-blocks': [2, 'always'],
+    'space-before-function-paren': [2, 'never'],
+    'space-in-parens': [2, 'never'],
+    'space-infix-ops': 2,
+    'space-unary-ops': [2, {
+      'words': true,
+      'nonwords': false
+    }],
+    'spaced-comment': [2, 'always', {
+      'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ',']
+    }],
+    'template-curly-spacing': [2, 'never'],
+    'use-isnan': 2,
+    'valid-typeof': 2,
+    'wrap-iife': [2, 'any'],
+    'yield-star-spacing': [2, 'both'],
+    'yoda': [2, 'never'],
+    'prefer-const': 2,
+    'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
+    'object-curly-spacing': [2, 'always', {
+      objectsInObjects: false
+    }],
+    'array-bracket-spacing': [2, 'never'],
+    "require-default-prop": "off"
+  }
+}

+ 22 - 0
.gitignore

@@ -0,0 +1,22 @@
+.DS_Store
+node_modules
+/dist
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+package-lock.json

+ 4 - 0
Dockerfile

@@ -0,0 +1,4 @@
+FROM docker.reghao.cn/nginx/npm:1.21
+
+RUN sed -i 's/8080/8082/' /etc/nginx/conf.d/http.conf
+COPY ./dist/ /opt/webroot/

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 phk422
+
+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.

+ 81 - 0
README.md

@@ -0,0 +1,81 @@
+# 基于Vue,ElementUI的在现视频点播系统
+
+[项目演示](http://182.92.148.170:8080)
+
+**使用到的技术**
+
+```
+UI框架:EelementUI
+网络请求:axios
+视频播放组件:vue-player
+```
+
+**基本功能**
+
+```
+1.视频浏览
+2.视频搜索
+3.推荐视频
+4.视频排行榜
+5.视频收藏,为视频添加标签
+6.播放历史记录
+7.用户注册登录
+8.视频评论
+```
+
+**后端接口**
+
+打开vue.config.js配置服务接口地址:
+
+```javascript
+devServer: {
+	port: 8000, // 项目的端口号
+	// 跨域代理配置
+	proxy: {
+		'/api': {
+			target: 'http://localhost:8888', //这里是接口地址,接口地址联系作者获取
+			ws: true,//是否代理websockets
+			changeOrigin: true,   // 设置同源  默认false,是否需要改变原始主机头为目标URL
+			pathRewrite: {
+				'^/api': ''
+			}		
+		}
+	}
+},
+```
+
+
+
+```
+接口地址可添加微信/QQ获取:
+邮箱地址:1769476788@qq.com
+微信账号/QQ:1769476788
+```
+
+**个人微信**
+
+<img src="img/image-20200817121128341.png" alt="image-20200817121128341" width="260px" />
+
+后面会将后端接口代码push上来!
+
+### 安装依赖
+
+```
+npm install
+```
+
+### 启动服务
+
+```
+npm run serve
+```
+
+启动服务后打开浏览器,访问http://localhost:8000
+
+### 项目打包
+
+```
+npm run build
+```
+
+

+ 5 - 0
babel.config.js

@@ -0,0 +1,5 @@
+module.exports = {
+  presets: [
+    '@vue/cli-plugin-babel/preset'
+  ]
+}

+ 7 - 0
build.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+
+commit_id=`git rev-parse HEAD | cut -c 1-8`
+npm run build
+docker build -t docker.reghao.cn/tnb/tnbapp:${commit_id} .
+docker push docker.reghao.cn/tnb/tnbapp:${commit_id}
+rm -rf dist

BIN
img/image-20200817121128341.png


+ 64 - 0
package.json

@@ -0,0 +1,64 @@
+{
+  "name": "tnbapp",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "serve": "vue-cli-service serve",
+    "build": "vue-cli-service build",
+    "lint": "vue-cli-service lint"
+  },
+  "dependencies": {
+    "@amap/amap-jsapi-loader": "^1.0.1",
+    "@liripeng/vue-audio-player": "^1.5.0",
+    "axios": "^0.19.2",
+    "babel-plugin-prismjs": "^2.0.1",
+    "core-js": "^3.6.4",
+    "crypto-js": "^4.1.1",
+    "dashjs": "^4.2.0",
+    "dplayer": "^1.26.0",
+    "element-ui": "^2.13.0",
+    "flv.js": "^1.6.2",
+    "hls.js": "^1.1.2",
+    "js-cookie": "2.2.0",
+    "jsencrypt": "^3.2.1",
+    "mavon-editor": "^2.10.4",
+    "nprogress": "^0.2.0",
+    "prismjs": "^1.25.0",
+    "shaka-player": "^3.2.1",
+    "svg-sprite-loader": "^5.0.0",
+    "v-viewer": "^1.6.4",
+    "videojs-contrib-hls": "^5.15.0",
+    "videojs-flash": "^2.2.1",
+    "vue": "^2.6.11",
+    "vue-amap": "^0.5.10",
+    "vue-baidu-map": "^0.21.22",
+    "vue-clipboard2": "^0.3.3",
+    "vue-cookies": "^1.7.0",
+    "vue-quill-editor": "^3.0.6",
+    "vue-router": "^3.4.5",
+    "vue-simple-uploader": "^0.7.6",
+    "vuex": "^3.4.0"
+  },
+  "devDependencies": {
+    "@vue/cli-plugin-babel": "^4.5.13",
+    "@vue/cli-plugin-eslint": "^4.5.13",
+    "@vue/cli-service": "^4.2.0",
+    "@vue/eslint-config-standard": "^5.1.2",
+    "babel-eslint": "^10.1.0",
+    "eslint": "^6.7.2",
+    "eslint-plugin-import": "^2.20.2",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-promise": "^4.2.1",
+    "eslint-plugin-standard": "^4.0.0",
+    "eslint-plugin-vue": "^6.2.2",
+    "vue-template-compiler": "^2.6.11",
+    "babel-plugin-import": "^1.13.1",
+    "compression-webpack-plugin": "^6.1.1",
+    "sass": "1.26.8",
+    "sass-loader": "8.0.2",
+    "style-resources-loader": "^1.4.1",
+    "vue-cli-plugin-style-resources-loader": "^0.1.4",
+    "vue-svg-component-runtime": "^1.0.1",
+    "vue-svg-icon-loader": "^2.1.1"
+  }
+}

+ 18 - 0
public/index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width,initial-scale=1.0">
+    <link rel="icon" href="<%= BASE_URL %>logo.png">
+<!--    <title><%= htmlWebpackPlugin.options.title %></title>-->
+    <title>tnb</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+    </noscript>
+    <div id="app"></div>
+    <!-- built files will be auto injected -->
+  </body>
+</html>

BIN
public/logo.png


+ 65 - 0
src/App.vue

@@ -0,0 +1,65 @@
+<template>
+  <div id="app">
+    <!--导航栏-->
+    <!--    <nav-bar v-if="type===1" />-->
+    <!--下面的部分通过路由动态决定渲染与否-->
+    <!--exclude,其值为正则,匹配到的组件的名称会被排除在keep-alive之外-->
+    <keep-alive exclude="Collection,History">
+      <router-view />
+    </keep-alive>
+    <!--页脚-->
+    <!--    <footer-bar />-->
+    <!--回到顶部按钮-->
+    <!--    <el-backtop :visibility-height="100" :bottom="60" />-->
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'App',
+  components: {
+  },
+  data() {
+    return {
+      type: 2
+    }
+  },
+  created() {
+    /**
+     * 防止vuex中的state在界面刷新后丢失
+     */
+    // 在页面加载时读取sessionStorage里的状态信息
+    if (sessionStorage.getItem('store')) {
+      this.$store.replaceState(Object.assign({}, this.$store.state, JSON.parse(sessionStorage.getItem('store'))))
+    }
+
+    // 在页面刷新时将vuex里的信息保存到sessionStorage里
+    window.addEventListener('beforeunload', () => {
+      sessionStorage.setItem('store', JSON.stringify(this.$store.state))
+    })
+  },
+  methods: {
+  }
+
+}
+</script>
+
+<style>
+@import "assets/css/base.css";
+
+#app {
+  overflow-y: scroll;
+}
+.searchd {
+  margin-top: 15px;
+  text-align: center;
+}
+
+/*.fade-enter-active,.fade-leave-active {
+  -webkit-transition:opacity 1s;
+  transition:opacity 1s
+}
+.fade-enter,.fade-leave-to {
+  opacity:0
+}*/
+</style>

+ 72 - 0
src/api/account.js

@@ -0,0 +1,72 @@
+import { get, post } from '@/utils/request'
+
+const accountApi = {
+  checkUsernameApi: '/api/user/account/check/username',
+  selectUsernameApi: '/api/user/account/select/username',
+  checkEmailApi: '/api/user/account/check/email',
+
+  verifyCodeApi: '/api/auth/code/verify',
+  captchaCodeApi: '/api/auth/code/captcha',
+  pubkeyApi: '/api/auth/code/pubkey',
+
+  accountVipApi: '/api/account/vip/basic',
+  registerApi: '/api/account/user/register',
+  resetPasswordApi: '/api/account/user/reset',
+  updateAvatarApi: '/api/account/profile/avatar',
+  loginApi: '/api/auth/signin',
+  logoutApi: '/api/auth/signout'
+}
+
+export function isUsernameExist(username) {
+  return get(accountApi.checkUsernameApi + '?username=' + username)
+}
+
+export function selectUsername(username) {
+  return post(accountApi.selectUsernameApi + '/' + username)
+}
+
+export function isEmailExist(email) {
+  return get(accountApi.checkEmailApi + '/' + email)
+}
+
+export function register(userRegistry) {
+  return post(accountApi.registerApi, userRegistry)
+}
+
+export function resetPassword(resetPasswordData) {
+  return post(accountApi.resetPasswordApi, resetPasswordData)
+}
+
+// 获取公钥
+export function getPubkey() {
+  return get(accountApi.pubkeyApi)
+}
+
+// 获取图形验证码
+export function getCaptchaCode(captchaCodeApi) {
+  return get(accountApi.captchaCodeApi)
+}
+
+// 获取短信验证码
+export function getVerifyCode(verifyCode) {
+  return post(accountApi.verifyCodeApi, verifyCode)
+}
+
+// 登录
+export function login(loginData) {
+  return post(accountApi.loginApi, loginData)
+}
+
+// 注销
+export function logout() {
+  return get(accountApi.logoutApi)
+}
+
+// VIP
+export function vip() {
+  return post(accountApi.accountVipApi)
+}
+
+export function updateAvatar(userAvatar) {
+  return post(accountApi.updateAvatarApi, userAvatar)
+}

+ 48 - 0
src/api/admin.js

@@ -0,0 +1,48 @@
+import { delete0, get, post } from '@/utils/request'
+
+const adminApi = {
+  siteNoticeApi: '/api/content/notice',
+  userListApi: '/api/user/list',
+  postListApi: '/api/content/post',
+  setHotVideoApi: '/api/content/video/config/hot',
+  dataSourceApi: '/api/spider/data/source'
+}
+
+// 获取站点公告
+export function getSiteNotice() {
+  return get(adminApi.siteNoticeApi)
+}
+
+// 更新站点公告
+export function updateSiteNotice(data) {
+  return post(adminApi.siteNoticeApi, data)
+}
+
+// 获取用户列表
+export function getUserList(page) {
+  return get(adminApi.userListApi, page)
+}
+
+// 获取稿件列表
+export function getPostList(page) {
+  return get(adminApi.postListApi, page)
+}
+
+// 获取源数据
+export function getDataSource(page) {
+  return get(adminApi.dataSourceApi, page)
+}
+
+// 删除
+export function deleteDataSource(data) {
+  return delete0(adminApi.dataSourceApi, data)
+}
+
+// 缓存源数据
+export function cacheDataSource(magnetId) {
+  return post(adminApi.dataSourceApi + '/' + magnetId)
+}
+
+export function setHotVideo() {
+  return post(adminApi.setHotVideoApi)
+}

+ 45 - 0
src/api/article.js

@@ -0,0 +1,45 @@
+import { delete0, get, post } from '@/utils/request'
+
+const articleApi = {
+  articlePostApi: '/api/content/post/article',
+  updateArticleScopeApi: '/api/content/post/article/update/scope',
+  updateArticleContentApi: '/api/content/post/article/update/content',
+  articleApi: '/api/content/article',
+  userArticleApi: '/api/content/article/user'
+}
+
+export function submitArticle(data) {
+  return post(articleApi.articlePostApi, data)
+}
+
+export function updateArticleScope(jsonData) {
+  return post(articleApi.updateArticleScopeApi, jsonData)
+}
+
+export function updateArticleContent(jsonData) {
+  return post(articleApi.updateArticleContentApi, jsonData)
+}
+
+export function deleteArticle(videoId) {
+  return delete0(articleApi.articleApi + '/' + videoId)
+}
+
+export function getArticlePosts(page) {
+  return get(articleApi.articlePostApi + '?page=' + page)
+}
+
+export function getArticlePost(articleId) {
+  return get(articleApi.articlePostApi + '/' + articleId)
+}
+
+export function getArticles(page) {
+  return get(articleApi.articleApi + '?page=' + page)
+}
+
+export function getUserArticles(userId, page) {
+  return get(articleApi.userArticleApi + '?userId=' + userId + '&page=' + page)
+}
+
+export function getArticle(articleId) {
+  return get(articleApi.articleApi + '/' + articleId)
+}

+ 40 - 0
src/api/audio.js

@@ -0,0 +1,40 @@
+import {delete0, get, post} from '@/utils/request'
+
+const audioApi = {
+  audioPostApi: '/api/content/post/audio',
+  updateAudioScopeApi: '/api/content/post/audio/update/scope',
+  audioApi: '/api/content/audio',
+  userAudioApi: '/api/content/audio/user',
+}
+
+export function addAudioPost(video) {
+  return post(audioApi.audioPostApi, video)
+}
+
+export function updateAudioScope(audioScope) {
+  return post(audioApi.updateAudioScopeApi, audioScope)
+}
+
+export function deleteAudioPost(audioId) {
+  return delete0(audioApi.audioPostApi + '/' + audioId)
+}
+
+export function getAudioPosts(page) {
+  return get(audioApi.audioPostApi + '?page=' + page)
+}
+
+export function getAudioPost(audioId) {
+  return get(audioApi.audioPostApi + '/' + audioId)
+}
+
+export function getAudios(page) {
+  return get(audioApi.audioApi + '?page=' + page)
+}
+
+export function getUserAudios(userId, page) {
+  return get(audioApi.userAudioApi + '?userId=' + userId + '&page=' + page)
+}
+
+export function getAudioInfo(audioId) {
+  return get(audioApi.audioApi + '/' + audioId)
+}

+ 61 - 0
src/api/cam.js

@@ -0,0 +1,61 @@
+import { delete0, get, post } from '@/utils/request'
+
+const camApi = {
+  camApi: '/api/content/cam',
+  updateCamNameApi: '/api/content/cam/update/name',
+  camListApi: '/api/content/cam/list/kv',
+  camRecordByMonthApi: '/api/content/cam/record/month',
+  camRecordByDayApi: '/api/content/cam/record/day',
+  camRecordApi: '/api/content/cam/record/id',
+  camLatestRecordApi: '/api/content/cam/record/latest',
+  camPushUrlApi: '/api/content/cam/push',
+  camPullUrlApi: '/api/content/cam/stream/rtmp/pull'
+}
+
+export function addCam(data) {
+  return post(camApi.camApi, data)
+}
+
+export function updateCamName(jsonData) {
+  return post(camApi.updateCamNameApi, jsonData)
+}
+
+export function deleteCam(camId) {
+  return delete0(camApi.camApi + '/' + camId)
+}
+
+export function getUserCams(page) {
+  return get(camApi.camApi + '?page=' + page)
+}
+
+export function getCamPushUrl(camId) {
+  return get(camApi.camPushUrlApi + '/' + camId)
+}
+
+export function getCamDetail(camId) {
+  return get(camApi.camApi + '/' + camId)
+}
+
+export function getCamList() {
+  return get(camApi.camListApi)
+}
+
+export function getCamRecordByMonth(param) {
+  return get(camApi.camRecordByMonthApi, param)
+}
+
+export function getCamRecordByDay(param) {
+  return get(camApi.camRecordByDayApi, param)
+}
+
+export function getCamPullUrl(camId) {
+  return get(camApi.camPullUrlApi + '/' + camId)
+}
+
+export function getCamRecord(recordId) {
+  return get(camApi.camRecordApi + '/' + recordId)
+}
+
+export function getLatestRecord(camId) {
+  return get(camApi.camLatestRecordApi + '/' + camId)
+}

+ 33 - 0
src/api/collect.js

@@ -0,0 +1,33 @@
+import { get, post, delete0 } from '@/utils/request'
+
+const collectApi = {
+  collectItemApi: '/api/content/favlist/collect',
+  userFavlistApi: '/api/content/favlist/user',
+  favlistApi: '/api/content/favlist',
+  videoFavlistApi: '/api/content/favlist/video'
+}
+
+// 收藏(取消收藏)内容
+export function collectItem(jsonData) {
+  return post(collectApi.collectItemApi, jsonData)
+}
+
+// 删除收藏夹
+export function deleteFavlist(favlistId) {
+  return delete0(collectApi.favlistApi + '/' + favlistId)
+}
+
+// 获取用户收藏夹
+export function getUserFavlist() {
+  return get(collectApi.userFavlistApi)
+}
+
+// 获取收藏夹内容
+export function getFavlist(favlistId, contentType, page) {
+  return get(collectApi.favlistApi + '?favlistId=' + favlistId + '&contentType=' + contentType + '&page=' + page)
+}
+
+// 获取视频收藏夹内容
+export function getVideoFavlist(favlistId, page) {
+  return get(collectApi.videoFavlistApi + '?favlistId=' + favlistId + '&page=' + page)
+}

+ 21 - 0
src/api/comment.js

@@ -0,0 +1,21 @@
+import { get, post } from '@/utils/request'
+
+const commentApi = {
+  videoCommentApi: '/api/comment/video',
+  videoChildCommentApi: '/api/comment/video/child'
+}
+
+// 发布评论
+export function publishComment(data) {
+  return post(commentApi.videoCommentApi, data)
+}
+
+// 获取评论
+export function getComment(videoId, pageNumber) {
+  return get(commentApi.videoCommentApi + '?videoId=' + videoId + '&pageNumber=' + pageNumber)
+}
+
+// 获取评论的子评论
+export function getChildComment(commentId, pageNumber) {
+  return get(commentApi.videoChildCommentApi + '?commentId=' + commentId + '&pageNumber=' + pageNumber)
+}

+ 22 - 0
src/api/content.js

@@ -0,0 +1,22 @@
+import { get, post } from '@/utils/request'
+
+const videoApi = {
+  userContentDataApi: '/api/content/userdata',
+  contentAccessCodeApi: '/api/content/userdata',
+  ossServerApi: '/api/content/oss/serverinfo'
+}
+
+/** *******************************************************************************************************************/
+// 获取用户内容统计
+export function getUserContentData(userId) {
+  return get(videoApi.userContentDataApi + '?userId=' + userId)
+}
+
+// 提交内容访问码
+export function submitAccessCode(jsonData) {
+  return post(videoApi.contentAccessCodeApi, jsonData)
+}
+
+export function getServerInfo(channelId) {
+  return post(videoApi.ossServerApi + '?channelId=' + channelId)
+}

+ 44 - 0
src/api/disk.js

@@ -0,0 +1,44 @@
+import { delete0, get, post } from '@/utils/request'
+
+const diskApi = {
+  getFolderTreeApi: '/api/account/disk/folder/tree',
+  createFolderApi: '/api/account/disk/folder/add',
+  addDiskFileApi: '/api/account/disk/file',
+  deleteDiskFileApi: '/api/account/disk/file',
+  moveDiskFileApi: '/api/account/disk/file/move',
+  getFileUrlApi: '/api/account/disk/file/url',
+  getFileDetailApi: '/api/account/disk/file/detail',
+  getFileListApi: '/api/account/disk/list'
+}
+
+export function getFolderTree() {
+  return get(diskApi.getFolderTreeApi)
+}
+
+export function addDiskFile(jsonData) {
+  return post(diskApi.addDiskFileApi, jsonData)
+}
+
+export function addDiskFolder(jsonData) {
+  return post(diskApi.createFolderApi, jsonData)
+}
+
+export function deleteDiskFile(fileIds) {
+  return delete0(diskApi.addDiskFileApi, fileIds)
+}
+
+export function moveDiskFile(jsonData) {
+  return post(diskApi.moveDiskFileApi, jsonData)
+}
+
+export function getFileList(form) {
+  return get(diskApi.getFileListApi, form)
+}
+
+export function getFileUrl(fileType, fileId) {
+  return get(diskApi.getFileUrlApi + '?fileType=' + fileType + '&fileId=' + fileId)
+}
+
+export function getFileDetail(fileId) {
+  return get(diskApi.getFileDetailApi + '?fileId=' + fileId)
+}

+ 60 - 0
src/api/image.js

@@ -0,0 +1,60 @@
+import { get, post, delete0 } from '@/utils/request'
+
+const imageApi = {
+  imageAlbumApi: '/api/content/post/image/album',
+  addAlbumImageApi: '/api/content/post/image/album/add',
+  deleteAlbumImageApi: '/api/content/post/image/album/delete',
+  updateAlbumScopeApi: '/api/content/post/image/album/update/scope',
+  updateAlbumCoverApi: '/api/content/post/image/album/update/cover',
+  albumImageApi: '/api/content/post/image/album',
+
+  albumApi: '/api/content/image/album',
+  userAlbumApi: '/api/content/image/album/user',
+  imageApi: '/api/content/image'
+}
+
+export function submitAlbum(jsonData) {
+  return post(imageApi.imageAlbumApi, jsonData)
+}
+
+export function addAlbumImage(jsonData) {
+  return post(imageApi.addAlbumImageApi, jsonData)
+}
+
+export function updateAlbumScope(jsonData) {
+  return post(imageApi.updateAlbumScopeApi, jsonData)
+}
+
+export function updateAlbumCover(jsonData) {
+  return post(imageApi.updateAlbumCoverApi, jsonData)
+}
+
+export function deleteAlbumImage(imageFileId) {
+  return delete0(imageApi.deleteAlbumImageApi + '/' + imageFileId)
+}
+
+export function deleteAlbum(albumId) {
+  return delete0(imageApi.imageAlbumApi + '/' + albumId)
+}
+
+export function getUserAlbums(page) {
+  return get(imageApi.imageAlbumApi + '?page=' + page)
+}
+
+export function getUserAlbums1(page, userId) {
+  return get(imageApi.userAlbumApi + '?page=' + page + '&userId=' + userId)
+}
+
+// 获取相册
+export function getAlbumImage(albumId) {
+  return get(imageApi.albumImageApi + '/' + albumId)
+}
+
+// 获取相册
+export function getAlbum(albumId) {
+  return get(imageApi.albumApi + '/' + albumId)
+}
+
+export function getImages(page) {
+  return get(imageApi.imageApi + '?page=' + page)
+}

+ 24 - 0
src/api/map.js

@@ -0,0 +1,24 @@
+import { get, post } from '@/utils/request'
+
+const mapAPI = {
+  center: '/api/content/map/center',
+  markers: '/api/content/map/markers',
+  markerInfoApi: '/api/content/map/marker',
+  location: '/api/content/map/location'
+}
+
+export function getMapCenter() {
+  return get(mapAPI.center)
+}
+
+export function getMarkerInfo(id) {
+  return get(mapAPI.markerInfoApi + '?id=' + id)
+}
+
+export function getMapMarkers(type) {
+  return get(mapAPI.markers + '?type=' + type)
+}
+
+export function sendClickedLocation(loc) {
+  return post(mapAPI.location, loc)
+}

+ 22 - 0
src/api/search.js

@@ -0,0 +1,22 @@
+import { get, post } from '@/utils/request'
+
+const searchApi = {
+  keywordSuggestApi: '/api/search/suggest',
+  videoSearchApi: '/api/search/query',
+  hotSearchApi: '/api/search/hot'
+}
+
+// 关键词建议
+export function keywordSuggest(keyword) {
+  return get(searchApi.keywordSuggestApi + '?keyword=' + keyword)
+}
+
+// 搜索关键词
+export function videoQuery(keyword, pageNumber) {
+  return get(searchApi.videoSearchApi + '?keyword=' + keyword + '&pageNumber=' + pageNumber)
+}
+
+// 热门搜索关键词列表
+export function hotKeyword() {
+  return get(searchApi.hotSearchApi)
+}

+ 22 - 0
src/api/status.js

@@ -0,0 +1,22 @@
+import { get, post } from '@/utils/request'
+
+const statusApi = {
+  statusPubApi: '/api/mblog/status',
+  statusApi: '/api/mblog/status',
+  userStatusApi: '/api/mblog/status/user'
+}
+
+// 状态发布接口
+export function pubStatus(statusPost) {
+  return post(statusApi.statusPubApi, statusPost)
+}
+
+// 状态获取接口
+export function getUserStatus(statusId) {
+  return get(statusApi.statusApi + '/' + statusId)
+}
+
+// 用户状态获取接口
+export function userStatus(userId, page) {
+  return get(statusApi.userStatusApi + '?userId=' + userId + '&page=' + page)
+}

+ 16 - 0
src/api/timeline.js

@@ -0,0 +1,16 @@
+import { get, post } from '@/utils/request'
+
+const timelineApi = {
+  videoTimelineApi: '/api/timeline/video',
+  statusTimelineApi: '/api/timeline/status'
+}
+
+// 用户视频时间线
+export function videoTimeline(nextId) {
+  return get(timelineApi.videoTimelineApi + '?nextId=' + nextId)
+}
+
+// 用户状态时间线
+export function statusTimeline(nextId) {
+  return get(timelineApi.statusTimelineApi + '?nextId=' + nextId)
+}

+ 41 - 0
src/api/user.js

@@ -0,0 +1,41 @@
+import { get, post } from '@/utils/request'
+
+const userApi = {
+  userInfoApi: '/api/user/info',
+  discoverUserApi: '/api/user/discover',
+  followUserApi: '/api/user/relation/follow',
+  unfollowUserApi: '/api/user/relation/unfollow',
+  checkRelationApi: '/api/user/relation/check',
+  userFollowerApi: '/api/user/relation/follower',
+  userFollowingApi: '/api/user/relation/following'
+}
+
+export function getUserInfo(userId) {
+  return get(userApi.userInfoApi + '?userId=' + userId)
+}
+
+export function getUsers() {
+  return get(userApi.discoverUserApi)
+}
+
+// 关注用户
+export function followUser(followingId) {
+  return post(userApi.followUserApi + '/' + followingId)
+}
+
+// 取消关注用户
+export function unfollowUser(followingId) {
+  return post(userApi.unfollowUserApi + '/' + followingId)
+}
+
+export function checkRelation(userId) {
+  return get(userApi.checkRelationApi + '/' + userId)
+}
+
+export function getUserFollower(userId) {
+  return get(userApi.userFollowerApi + '/' + userId)
+}
+
+export function getUserFollowing(userId) {
+  return get(userApi.userFollowingApi + '/' + userId)
+}

+ 168 - 0
src/api/video.js

@@ -0,0 +1,168 @@
+import { get, post, delete0 } from '@/utils/request'
+
+const videoApi = {
+  videoPostApi: '/api/content/post/video',
+  updateVideoScopeApi: '/api/content/post/video/update/scope',
+  updateVideoStatusApi: '/api/content/post/video/update/status',
+  updateVideoInfoApi: '/api/content/post/video/update/info',
+  updateVideoCoverApi: '/api/content/post/video/update/cover',
+  updateVideoFileApi: '/api/content/post/video/update/file',
+  videoResourceApi: '/api/content/post/video/resource',
+  convertVideoApi: '/api/content/post/video/convert',
+
+  videoRegionApi: '/api/content/video/region',
+  videoCategoryApi: '/api/content/video/categories',
+  categoryVideoApi: '/api/content/video/category',
+  categoryShortVideoApi: '/api/content/video/category/short',
+  userVideoPostApi: '/api/content/video/user',
+  tagVideoPostApi: '/api/content/video/tag',
+  videoInfoApi: '/api/content/video/detail',
+  videoUrlApi: '/api/content/video/url',
+
+  videoErrorReportApi: '/api/content/video/report',
+  videoDownloadApi: '/api/content/video/download',
+  cacheBiliApi: '/api/content/video/cache/bili',
+  shortUrlApi: '/api/content/share',
+
+  videoRecommendApi: '/api/content/video/recommend',
+  similarVideoApi: '/api/content/video/similar',
+  hotVideoApi: '/api/content/video/hot',
+  userContentDataApi: '/api/content/userdata'
+}
+
+// *********************************************************************************************************************
+// 添加视频贴
+export function addVideoPost(video) {
+  return post(videoApi.videoPostApi, video)
+}
+
+// 更新视频可见范围
+export function updateVideoScope(data) {
+  return post(videoApi.updateVideoScopeApi, data)
+}
+
+// 更新视频状态
+export function updateVideoStatus(data) {
+  return post(videoApi.updateVideoStatusApi, data)
+}
+
+// 更新视频信息
+export function updateVideoInfo(data) {
+  return post(videoApi.updateVideoInfoApi, data)
+}
+
+// 更新视频封面
+export function updateVideoCover(data) {
+  return post(videoApi.updateVideoCoverApi, data)
+}
+
+// 更新视频文件
+export function updateVideoFile(data) {
+  return post(videoApi.updateVideoFileApi, data)
+}
+
+// 删除视频贴
+export function deleteVideoPost(videoId) {
+  return delete0(videoApi.videoPostApi + '/' + videoId)
+}
+
+// 获取视频贴列表
+export function getVideoPosts(page) {
+  return get(videoApi.videoPostApi + '?page=' + page)
+}
+
+// 获取视频贴详情
+export function getVideoPost(videoId) {
+  return get(videoApi.videoPostApi + '/' + videoId)
+}
+
+// 获取视频资源
+export function getVideoResource(videoId) {
+  return get(videoApi.videoResourceApi + '/' + videoId)
+}
+
+// 视频转码
+export function convertVideo(videoId) {
+  return post(videoApi.convertVideoApi + '/' + videoId)
+}
+
+// *********************************************************************************************************************
+// 获取视频分类
+export function videoRegion() {
+  return get(videoApi.videoRegionApi)
+}
+
+export function videoCategories() {
+  return get(videoApi.videoCategoryApi)
+}
+
+// 获取分类视频
+export function categoryVideos(categoryId, page) {
+  return get(videoApi.categoryVideoApi + '?categoryId=' + categoryId + '&page=' + page)
+}
+
+export function categoryShortVideos(categoryId, page) {
+  return get(videoApi.categoryShortVideoApi + '?categoryId=' + categoryId + '&page=' + page)
+}
+
+// 获取用户视频
+export function getUserVideos(userId, page) {
+  return get(videoApi.userVideoPostApi + '?userId=' + userId + '&page=' + page)
+}
+
+// 获取相同标签的视频
+export function getTagVideos(tag, page) {
+  return get(videoApi.tagVideoPostApi + '?tag=' + tag + '&page=' + page)
+}
+
+// 获取视频详情
+export function videoInfo(videoId) {
+  return get(videoApi.videoInfoApi + '/' + videoId)
+}
+
+// 获取视频 URL
+export function videoUrl(videoId) {
+  return get(videoApi.videoUrlApi + '/' + videoId)
+}
+
+// *********************************************************************************************************************
+// 报告视频错误
+export function videoErrorReport(data) {
+  return post(videoApi.videoErrorReportApi, data)
+}
+
+// 下载视频
+export function downloadVideo(videoId) {
+  return get(videoApi.videoDownloadApi + '/' + videoId)
+}
+
+// 缓存 bili 视频
+export function cacheBiliVideo(bvId) {
+  return post(videoApi.cacheBiliApi + '/' + bvId)
+}
+
+// 获取分享视频的短链接
+export function getShortUrl(bvId) {
+  return get(videoApi.shortUrlApi + '?videoId=' + bvId)
+}
+
+// ********************************************************************************************************************
+// 获取推荐视频
+export function videoRecommend(nextId) {
+  return get(videoApi.videoRecommendApi + '?nextId=' + nextId)
+}
+
+// 获取相似视频
+export function similarVideo(videoId) {
+  return get(videoApi.similarVideoApi + '?videoId=' + videoId)
+}
+
+// 获取热门视频
+export function getHotVideo() {
+  return get(videoApi.hotVideoApi)
+}
+
+// 获取用户内容统计
+export function getUserContentData(userId) {
+  return get(videoApi.userContentDataApi + '?userId=' + userId)
+}

+ 20 - 0
src/api/visit.js

@@ -0,0 +1,20 @@
+import { get, delete0 } from '@/utils/request'
+
+const visitApi = {
+  visitRecordApi: '/api/content/video/visit'
+}
+
+// 获取用户的视频观看记录
+export function getVisitRecord(nextId) {
+  return get(visitApi.visitRecordApi + '?nextId=' + nextId)
+}
+
+// 删除用户的视频观看记录
+export function deleteVisitRecord(nextId) {
+  return delete0(visitApi.visitRecordApi + '?nextId=' + nextId)
+}
+
+// 清空用户的视频观看记录
+export function eraseVisitRecord(nextId) {
+  return delete0(visitApi.visitRecordApi + '?nextId=' + nextId)
+}

+ 20 - 0
src/assets/css/base.css

@@ -0,0 +1,20 @@
+body {
+    width: 100vw;
+    overflow: hidden;
+    padding: 0px;
+    margin: 0px;
+}
+
+/*解决滚动条消失*/
+html {
+    overflow-y: scroll;
+}
+
+:root {
+    overflow-y: auto;
+    overflow-x: hidden;
+}
+
+:root body {
+    position: absolute;
+}

+ 148 - 0
src/assets/css/global.scss

@@ -0,0 +1,148 @@
+@import './reset.css';
+
+.no-select {
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+.no-padding {
+  padding: 0;
+}
+.no-border {
+  border: 0;
+}
+.pointer {
+  cursor: pointer;
+}
+.border-radius0 {
+  border-radius: 0;
+}
+.full-height {
+  height: 100%;
+}
+.ov-hidden {
+  overflow: hidden;
+}
+.lum-text-ellipsis {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.lum-scrollbar {
+  &::-webkit-scrollbar {
+    width: 3px;
+    background-color: #e4e4e5;
+  }
+  &::-webkit-scrollbar-thumb {
+    border-radius: 3px;
+    background-color: #c0bebc;
+  }
+}
+.larkc-tag {
+  font-size: 12px;
+  font-weight: 400;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 6px;
+  height: 20px;
+  border-radius: 2px;
+  cursor: default;
+  user-select: none;
+  background-color: #dee0e3;
+  transform: scale(0.83);
+  transform-origin: left;
+  flex-shrink: 0;
+}
+.flex-center {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.lum-dialog-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 999;
+  width: 100%;
+  height: 100%;
+  background-color: white;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  .lum-dialog-box {
+    min-width: 200px;
+    min-height: 200px;
+    background-color: white;
+    border-radius: 10px;
+    overflow: hidden;
+    box-shadow: 0 2px 8px 0 rgba(31,35,41,0.2);
+    margin: 0 10px;
+    .container {
+      height: 100%;
+    }
+    .header {
+      padding: 0;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      border-bottom: 1px solid #f5eeee;
+      & > p {
+        &:first-child {
+          text-indent: 20px;
+        }
+      }
+      .tools {
+        height: 100%;
+        width: 100px;
+        display: flex;
+        align-items: center;
+        justify-content: flex-end;
+        padding-right: 20px;
+        i {
+          font-size: 20px;
+          cursor: pointer;
+          margin-left: 8px;
+        }
+      }
+    }
+    .main {
+      #deep.el-input__inner {
+        border-radius: 1px !important;
+      }
+      .submit-btn {
+        border-radius: 2px;
+        font-weight: 400;
+      }
+    }
+  }
+}
+.talk-notify {
+  .el-notification__title {
+    font-weight: 300;
+    font-size: 16px;
+    color: #f44336;
+  }
+  p {
+    max-height: 65px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    text-indent: -7px;
+    word-break: break-all;
+  }
+}
+.im-notify {
+  padding: 14px 26px 8px 0px;
+  .el-notification__closeBtn {
+    position: absolute;
+    top: 12px;
+  }
+  .el-notification__group {
+    margin-left: 5px;
+  }
+}

+ 155 - 0
src/assets/css/login-auth.scss

@@ -0,0 +1,155 @@
+#deep.el-input__inner {
+  border-radius: 1px !important;
+}
+
+#auth-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 100%;
+  background-color: #f6f8fb;
+  #logo-name {
+    width: 200px;
+    height: 38px;
+    font-size: 34px;
+    font-family: Times New Roman, Georgia, Serif;
+    color: #2196f3;
+    margin-left: 20px;
+    margin-top: 20px;
+  }
+  #login-box {
+    position: absolute;
+    width: 350px;
+    min-height: 480px;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%,-50%);
+    background-color: white;
+    border-radius: 5px;
+    box-shadow: 0 0 0 #ccc;
+    box-shadow: 0 4px 14px 0 rgba(206,207,209,0.5);
+    padding: 10px 20px;
+    .header {
+      width: 100%;
+      height: 38px;
+      font-size: 22px;
+      margin: 25px 0 20px 0;
+    }
+    .main {
+      width: 100%;
+      .links {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        a {
+          font-weight: normal !important;
+        }
+      }
+      .send-code-btn {
+        width: 140px;
+        height: 40px;
+        line-height: 40px;
+        display: inline-block;
+        background: #f3ecec;
+        text-align: center;
+        color: #777373;
+        cursor: pointer;
+        user-select: none;
+        margin-left: 5px;
+        &:active {
+          background: #e4dbdb;
+        }
+      }
+      .send-sms-disable {
+        cursor: not-allowed !important;
+        background: #f7f7f7 !important;
+        color: silver !important;
+      }
+      .submit-btn {
+        width: 100%;
+        border-radius: 2px;
+      }
+    }
+  }
+}
+.preview-account {
+  text-align: center;
+  p {
+    height: 25px;
+    line-height: 25px;
+    color: #2d2c2c;
+    font-weight: 100;
+    font-size: 12px;
+  }
+}
+.copyright {
+  position: absolute;
+  bottom: 30px;
+  left: 0;
+  right: 0;
+  width: 70%;
+  text-align: center;
+  margin: 0 auto;
+  font-size: 12px;
+  color: #b1a0a0;
+  a {
+    color: #777272;
+    font-weight: 400;
+  }
+}
+@media screen and (max-height : 500px) {
+  .copyright {
+    display: none;
+  }
+}
+
+.fly-box {
+  .bg-fly-circle1 {
+    left: 40px;
+    top: 100px;
+    width: 100px;
+    height: 100px;
+    border-radius: 50%;
+    background: linear-gradient(to right,rgba(100,84,239,0.07) 0%,rgba(48,33,236,0.04) 100%);
+    animation: move 2.5s linear infinite;
+  }
+  .bg-fly-circle2 {
+    left: 3%;
+    top: 60%;
+    width: 150px;
+    height: 150px;
+    border-radius: 50%;
+    background: linear-gradient(to right,rgba(100,84,239,0.08) 0%,rgba(48,33,236,0.04) 100%);
+    animation: move 3s linear infinite;
+  }
+  .bg-fly-circle3 {
+    right: 2%;
+    top: 140px;
+    width: 145px;
+    height: 145px;
+    border-radius: 50%;
+    background: linear-gradient(to right,rgba(100,84,239,0.1) 0%,rgba(48,33,236,0.04) 100%);
+    animation: move 2.5s linear infinite;
+  }
+  .bg-fly-circle4 {
+    right: 5%;
+    top: 60%;
+    width: 160px;
+    height: 160px;
+    border-radius: 50%;
+    background: linear-gradient(to right,rgba(100,84,239,0.02) 0%,rgba(48,33,236,0.04) 100%);
+    animation: move 3.5s linear infinite;
+  }
+}
+@keyframes move {
+  0% {
+    transform: translateY(0px);
+  }
+  50% {
+    transform: translateY(25px);
+  }
+  100% {
+    transform: translateY(0px);
+  }
+}

+ 62 - 0
src/assets/css/reset.css

@@ -0,0 +1,62 @@
+* {
+    margin: 0;
+    padding: 0;
+}
+
+body,
+html {
+    height: 100%;
+    min-width: 500px;
+    font-family: "Microsoft YaHei";
+    font-size: 16px;
+    color: #333;
+}
+
+button,
+input,
+select,
+textarea {
+    font-size: 100%;
+    margin: 0;
+    padding: 0;
+    border: none;
+    outline: none;
+}
+
+img {
+    border: 0;
+}
+
+a,
+img {
+    -webkit-touch-callout: none
+}
+
+a {
+    text-decoration: none;
+}
+
+textarea {
+    resize: none;
+    outline: 0;
+    white-space: pre-wrap;
+    word-wrap: break-word;
+    border: none;
+    background: #fff;
+    font-family: "Microsoft YaHei";
+}
+
+:focus {
+    outline: none;
+}
+
+.clearfix {
+    clear: both;
+    content: "";
+    display: block;
+    overflow: hidden
+}
+
+.clear {
+    clear: both;
+}

+ 15 - 0
src/assets/css/variable.scss

@@ -0,0 +1,15 @@
+:root {
+  --themeBagColor: red;
+}
+.theme-default {
+  --themeBagColor: #fff;
+}
+.theme-dark {
+  --themeBagColor: black;
+}
+.theme-red {
+  --themeBagColor: red;
+}
+.theme-blue {
+  --themeBagColor: blue;
+}

File diff suppressed because it is too large
+ 5 - 0
src/assets/icon/iconfont.css


BIN
src/assets/icon/iconfont.eot


File diff suppressed because it is too large
+ 29 - 0
src/assets/icon/iconfont.svg


BIN
src/assets/icon/iconfont.ttf


BIN
src/assets/icon/iconfont.woff


BIN
src/assets/img/icon/avatar.png


BIN
src/assets/img/icon/backtop.png


BIN
src/assets/img/icon/erweima.png


BIN
src/assets/img/icon/exit.png


BIN
src/assets/img/icon/github.png


BIN
src/assets/img/icon/history.png


BIN
src/assets/img/icon/like.png


BIN
src/assets/img/icon/logo.png


BIN
src/assets/img/icon/mylike.png


BIN
src/assets/img/icon/not-collection.png


BIN
src/assets/img/icon/not-history.png


BIN
src/assets/img/icon/not-result.png


BIN
src/assets/img/icon/play-icon.png


BIN
src/assets/img/icon/profile.png


BIN
src/assets/img/icon/recommmand-icon.png


BIN
src/assets/img/icon/search-result.png


BIN
src/assets/img/icon/speaker.png


BIN
src/assets/img/icon/weixin.png


BIN
src/assets/img/logo.png


BIN
src/assets/img/moviecover/py.jpg


+ 4 - 0
src/assets/js/const.js

@@ -0,0 +1,4 @@
+// 常量
+
+// 服务器地址
+export const URL = 'http://localhost:8080'

+ 200 - 0
src/assets/js/mixin.js

@@ -0,0 +1,200 @@
+/**
+ * 混入对象,抽取Vue中公共的部分
+ */
+import { getPubkey, getCaptchaCode, getVerifyCode, login, logout } from '@/api/account'
+import { setUserToken, removeAll } from '@/utils/auth'
+import { JSEncrypt } from 'jsencrypt'
+import Vue from 'vue'
+
+export const userMixin = {
+  data() {
+    return {
+      pubkey: '',
+      pubkeyR: '',
+      captchaCode: '',
+      userLogin: {
+        principal: null,
+        credential: null,
+        captchaCode: null,
+        loginType: 2,
+        plat: 2
+      },
+      loginDialog: false,
+      registerDialog: false,
+      dialogVisible: false,
+      dialogVisible2: false,
+      nickname: '', // 用户昵称
+      phone: '', // 电话
+      mobile: '', // 电话
+      username: '', // 电话
+      password: '', // 用户密码
+      repassword: '', // 确认密码
+      avatarurl: '', // 用户头像路径
+      rcode: '', // 注册验证码
+      isLoading: false, // 登录加载效果
+      user: this.$user, // 登录用户对象
+      code: '获取验证码',
+      isBtn: false,
+      imageUrl: require('assets/img/icon/avatar.png')
+    }
+  },
+  methods: {
+    fetchPubkey() {
+      getPubkey().then(resp => {
+        if (resp.code === 0) {
+          this.pubkey = resp.data.pubkey
+          this.pubkeyR = resp.data.r
+
+          this.getCaptcha()
+        } else {
+          this.message = resp.msg
+          this.showMessage = true
+        }
+      }).catch(error => {
+        this.message = error.message
+        this.showMessage = true
+      })
+    },
+    getCaptcha() {
+      getCaptchaCode().then(resp => {
+        if (resp.code === 0) {
+          this.captchaCode = resp.data
+          this.dialogVisible = true
+        } else {
+          this.message = '获取图形验证码失败, 请重新刷新页面'
+          this.showMessage = true
+        }
+      })
+    },
+    encryptPassword(password, pubkey, pubkeyR) {
+      var encryptor = new JSEncrypt()
+      encryptor.setPublicKey(pubkey)
+      return encryptor.encrypt(pubkeyR + password)
+    },
+    fetchVerifyCode() {
+      if (this.userLogin.principal === null || this.userLogin.principal === '') {
+        this.$message.success('请填写手机号')
+        return
+      }
+
+      this.isBtn = true
+      let time = 60
+      const timeout = setInterval(() => {
+        if (time !== 0) {
+          time--
+          this.code = time + 's后重新获取'
+        } else {
+          this.isBtn = false
+          this.code = '重新获取验证码'
+          clearTimeout(timeout)
+        }
+      }, 1000)
+
+      const verifyCodeReq = {}
+      verifyCodeReq.receiver = this.userLogin.principal
+      verifyCodeReq.notifyType = 2
+      getVerifyCode(verifyCodeReq).then(resp => {
+        if (resp.code === 0) {
+          this.$message.success('验证码已发送, 请注意查收')
+        } else {
+          this.$message.warning(resp.msg)
+        }
+      }).catch(error => {
+        this.$message.error(error)
+      })
+    },
+    // 昵称校验
+    nickNameBlur() {
+      if (this.nickname.length <= 0) {
+        this.$message.warning('昵称不能为空')
+      }
+    },
+    // 电话校验
+    phoneBlur() {
+      if (!(/^1[3456789]\d{9}$/.test(this.mobile))) {
+        this.$message.warning('电话号码格式有误')
+      }
+    },
+    loginBtn() {
+      // 显示加载效果
+      this.isLoading = true
+      if (this.userLogin.principal === '') {
+        this.$message.warning('手机号不能为空')
+        return
+      }
+      if (this.userLogin.credential === '' || this.userLogin.credential === null) {
+        this.$message.warning('短信验证码不能为空')
+        return
+      }
+      if (this.userLogin.captchaCode === '') {
+        this.$message.warning('图形验证码不能为空')
+        return
+      }
+
+      this.userLogin.credential = this.encryptPassword(this.userLogin.credential, this.pubkey, this.pubkeyR)
+      login(this.userLogin).then(resp => {
+        if (resp.code === 0) {
+          const respData = resp.data
+          const userInfo = respData.accountInfo
+          const userToken = respData.accountToken
+          // 保存授权信息到本地缓存
+          setUserToken(userToken)
+          this.$store.commit('UPDATE_USER_INFO', userInfo)
+
+          // 关闭弹窗并刷新页面
+          this.dialogVisible = false
+          this.$router.go(0)
+        } else {
+          // 登录失败
+          this.$message.warning(resp.msg)
+        }
+      }).catch(error => {
+        // 登录请求错误
+        this.$message.error(error)
+      }).finally(() => {
+        this.userLogin = {
+          principal: null,
+          credential: null,
+          captchaCode: null,
+          loginType: 2,
+          plat: 1
+        }
+      })
+    },
+    goToLogout() {
+      this.$confirm('退出登录, 是否继续?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        logout().then(resp => {
+          if (resp.code === 0) {
+            Vue.$cookies.remove('token')
+            this.$store.commit('USER_LOGOUT')
+            removeAll()
+          } else {
+            this.$notify.error({
+              title: '提示',
+              message: resp.msg
+            })
+          }
+        }).catch((e) => {
+          console.log(e)
+          this.$notify({
+            message: '网络错误,请稍后再试...'
+          })
+        }).finally(() => {
+          // 刷新当前页面
+          this.$router.go(0)
+          // this.$router.push('/')
+          this.isLoading = false
+        })
+      }).catch(() => {
+        this.$message({
+          type: 'info',
+          message: '已取消'
+        })
+      })
+    }
+  }
+}

+ 96 - 0
src/assets/js/utils.js

@@ -0,0 +1,96 @@
+// 工具类
+
+// 随机获取数组中的num个元素并返回新数组
+export function getArrayItems(arr, num) {
+  const newArr = []
+  for (let i = 0; i <= num - 1; i++) {
+    // 生成数组长度范围内的随机数
+    const index = Math.floor(Math.random() * arr.length)
+    newArr[i] = arr.splice(index, 1)[0]
+  }
+  return newArr
+}
+
+// 23000 =》 2.3万
+export function handleVisited(visited) {
+  // console.log(visited);
+  if (visited >= 10000) {
+    var s = Math.floor(visited / 10000)
+    var s2 = Math.floor(visited % 10000 / 1000)
+    visited = s + '.' + s2 + '万'
+  }
+  return visited
+}
+
+// 时间的转换
+export function getDate(pretime) {
+  const minute = 1000 * 60
+  const hour = minute * 60
+  const day = hour * 24
+  const month = day * 30
+  // 将时间转化为时间戳
+  pretime = pretime.replace(/\-/g, '/')
+  const time = new Date(pretime).getTime()
+  // 获取当前时间戳
+  const now = new Date().getTime()
+  const subTime = now - time
+  const monthC = subTime / month
+  const weekC = subTime / (7 * day)
+  const dayC = subTime / day
+  const hourC = subTime / hour
+  const minC = subTime / minute
+
+  if (monthC >= 1) {
+    return parseInt(monthC) + '个月前'
+  } else if (weekC >= 1) {
+    return parseInt(weekC) + '周前'
+  } else if (dayC >= 1) {
+    return parseInt(dayC) + '天前'
+  } else if (hourC >= 1) {
+    return parseInt(hourC) + '小时前'
+  } else if (minC >= 1) {
+    return parseInt(minC) + '分钟前'
+  } else {
+    return '刚刚'
+  }
+}
+
+// 获取唯一的id
+export function getUUid() {
+  var s = []
+  var hexDigits = '0123456789abcdef'
+  for (var i = 0; i < 36; i++) {
+    s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
+  }
+  s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010
+  s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) // bits 6-7 of the clock_seq_hi_and_reserved to 01
+  s[8] = s[13] = s[18] = s[23] = '-'
+
+  var uuid = s.join('')
+  return uuid
+}
+
+// 时间格式化
+export function formatDate(date, fmt) {
+  if (/(y+)/.test(fmt)) {
+    fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length))
+  }
+  const o = {
+    'M+': date.getMonth() + 1,
+    'd+': date.getDate(),
+    'h+': date.getHours(),
+    'm+': date.getMinutes(),
+    's+': date.getSeconds()
+  }
+  for (const k in o) {
+    if (new RegExp(`(${k})`).test(fmt)) {
+      const str = o[k] + ''
+      fmt = fmt.replace(RegExp.$1, (RegExp.$1.length === 1) ? str : padLeftZero(str))
+    }
+  }
+  return fmt
+}
+
+function padLeftZero(str) {
+  return ('00' + str).substr(str.length)
+}

+ 76 - 0
src/components/LivePlayer.vue

@@ -0,0 +1,76 @@
+<template>
+  <div id="dplayer" ref="dplayer" style="height: 480px;" />
+</template>
+
+<script>
+import flvjs from 'flv.js'
+import DPlayer from 'dplayer'
+import { getCamPullUrl } from '@/api/cam'
+
+export default {
+  name: 'LivePlayer',
+  props: {
+    videoProp: {
+      type: Object,
+      default: () => null
+    }
+  },
+  data() {
+    return {
+      flvjs,
+      DPlayer,
+      getUrl: true
+    }
+  },
+  created() {
+  },
+  mounted() {
+    const camId = this.videoProp.videoId
+    this.getPullUrl(camId)
+  },
+  methods: {
+    getPullUrl(camId) {
+      getCamPullUrl(camId).then(resp => {
+        if (resp.code === 0) {
+          this.initFlvPlayer(resp.data)
+        } else {
+          this.$notify.error({
+            message: '获取摄像头拉流地址失败',
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify.error({
+          message: error.message,
+          type: 'warning',
+          duration: 3000
+        })
+      })
+    },
+    initFlvPlayer(videoUrl) {
+      new DPlayer({
+        container: document.getElementById('dplayer'),
+        live: true,
+        video: {
+          url: videoUrl,
+          type: 'customFlv',
+          customType: {
+            customFlv: function(video, player) {
+              const flvPlayer = flvjs.createPlayer({
+                type: 'flv',
+                url: video.src
+              })
+              flvPlayer.attachMediaElement(video)
+              flvPlayer.load()
+            }
+          }
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 139 - 0
src/components/RichText.vue

@@ -0,0 +1,139 @@
+<template>
+  <quill-editor
+    ref="myTextEditor"
+    v-model="text"
+    class="editor"
+    :options="editorOption"
+    @change="onEditorChange($event)"
+  />
+</template>
+
+<script>
+const toolbar = [
+  ['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
+  ['blockquote', 'code-block'], // 引用  代码块
+  [{ header: 1 }, { header: 2 }], // 1、2 级标题
+  [{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
+  //   [{ script: "sub" }, { script: "super" }], // 上标/下标
+  //   [{ indent: "-1" }, { indent: "+1" }], // 缩进
+  // [{'direction': 'rtl'}],                         // 文本方向
+  [{ size: ['small', false, 'large', 'huge'] }], // 字体大小
+  [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
+  [{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
+  [{ font: [] }], // 字体种类
+  [{ align: [] }], // 对齐方式
+  ['clean'], // 清除文本格式
+  ['link', 'image', 'video'] // 链接、图片、视频
+] // 工具菜单栏配置
+
+export default {
+  name: 'RichText',
+  props: {
+    text: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    return {
+      content: '', // 内容
+      editorOption: {
+        modules: {
+          toolbar
+        },
+        placeholder: '', // 提示
+        readyOnly: false, // 是否只读
+        theme: 'snow', // 主题 snow/bubble
+        syntax: true // 语法检测
+      }
+    }
+  },
+  methods: {
+    // 值发生变化
+    onEditorChange(editor) {
+      this.content = editor.html
+      this.$emit('content', editor.html)
+    }
+  }
+}
+</script>
+
+<style>
+.editor {
+  line-height: normal !important;
+  height: 480px;
+  margin-bottom: 30px;
+}
+.ql-snow .ql-tooltip[data-mode="link"]::before {
+  content: "请输入链接地址:";
+}
+.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
+  border-right: 0px;
+  content: "保存";
+  padding-right: 0px;
+}
+
+.ql-snow .ql-tooltip[data-mode="video"]::before {
+  content: "请输入视频地址:";
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item::before {
+  content: "14px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
+  content: "10px";
+}
+
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
+  content: "18px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
+  content: "32px";
+}
+
+.ql-snow .ql-picker.ql-header .ql-picker-label::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item::before {
+  content: "文本";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
+  content: "标题1";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
+  content: "标题2";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
+  content: "标题3";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
+  content: "标题4";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
+  content: "标题5";
+}
+.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
+.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
+  content: "标题6";
+}
+
+.ql-snow .ql-picker.ql-font .ql-picker-label::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item::before {
+  content: "标准字体";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="serif"]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before {
+  content: "衬线字体";
+}
+.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="monospace"]::before,
+.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before {
+  content: "等宽字体";
+}
+</style>

+ 257 - 0
src/components/StampBadge.vue

@@ -0,0 +1,257 @@
+<template>
+  <div
+    class="first-ring"
+    v-bind="getBindValue"
+    :class="getStampBadgeClass"
+    :style="{ transform: `rotate(${rotate}deg)` }"
+  >
+    <div class="second-ring" :class="getStampBadgeClass">
+      <div class="third-ring" :class="getStampBadgeClass">
+        <div class="forth-ring" :class="getStampBadgeClass">
+          <div class="content-rectangle ellipsis" :class="getStampBadgeClass">
+            <span class="">{{ content }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'StampBadge',
+  // inheritAttrs: false,
+  props: {
+    color: {
+      type: String,
+      default: 'primary',
+      validator: (v) =>
+        ['primary', 'error', 'warning', 'success', 'info'].includes(v)
+    },
+    /**
+     * stamp badge size.
+     * @default: middle
+     */
+    size: {
+      type: String,
+      default: 'middle',
+      validator: (v) => ['large', 'middle', 'small'].includes(v)
+    },
+    /**
+     * stamp badge rotate deg.
+     * @default: 0
+     */
+    rotate: { type: Number, default: 0 },
+    content: { type: String, default: 'Unknown' }
+  },
+  computed: {
+    getStampBadgeClass() {
+      const { color, size } = this.$props
+      return [
+        {
+          [`stamp-badge-${color}`]: !!color,
+          [`stamp-badge-${size}`]: !!size
+        }
+      ]
+    },
+    getBindValue() {
+      return { ...this.$attrs, ...this.$props }
+    }
+  },
+  methods: {}
+}
+</script>
+
+<style lang="scss" scoped>
+.first-ring {
+  border-radius: 100px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.second-ring {
+  background: #fff;
+  border-radius: 100px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.third-ring {
+  border-radius: 100px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+.forth-ring {
+  background: #fff;
+  border-radius: 100px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  position: relative;
+}
+
+.content-rectangle {
+  background: #fff;
+  font-weight: bold;
+  text-align: center;
+  position: absolute;
+}
+
+.ellipsis {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+// primary
+.stamp-badge-primary.first-ring {
+  background: #1890ff;
+}
+
+.stamp-badge-primary.third-ring {
+  background: #1890ff;
+}
+
+.stamp-badge-primary.content-rectangle {
+  border: 1px solid #1890ff;
+  color: #1890ff;
+}
+
+// success
+.stamp-badge-success.first-ring {
+  background: #52c41a;
+}
+
+.stamp-badge-success.third-ring {
+  background: #52c41a;
+}
+
+.stamp-badge-success.content-rectangle {
+  border: 1px solid #52c41a;
+  color: #52c41a;
+}
+
+// error
+.stamp-badge-error.first-ring {
+  background: #ff4d4f;
+}
+
+.stamp-badge-error.third-ring {
+  background: #ff4d4f;
+}
+
+.stamp-badge-error.content-rectangle {
+  border: 1px solid #ff4d4f;
+  color: #ff4d4f;
+}
+
+// warning
+.stamp-badge-warning.first-ring {
+  background: #faad14;
+}
+
+.stamp-badge-warning.third-ring {
+  background: #faad14;
+}
+
+.stamp-badge-warning.content-rectangle {
+  border: 1px solid #faad14;
+  color: #faad14;
+}
+
+// info
+.stamp-badge-info.first-ring {
+  background: #ccc;
+}
+
+.stamp-badge-info.third-ring {
+  background: #ccc;
+}
+
+.stamp-badge-info.content-rectangle {
+  border: 1px solid #ccc;
+  color: #ccc;
+}
+
+// large
+.stamp-badge-large.first-ring {
+  width: 84px;
+  height: 84px;
+}
+
+.stamp-badge-large.second-ring {
+  width: 80px;
+  height: 80px;
+}
+
+.stamp-badge-large.third-ring {
+  width: 74px;
+  height: 74px;
+}
+
+.stamp-badge-large.forth-ring {
+  width: 64px;
+  height: 64px;
+}
+
+.stamp-badge-large.content-rectangle {
+  width: 90px;
+  font-size: 1.2rem;
+}
+
+// middle
+.stamp-badge-middle.first-ring {
+  width: 64px;
+  height: 64px;
+}
+
+.stamp-badge-middle.second-ring {
+  width: 60px;
+  height: 60px;
+}
+
+.stamp-badge-middle.third-ring {
+  width: 56px;
+  height: 56px;
+}
+
+.stamp-badge-middle.forth-ring {
+  width: 48px;
+  height: 48px;
+}
+
+.stamp-badge-middle.content-rectangle {
+  width: 70px;
+  font-size: 1rem;
+}
+
+// small
+.stamp-badge-small.first-ring {
+  width: 54px;
+  height: 54px;
+}
+
+.stamp-badge-small.second-ring {
+  width: 50px;
+  height: 50px;
+}
+
+.stamp-badge-small.third-ring {
+  width: 46px;
+  height: 46px;
+}
+
+.stamp-badge-small.forth-ring {
+  width: 38px;
+  height: 38px;
+}
+
+.stamp-badge-small.content-rectangle {
+  width: 60px;
+  font-size: 0.8rem;
+}
+</style>

+ 219 - 0
src/components/VideoPlayer.vue

@@ -0,0 +1,219 @@
+<template>
+  <div id="dplayer" ref="dplayer" style="height: 480px;" />
+</template>
+
+<script>
+import { videoUrl } from '@/api/video'
+import SocketInstance from '@/utils/ws/socket-instance'
+
+import flvjs from 'flv.js'
+import DPlayer from 'dplayer'
+import { getAccessToken } from '@/utils/auth'
+
+export default {
+  name: 'VideoPlayer',
+  props: {
+    videoProp: {
+      type: Object,
+      default: () => null
+    }
+  },
+  data() {
+    return {
+      flvjs,
+      DPlayer,
+      danmaku: {
+        api: process.env.VUE_APP_SERVER_URL + '/api/comment/danmaku/',
+        token: 'tnbapp'
+      },
+      getUrl: true
+    }
+  },
+  created() {
+  },
+  mounted() {
+    const videoId = this.videoProp.videoId
+    if (this.getUrl) {
+      this.getVideoUrl(videoId)
+    }
+  },
+  methods: {
+    getVideoUrl(videoId) {
+      videoUrl(videoId).then(res => {
+        if (res.code === 0) {
+          var event = false
+          const token = getAccessToken()
+          if (token != null) {
+            SocketInstance.connect()
+            event = true
+          }
+
+          const urlType = res.data.type
+          if (urlType === 'mp4') {
+            const urls = res.data.urls
+            for (const url of urls) {
+              url.type = 'normal'
+            }
+            this.initMp4Player(this.videoProp.userId, videoId, this.videoProp.coverUrl, urls, res.data.currentTime, event)
+          } else if (urlType === 'flv') {
+            const urls = res.data.urls
+            this.initFlvPlayer(this.videoProp.userId, videoId, this.videoProp.coverUrl, urls, res.data.currentTime, event)
+          } else {
+            this.$notify.error({
+              message: '视频 url 类型不合法',
+              type: 'warning',
+              duration: 3000
+            })
+          }
+        } else {
+          this.$notify.error({
+            message: '视频 url 获取失败',
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify.error({
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    danmakuConfig() {
+      // TODO 获取弹幕配置,将 videoUrl 作为本函数的回调
+    },
+    initMp4Player(userId, videoId, coverUrl, urls, pos, event) {
+      const player = new DPlayer({
+        container: document.querySelector('#dplayer'),
+        lang: 'zh-cn',
+        logo: '/logo.png',
+        screenshot: true,
+        autoplay: true,
+        volume: 0.1,
+        mutex: true,
+        video: {
+          pic: coverUrl,
+          defaultQuality: 0,
+          quality: urls,
+          hotkey: true
+        },
+        danmaku: {
+          id: videoId,
+          maximum: 10000,
+          api: this.danmaku.api,
+          token: this.danmaku.token,
+          user: userId,
+          bottom: '15%',
+          unlimited: true
+        }
+      })
+
+      // 设置音量
+      // player.volume(0.1, true, false)
+      // 跳转到上次看到的位置
+      player.seek(pos)
+
+      /* 事件绑定 */
+      player.on('progress', function() {
+        if (event) {
+          const payload = {}
+          payload.videoId = videoId
+          payload.currentTime = player.video.currentTime
+          payload.ended = false
+
+          const jsonData = {}
+          jsonData.event = 'progress'
+          jsonData.payload = JSON.stringify(payload)
+          SocketInstance.send(jsonData)
+        }
+      })
+
+      player.on('ended', () => {
+        if (event) {
+          const payload = {}
+          payload.videoId = videoId
+          payload.currentTime = player.video.currentTime
+          payload.ended = true
+
+          const jsonData = {}
+          jsonData.event = 'progress'
+          jsonData.payload = JSON.stringify(payload)
+          SocketInstance.send(jsonData)
+        }
+      })
+
+      player.on('volumechange', () => {
+        console.log('声音改变')
+      })
+    },
+    initFlvPlayer(userId, videoId, coverUrl, urls, pos, event) {
+      const player = new DPlayer({
+        container: document.getElementById('dplayer'),
+        lang: 'zh-cn',
+        logo: '/logo.png',
+        screenshot: true,
+        autoplay: true,
+        volume: 0.1,
+        mutex: true,
+        video: {
+          pic: coverUrl,
+          defaultQuality: 0,
+          quality: urls,
+          hotkey: true,
+          type: 'customFlv',
+          customType: {
+            customFlv: function(video, player) {
+              const flvPlayer = flvjs.createPlayer({
+                type: 'flv',
+                url: video.src
+              })
+              flvPlayer.attachMediaElement(video)
+              flvPlayer.load()
+            }
+          }
+        }
+      })
+
+      // 跳转到上次看到的位置
+      player.seek(pos)
+
+      /* 事件绑定 */
+      player.on('progress', function() {
+        if (event) {
+          const payload = {}
+          payload.videoId = videoId
+          payload.currentTime = player.video.currentTime
+          payload.ended = false
+
+          const jsonData = {}
+          jsonData.event = 'progress'
+          jsonData.payload = JSON.stringify(payload)
+          SocketInstance.send(jsonData)
+        }
+      })
+
+      player.on('ended', () => {
+        if (event) {
+          const payload = {}
+          payload.videoId = videoId
+          payload.currentTime = player.video.currentTime
+          payload.ended = true
+
+          const jsonData = {}
+          jsonData.event = 'progress'
+          jsonData.payload = JSON.stringify(payload)
+          SocketInstance.send(jsonData)
+        }
+      })
+
+      player.on('volumechange', () => {
+        console.log('声音改变')
+      })
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 106 - 0
src/components/VideoPreviewPlayer.vue

@@ -0,0 +1,106 @@
+<template>
+  <div id="dplayer" ref="dplayer" style="height: 480px;" />
+</template>
+
+<script>
+import { videoUrl } from '@/api/video'
+
+import flvjs from 'flv.js'
+import DPlayer from 'dplayer'
+
+export default {
+  name: 'VideoPreviewPlayer',
+  props: {
+    videoProp: {
+      type: Object,
+      default: () => null
+    }
+  },
+  data() {
+    return {
+      flvjs,
+      DPlayer,
+      message: '',
+      dplayer: null
+    }
+  },
+  watch: {
+    // 监控 videoProp 对象的变化
+    videoProp(newVal) {
+      console.log('videoProp changed')
+      if (!newVal.play) {
+        this.dplayer.destroy()
+      } else {
+        this.getVideoUrl(newVal.videoId)
+      }
+    }
+  },
+  created() {
+    this.getVideoUrl(this.videoProp.videoId)
+  },
+  methods: {
+    getVideoUrl(videoId) {
+      videoUrl(videoId).then(res => {
+        if (res.code === 0) {
+          const urlType = res.data.type
+          if (urlType === 'mp4') {
+            const urls = res.data.urls
+            for (const url of urls) {
+              url.type = 'normal'
+            }
+            this.dplayer = this.initMp4Player(this.videoProp.coverUrl, urls)
+          } else if (urlType === 'flv') {
+            const urls = res.data.urls
+            const url = urls[0].url
+            this.dplayer = this.initFlvPlayer(this.videoProp.coverUrl, url)
+          } else {
+            this.message = 'url 类型无法识别'
+          }
+        } else {
+          console.error(res.msg)
+        }
+      }).catch(error => {
+        console.error(error.message)
+      })
+    },
+    initMp4Player(coverUrl, videoUrls) {
+      return new DPlayer({
+        container: document.querySelector('#dplayer'),
+        lang: 'zh-cn',
+        logo: '/logo.png',
+        screenshot: false,
+        autoplay: true,
+        volume: 0.1,
+        mutex: true,
+        video: {
+          pic: coverUrl,
+          defaultQuality: 0,
+          quality: videoUrls
+        }
+      })
+    },
+    initFlvPlayer(coverUrl, videoUrl) {
+      return new DPlayer({
+        container: document.getElementById('dplayer'),
+        video: {
+          url: videoUrl,
+          type: 'customFlv',
+          customType: {
+            customFlv: function(video, player) {
+              const flvPlayer = flvjs.createPlayer({
+                type: 'flv',
+                url: video.src
+              })
+              flvPlayer.attachMediaElement(video)
+              flvPlayer.load()
+            }
+          }
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 65 - 0
src/components/card/ArticleCard.vue

@@ -0,0 +1,65 @@
+<template>
+  <el-col style="padding-right: 7px; padding-left: 7px">
+    <div style="cursor: pointer" :title="article.title">
+      <el-card class="box-card">
+        <div slot="header" class="clearfix">
+          <el-row>
+            <router-link target="_blank" :to="`/article/${article.articleId}`">
+              <span style="left: 0;margin-bottom: 0px;color: black;">{{ article.title | ellipsis }}</span>
+            </router-link>
+          </el-row>
+        </div>
+        <div class="text item">
+          <el-row>
+            <span v-html="article.excerpt"/>
+          </el-row>
+        </div>
+      </el-card>
+    </div>
+  </el-col>
+</template>
+
+<script>
+export default {
+  name: 'ArticleCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 10
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    article: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+  }
+}
+</script>
+
+<style scoped>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+</style>

+ 171 - 0
src/components/card/AudioCard.vue

@@ -0,0 +1,171 @@
+<template>
+  <el-col style="padding-right: 7px; padding-left: 7px">
+    <div style="cursor: pointer" :title="audio.title">
+      <el-card :body-style="{ padding: '0px' }" class="card">
+        <router-link target="_blank" :to="`/audio/${audio.audioId}`">
+          <div class="imgs">
+            <el-image
+              :src="audio.coverUrl"
+              lazy
+              fit="cover"
+              class="coverImg"
+            />
+            <span style="position: absolute; bottom: 0; right: 0; color:blue">
+              <i class="el-icon-mic">{{ audio.duration }} </i>
+            </span>
+          </div>
+        </router-link>
+        <div style="padding: 14px">
+          <router-link target="_blank" :to="`/audio/${audio.audioId}`">
+            <span style="left: 0;margin-bottom: 0px;color: black;">{{ audio.title | ellipsis }}</span>
+          </router-link>
+        </div>
+        <div style="padding: 14px">
+          <span style="left: 0;margin-bottom: 0px;color: black;">{{ audio.pubDate }}</span>
+        </div>
+        <div v-if="audio.user !== undefined && audio.user !== null" style="padding: 14px">
+          <span style="left: 0;margin-bottom: 0px;color: black;">
+            <router-link target="_blank" :to="`/user/${audio.user.userId}`"><i class="el-icon-user"> {{ audio.user.screenName }} </i></router-link> · {{ convertTimestamp(audio.pubDate) }}
+          </span>
+        </div>
+      </el-card>
+    </div>
+  </el-col>
+</template>
+
+<script>
+export default {
+  name: 'AudioCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 10
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    audio: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    convertTimestamp(value) {
+      const date = new Date(value*1000)
+      var month = date.getMonth()
+      if (month < 10) {
+        if (month === 0) {
+          month = '01'
+        } else {
+          month = '0' + month
+        }
+      }
+
+      var day = date.getDay()
+      if (day < 10) {
+        day = '0' + day
+      }
+      return month + '-' + day
+    }
+  }
+}
+</script>
+
+<style scoped>
+.time {
+  font-size: 15px;
+  color: #999;
+}
+
+.bottom {
+  margin-top: 13px;
+  line-height: 12px;
+}
+
+.tit {
+  font-weight: 700;
+  font-size: 18px;
+
+  height: 50px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2; /*行数*/
+  -webkit-box-orient: vertical;
+}
+
+.num {
+  position: relative;
+  font-size: 15px;
+  padding-top: 9px;
+}
+
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+  .tit {
+    font-weight: 600;
+    font-size: 12px;
+    height: 32px;
+  }
+  .time {
+    font-size: 10px;
+    color: #999;
+  }
+  .num {
+    font-size: 9px;
+    padding-top: 3px;
+  }
+  .bottom {
+    margin-top: 2px;
+    line-height: 7px;
+  }
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.coverImg {
+  width: 100%;
+  height: 175px;
+  display: block;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+/*.card:hover {
+  !*鼠标放上之后元素变成1.06倍大小*!
+  transform: scale(1.06);
+}*/
+.imgs {
+  position: relative;
+}
+.play-icon {
+  position: absolute;
+  /*top: -15px;*/
+  right: 2%;
+  bottom: 5px;
+  z-index: 7;
+  width: 40px;
+}
+</style>

+ 196 - 0
src/components/card/HistoryVideoCard.vue

@@ -0,0 +1,196 @@
+<template>
+  <el-col style="padding-right: 7px; padding-left: 7px">
+    <div style="cursor: pointer" :title="video.title">
+      <el-card :body-style="{ padding: '0px' }" class="card">
+        <router-link target="_blank" :to="`/video/${video.videoId}`">
+          <div class="imgs">
+            <el-image
+              lazy
+              fit="cover"
+              :src="video.coverUrl"
+              class="coverImg"
+            />
+            <span style="position: absolute; bottom: 0; left: 0; color:white">
+              <i v-if="video.horizontal" class="el-icon-monitor" />
+              <i v-else class="el-icon-mobile-phone" />
+            </span>
+            <span style="position: absolute; bottom: 0; left: 10%; color:white">
+              <i class="el-icon-video-play">{{ getVisited(video.view) }}</i>
+            </span>
+            <span style="position: absolute; bottom: 0; left: 30%; color:white">
+              <i class="el-icon-s-comment">{{ getVisited(video.comment) }}</i>
+            </span>
+            <span style="position: absolute; bottom: 0; right: 0; color:white"> {{ video.duration }} </span>
+          </div>
+        </router-link>
+        <el-progress :percentage="setItemProgress(video)" />
+        <div style="padding: 14px">
+          <router-link target="_blank" :to="`/video/${video.videoId}`">
+            <span style="left: 0;margin-bottom: 0px;color: black;">{{ video.title | ellipsis }}</span>
+          </router-link>
+        </div>
+        <div style="padding: 14px">
+          <span style="left: 0;margin-bottom: 0px;color: black;">
+            <router-link target="_blank" :to="`/user/${video.user.userId}`">
+              <i class="el-icon-user"> {{ video.user.screenName | ellipsisUsername }} </i></router-link> • {{ video.publishAt }}
+          </span>
+        </div>
+      </el-card>
+    </div>
+  </el-col>
+</template>
+
+<script>
+import { handleVisited } from 'assets/js/utils'
+
+export default {
+  name: 'HistoryVideoCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 10
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    },
+    ellipsisUsername(value) {
+      if (!value) return ''
+      const max = 5
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    video: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    getVisited(visited) {
+      return handleVisited(visited)
+    },
+    convertTimestamp(value) {
+      const date = new Date(value * 1000)
+      var month = date.getMonth()
+      if (month < 10) {
+        if (month === 0) {
+          month = '01'
+        } else {
+          month = '0' + month
+        }
+      }
+
+      var day = date.getDay()
+      if (day < 10) {
+        day = '0' + day
+      }
+      return month + '-' + day
+    },
+    setItemProgress(video) {
+      const progress = Math.floor(video.currentTime / video.duration * 100)
+      return progress
+    }
+  }
+}
+</script>
+
+<style scoped>
+.time {
+  font-size: 15px;
+  color: #999;
+}
+
+.bottom {
+  margin-top: 13px;
+  line-height: 12px;
+}
+
+.tit {
+  font-weight: 700;
+  font-size: 18px;
+
+  height: 50px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-overflow: ellipsisUsername;
+  display: -webkit-box;
+  -webkit-line-clamp: 2; /*行数*/
+  -webkit-box-orient: vertical;
+}
+
+.num {
+  position: relative;
+  font-size: 15px;
+  padding-top: 9px;
+}
+
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+  .tit {
+    font-weight: 600;
+    font-size: 12px;
+    height: 32px;
+  }
+  .time {
+    font-size: 10px;
+    color: #999;
+  }
+  .num {
+    font-size: 9px;
+    padding-top: 3px;
+  }
+  .bottom {
+    margin-top: 2px;
+    line-height: 7px;
+  }
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.coverImg {
+  width: 100%;
+  height: 175px;
+  display: block;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+/*.card:hover {
+  !*鼠标放上之后元素变成1.06倍大小*!
+  transform: scale(1.06);
+}*/
+.imgs {
+  position: relative;
+}
+.play-icon {
+  position: absolute;
+  /*top: -15px;*/
+  right: 2%;
+  bottom: 5px;
+  z-index: 7;
+  width: 40px;
+}
+</style>

+ 237 - 0
src/components/card/HotSearch.vue

@@ -0,0 +1,237 @@
+<template>
+  <el-card class="box-card" :body-style="{ paddingTop: '0px' }">
+    <div slot="header" class="clearfix">
+      <img src="@/assets/img/icon/like.png" alt="" class="recommand-icon">
+      <span>{{ cardProp.title }}</span>
+    </div>
+    <div v-if="cardProp.type === 'hotSearch'">
+      <div
+        v-for="(search, index) in cardProp.dataList"
+        :key="index"
+        :title="search.keyword"
+        class="item"
+      >
+        <div :class="rank(index + 1)">{{ index + 1 }}</div>
+        <router-link target="_blank" :to="`/search?keyword=${search.keyword}&pageNumber=1`">
+          {{ search.keyword }}
+        </router-link>
+      </div>
+    </div>
+    <div v-if="cardProp.type === 'hotWatch'">
+      <div
+        v-for="(video, index) in cardProp.dataList"
+        :key="index"
+        :title="video.title"
+        class="item"
+      >
+        <div :class="rank(index + 1)">{{ index + 1 }}</div>
+        <router-link target="_blank" :to="`/video/${video.videoId}`">
+          <span style="left: 0;margin-bottom: 0px;color: blue;">{{ video.title | ellipsis }}</span>
+        </router-link>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script>
+export default {
+  name: 'HotSearch',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 15
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    cardProp: {
+      type: Object,
+      default: null
+    }
+  },
+  data() {
+    return {
+    }
+  },
+  computed: {
+    rank() {
+      return (index) => {
+        // index是使用时的参数
+        // console.log(index)
+        if (index === 1) {
+          return 'first'
+        } else if (index === 2) {
+          return 'second'
+        } else if (index === 3) {
+          return 'third'
+        } else if (index === 4) {
+          return 'fourth'
+        } else if (index === 5) {
+          return 'fifth'
+        } else if (index === 6) {
+          return 'sixth'
+        } else if (index === 7) {
+          return 'seventh'
+        } else {
+          return 'other'
+        }
+      }
+    }
+  },
+  created() {
+  },
+  methods: {}
+}
+</script>
+
+<style scoped>
+.item {
+  height: 25px;
+  margin-top: 4px;
+  margin-bottom: 16px;
+  cursor: pointer;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 1; /*行数*/
+  -webkit-box-orient: vertical;
+}
+.item:hover {
+  transform: scale(1.1); /*鼠标放上之后元素变成1.1倍大小*/
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+.clearfix:after {
+  clear: both;
+}
+
+.clearfix {
+  position: relative;
+}
+.clearfix span {
+  font-size: 20px;
+  position: absolute;
+  bottom: 8px;
+  left: 35px;
+}
+.box-card {
+  width: 100%;
+}
+
+.recommand-icon {
+  width: 30px;
+}
+
+@media screen and (max-width: 768px) {
+  .clearfix span {
+    font-size: 15px;
+    position: absolute;
+    bottom: 8px;
+    left: 35px;
+  }
+  .text {
+    font-size: 13px;
+  }
+}
+
+.other {
+  display: inline-block;
+  background-color: rgb(176, 183, 194);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.first {
+  display: inline-block;
+  background-color: rgb(255, 0, 0);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.second {
+  display: inline-block;
+  background-color: rgb(255, 128, 0);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.third {
+  display: inline-block;
+  background-color: rgb(255, 215, 0);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.fourth {
+  display: inline-block;
+  background-color: rgb(0, 255, 0);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.fifth {
+  display: inline-block;
+  background-color: rgb(0, 255, 255);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.sixth {
+  display: inline-block;
+  background-color: rgb(0, 0, 255);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.seventh {
+  display: inline-block;
+  background-color: rgb(128, 0, 255);
+  width: 22px;
+  height: 22px;
+  line-height: 20px;
+  text-align: center;
+  color: #ffffff;
+  font-size: 15px;
+  border-radius: 5px;
+}
+.el-card__body {
+  padding-top: 0px;
+}
+</style>

+ 156 - 0
src/components/card/ImageAlbumCard.vue

@@ -0,0 +1,156 @@
+<template>
+  <el-col style="padding-right: 7px; padding-left: 7px">
+    <div style="cursor: pointer">
+      <el-card :body-style="{ padding: '0px' }" class="card">
+        <router-link target="_blank" :to="`/image/album/${imageAlbum.albumId}`">
+          <div class="imgs">
+            <el-image
+              lazy
+              fit="cover"
+              :src="imageAlbum.coverUrl"
+              class="coverImg"
+            />
+            <span style="position: absolute; bottom: 0; right: 0; color:white">
+              <i class="el-icon-picture-outline">{{imageAlbum.total}}</i>
+            </span>
+          </div>
+        </router-link>
+        <div style="padding: 14px">
+          <router-link target="_blank" :to="`/image/album/${imageAlbum.albumId}`">
+            <span style="left: 0;margin-bottom: 0px;color: black;">{{ imageAlbum.albumName | ellipsis }}</span>
+          </router-link>
+        </div>
+<!--        <div style="padding: 14px">
+          <span style="left: 0;margin-bottom: 0px;color: black;">
+            <router-link target="_blank" :to="`/user/${imageAlbum.userId}`"><i class="el-icon-user"> {{ imageAlbum.username }} </i></router-link> · {{ imageAlbum.pubDate }}
+          </span>
+        </div>-->
+      </el-card>
+    </div>
+  </el-col>
+</template>
+
+<script>
+import { handleVisited } from 'assets/js/utils'
+
+export default {
+  name: 'ImageAlbumCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 20
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    imageAlbum: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    getVisited(visited) {
+      return handleVisited(visited)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.time {
+  font-size: 15px;
+  color: #999;
+}
+
+.bottom {
+  margin-top: 13px;
+  line-height: 12px;
+}
+
+.tit {
+  font-weight: 700;
+  font-size: 18px;
+
+  height: 50px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2; /*行数*/
+  -webkit-box-orient: vertical;
+}
+
+.num {
+  position: relative;
+  font-size: 15px;
+  padding-top: 9px;
+}
+
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+  .tit {
+    font-weight: 600;
+    font-size: 12px;
+    height: 32px;
+  }
+  .time {
+    font-size: 10px;
+    color: #999;
+  }
+  .num {
+    font-size: 9px;
+    padding-top: 3px;
+  }
+  .bottom {
+    margin-top: 2px;
+    line-height: 7px;
+  }
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.coverImg {
+  width: 100%;
+  height: 175px;
+  display: block;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+/*.card:hover {
+  !*鼠标放上之后元素变成1.06倍大小*!
+  transform: scale(1.06);
+}*/
+.imgs {
+  position: relative;
+}
+.play-icon {
+  position: absolute;
+  /*top: -15px;*/
+  right: 2%;
+  bottom: 5px;
+  z-index: 7;
+  width: 40px;
+}
+</style>

+ 74 - 0
src/components/card/PermissionDeniedCard.vue

@@ -0,0 +1,74 @@
+<template>
+  <div>
+    <el-row class="not-result">
+      <span style="position: relative; bottom: 0; color:red; font-size: 20px">
+        抱歉, 此{{textObject.content}}仅供 VIP 查看, 您还不是 VIP~
+      </span>
+    </el-row>
+    <el-row class="not-result">
+      <el-col :md="24" class="movie-list">
+        <el-col :md="12">
+          <el-button
+            size="medium"
+            type="warning"
+            @click="handleAck(textObject.route)">看看别的</el-button>
+        </el-col>
+        <el-col :md="12">
+          <el-button
+            size="medium"
+            type="success"
+            @click="handleVip">开通 VIP</el-button>
+        </el-col>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'PermissionDeniedCard',
+  props: {
+    textObject: {
+      type: Object,
+      default: null
+    },
+  },
+  filters: {
+  },
+  data() {
+    return {
+    }
+  },
+  methods: {
+    handleAck(route) {
+      this.$router.push(route)
+    },
+    handleVip() {
+      this.$router.push('/vip')
+    }
+  }
+}
+</script>
+
+<style scoped>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px){
+  .movie-list {
+    padding-top: 8px;
+    padding-left: 0.5%;
+    padding-right: 0.5%;
+  }
+}
+
+.movie-list {
+  padding-top: 15px;
+  padding-left: 6%;
+  padding-right: 6%;
+}
+
+.not-result {
+  padding-top: 100px;
+  padding-bottom: 100px;
+  text-align: center;
+}
+</style>

+ 111 - 0
src/components/card/SideVideoCard.vue

@@ -0,0 +1,111 @@
+<template>
+  <el-col style="padding-right: 7px; padding-left: 7px">
+    <div style="cursor: pointer" :title="video.title">
+      <el-card :body-style="{ padding: '0px' }" class="card">
+        <el-col :md="8">
+          <router-link :to="`/video/${video.videoId}`">
+            <div class="imgs">
+              <el-image
+                lazy
+                fit="cover"
+                :src="video.coverUrl"
+                class="coverImg"
+              />
+              <span style="position: absolute; bottom: 0; left: 0; color:white">
+                <i v-if="video.horizontal" class="el-icon-monitor" />
+                <i v-else class="el-icon-mobile-phone" />
+              </span>
+              <span style="position: absolute; bottom: 0; right: 0; color:white"> {{ video.duration }} </span>
+            </div>
+          </router-link>
+        </el-col>
+        <el-col :md="16">
+          <div style="padding: 14px">
+            <router-link style="text-decoration-line: none" :to="`/video/${video.videoId}`">
+              <span style="left: 0;margin-bottom: 0px;color: black;">{{ video.title | ellipsis }}</span>
+            </router-link>
+          </div>
+          <div style="padding: 14px">
+            <span style="left: 0;margin-bottom: 0px;color: black;">
+              <router-link style="text-decoration-line: none" target="_blank" :to="`/user/${video.user.userId}`">
+                <i class="el-icon-user"> {{ video.user.screenName | ellipsisUsername }} </i></router-link>
+            </span>
+          </div>
+          <div style="padding: 14px">
+            <el-col :md="6">
+              <span class="el-icon-video-play" style="left: 0;margin-bottom: 0px;color: black;">
+                {{ video.view }}
+              </span>
+            </el-col>
+            <el-col :md="6">
+              <span class="el-icon-s-comment" style="left: 0;margin-bottom: 0px;color: black;">
+                {{ video.comment }}
+              </span>
+            </el-col>
+          </div>
+        </el-col>
+      </el-card>
+    </div>
+  </el-col>
+</template>
+
+<script>
+export default {
+  name: 'SideVideoCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 20
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    },
+    ellipsisUsername(value) {
+      if (!value) return ''
+      const max = 10
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    video: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+  }
+}
+</script>
+
+<style scoped>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+  .coverImg {
+    height: 80px !important;
+  }
+}
+
+.coverImg {
+  width: 100%;
+  height: 120px;
+  display: block;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+.imgs {
+  position: relative;
+}
+</style>

+ 85 - 0
src/components/card/SiteNotice.vue

@@ -0,0 +1,85 @@
+<template>
+  <el-card class="box-card">
+    <div slot="header" class="clearfix">
+      <img src="@/assets/img/icon/speaker.png" alt="" class="recommand-icon">
+      <span>站点公告</span>
+    </div>
+    <div class="text item" style="height: 20vh;">
+      <el-scrollbar style="width: 100%; height: 100%;">
+        <el-row>
+          <span v-html="content" />
+        </el-row>
+      </el-scrollbar>
+    </div>
+  </el-card>
+</template>
+
+<script>
+import { getSiteNotice } from '@/api/admin'
+
+export default {
+  name: 'SiteNotice',
+  data() {
+    return {
+      content: ''
+    }
+  },
+  created() {
+    getSiteNotice().then(resp => {
+      if (resp.code === 0) {
+        this.content = resp.data
+      }
+    })
+  },
+  methods: {}
+}
+</script>
+
+<style scoped>
+@media screen and (max-width: 768px) {
+  .clearfix span {
+    font-size: 15px;
+    position: absolute;
+    bottom: 8px;
+    left: 35px;
+  }
+}
+
+.item {
+  height: 25px;
+  margin-top: 4px;
+  margin-bottom: 16px;
+
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 1; /*行数*/
+  -webkit-box-orient: vertical;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+.clearfix:after {
+  clear: both;
+}
+
+.clearfix {
+  position: relative;
+}
+.clearfix span {
+  font-size: 20px;
+  position: absolute;
+  bottom: 8px;
+  left: 35px;
+}
+.box-card {
+  width: 100%;
+}
+
+.recommand-icon {
+  width: 30px;
+}
+</style>

+ 166 - 0
src/components/card/StatusCard.vue

@@ -0,0 +1,166 @@
+<template>
+  <el-card :body-style="{ padding: '0px' }" class="card">
+    <el-row>
+      <el-col :md="3">
+        <router-link target="_blank" :to="`/user/` + status.user.userId">
+          <el-avatar>
+            <el-image :src="status.user.avatarUrl" />
+          </el-avatar>
+        </router-link>
+      </el-col>
+      <el-col :md="12">
+        <el-row>
+          <router-link target="_blank" :to="`/user/` + status.user.userId">
+            <span>{{ status.user.screenName }}</span>
+          </router-link>
+        </el-row>
+        <el-row>
+          <router-link target="_blank" :to="`/status/` + status.statusId">
+            <span>{{ status.createdAt }} 来自 微博网页版</span>
+          </router-link>
+        </el-row>
+      </el-col>
+    </el-row>
+    <el-row>
+      <span v-html="status.text" />
+    </el-row>
+    <el-row v-if="status.imageUrls.length !== 0">
+      <el-col :md="6" v-for="imageUrl in status.imageUrls" :key="imageUrl.thumbnailUrl">
+        <el-image
+          lazy
+          fit="cover"
+          class="coverImg"
+          :src="imageUrl.thumbnailUrl"
+          @click="showImage(status.imageUrls)">
+        </el-image>
+      </el-col>
+    </el-row>
+    <el-row v-if="status.audioUrl !== undefined && status.audioUrl !== null">
+    </el-row>
+  </el-card>
+</template>
+
+<script>
+export default {
+  name: 'StatusCard',
+  components: {},
+  props: {
+    status: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    showImage(imageUrls) {
+      const imgs = []
+      for (const i of imageUrls) {
+        imgs.push(i.originalUrl)
+      }
+      this.$viewerApi({
+        images: imgs,
+        options: {
+          movable: true,
+          fullscreen: false,
+          keyboard: true
+        }
+      })
+    },
+  }
+}
+</script>
+
+<style scoped>
+.time {
+  font-size: 15px;
+  color: #999;
+}
+
+.bottom {
+  margin-top: 13px;
+  line-height: 12px;
+}
+
+.tit {
+  font-weight: 700;
+  font-size: 18px;
+
+  height: 50px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2; /*行数*/
+  -webkit-box-orient: vertical;
+}
+
+.num {
+  position: relative;
+  font-size: 15px;
+  padding-top: 9px;
+}
+
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+  .tit {
+    font-weight: 600;
+    font-size: 12px;
+    height: 32px;
+  }
+  .time {
+    font-size: 10px;
+    color: #999;
+  }
+  .num {
+    font-size: 9px;
+    padding-top: 3px;
+  }
+  .bottom {
+    margin-top: 2px;
+    line-height: 7px;
+  }
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.coverImg {
+  width: 100%;
+  height: 175px;
+  display: block;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+/*.card:hover {
+  !*鼠标放上之后元素变成1.06倍大小*!
+  transform: scale(1.06);
+}*/
+.imgs {
+  position: relative;
+}
+.play-icon {
+  position: absolute;
+  /*top: -15px;*/
+  right: 2%;
+  bottom: 5px;
+  z-index: 7;
+  width: 40px;
+}
+</style>

+ 67 - 0
src/components/card/TextCard.vue

@@ -0,0 +1,67 @@
+<template>
+  <el-card :body-style="{ padding: '0px' }" class="card">
+    <el-input
+      v-model="textarea"
+      type="textarea"
+      :rows="3"
+      placeholder="有什么新鲜事想分享给大家?"
+    />
+    <el-upload
+      :action="actionUrl"
+    >
+      <el-button><i class="el-icon-picture-outline">图片</i></el-button>
+    </el-upload>
+    <el-dialog :visible.sync="dialogVisible">
+      <img width="100%" :src="dialogImageUrl" alt="">
+    </el-dialog>
+    <el-row style="text-align: right">
+      <el-button round type="submit" @click="onSubmit">发送</el-button>
+    </el-row>
+  </el-card>
+</template>
+
+<script>
+export default {
+  name: 'TextCard',
+  data() {
+    return {
+      actionUrl: process.env.VUE_APP_SERVER_URL + '/api/content/timeline',
+      textarea: '',
+      dialogImageUrl: '',
+      dialogVisible: false
+    }
+  },
+  methods: {
+    handleRemove(file, fileList) {
+      console.log(file, fileList)
+    },
+    handlePictureCardPreview(file) {
+      this.dialogImageUrl = file.url
+      this.dialogVisible = true
+    },
+    onSubmit() {
+      console.log('发送状态')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.el-button--submit.is-active,
+.el-button--submit:active {
+  background: #20B2AA;
+  border-color: #20B2AA;
+  color: #fff;
+}
+.el-button--submit:focus,
+.el-button--submit:hover {
+  background: #48D1CC;
+  border-color: #48D1CC;
+  color: #fff;
+}
+.el-button--submit {
+  color: #FFF;
+  background-color: #20B2AA;
+  border-color: #20B2AA;
+}
+</style>

+ 139 - 0
src/components/card/UserAvatarCard.vue

@@ -0,0 +1,139 @@
+<template>
+  <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+    <el-card class="box-card">
+      <div slot="header" class="clearfix">
+        <el-row>
+          <el-col :md="4">
+            <router-link target="_blank" :to="`/user/` + userAvatar.userId">
+              <el-avatar>
+                <el-image :src="userAvatar.avatarUrl" />
+              </el-avatar>
+            </router-link>
+          </el-col>
+          <el-col :md="16">
+            <el-row>
+              <span v-html="userAvatar.screenName" />
+            </el-row>
+            <el-row>
+              <span>关注 {{ userAvatar.following }}</span>
+              <span v-html="'&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'" />
+              <span>粉丝 {{ userAvatar.follower }}</span>
+            </el-row>
+            <el-row v-if="userAvatar.signature !== null">
+              <span>{{ userAvatar.signature }}</span>
+            </el-row>
+          </el-col>
+        </el-row>
+      </div>
+      <div class="text item">
+        <el-row>
+          <el-col :md="18">
+            <el-button
+              v-if="userAvatar.followed"
+              type="danger"
+              size="mini"
+              icon="el-icon-check"
+              @click="unfollowUser(userAvatar.userId)"
+            >
+              <span>已关注</span>
+            </el-button>
+            <el-button
+              v-else
+              type="danger"
+              size="mini"
+              icon="el-icon-plus"
+              @click="followUser(userAvatar.userId)"
+            >
+              <span>关注</span>
+            </el-button>
+            <el-button
+              type="danger"
+              size="mini"
+              icon="el-icon-message"
+              @click="sendMessage(userAvatar.userId)"
+            >
+              <span>发消息</span>
+            </el-button>
+          </el-col>
+        </el-row>
+      </div>
+    </el-card>
+  </el-row>
+</template>
+
+<script>
+import { followUser, unfollowUser } from '@/api/user'
+
+export default {
+  name: 'UserAvatarCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 20
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    userAvatar: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    return {
+      followButton: {
+        icon: 'el-icon-plus',
+        text: '关注'
+      }
+    }
+  },
+  created() {
+  },
+  methods: {
+    followUser(userId) {
+      followUser(userId).then(resp => {
+        if (resp.code === 0) {
+          this.userAvatar.followed = true
+        }
+      })
+    },
+    unfollowUser(userId) {
+      unfollowUser(userId).then(resp => {
+        if (resp.code === 0) {
+          this.userAvatar.followed = false
+        }
+      })
+    },
+    sendMessage(userId) {
+      this.$notify.info({
+        message: '未实现',
+        duration: 3000
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+</style>

+ 199 - 0
src/components/card/VideoCard.vue

@@ -0,0 +1,199 @@
+<template>
+  <el-col style="padding-right: 7px; padding-left: 7px">
+    <div style="cursor: pointer" :title="video.title">
+      <el-card :body-style="{ padding: '0px' }" class="card">
+        <router-link target="_blank" :to="`/video/${video.videoId}`">
+          <div class="imgs">
+            <el-image
+              lazy
+              fit="cover"
+              :src="video.coverUrl"
+              class="coverImg"
+            />
+            <span style="position: absolute; top: 0; left: 0; color:red">
+              <i v-if="!video.cached" class="el-icon-close" />
+            </span>
+            <span style="position: absolute; top: 0; left: 60%; color:white"> {{ video.duration }} </span>
+            <span style="position: absolute; bottom: 0; left: 0; color:white">
+              <i v-if="video.horizontal" class="el-icon-monitor" />
+              <i v-else class="el-icon-mobile-phone" />
+            </span>
+            <span style="position: absolute; bottom: 0; left: 10%; color:white">
+              <i class="el-icon-video-play">{{ getVisited(video.view) }}</i>
+            </span>
+            <span style="position: absolute; bottom: 0; left: 40%; color:white">
+              <i class="el-icon-s-comment">{{ getVisited(video.comment) }}</i>
+            </span>
+          </div>
+        </router-link>
+        <div style="padding: 14px">
+          <router-link style="text-decoration-line: none" target="_blank" :to="`/video/${video.videoId}`">
+            <span style="left: 0;margin-bottom: 0px;color: black;">{{ video.title | ellipsis }}</span>
+          </router-link>
+        </div>
+        <div v-if="video.user !== undefined && video.user !== null" style="padding: 14px">
+          <span style="left: 0;margin-bottom: 0px;color: black;">
+            <router-link target="_blank" :to="`/user/${video.user.userId}`">
+              <i class="el-icon-user"> {{ video.user.screenName | ellipsisUsername }} </i></router-link> • {{ video.pubDateStr }}
+          </span>
+        </div>
+        <div v-if="video.user === undefined || video.user === null" style="padding: 14px">
+          <span style="left: 0;margin-bottom: 0px;color: black;">
+            {{ video.pubDateStr }}
+          </span>
+        </div>
+      </el-card>
+    </div>
+  </el-col>
+</template>
+
+<script>
+import { handleVisited } from 'assets/js/utils'
+
+export default {
+  name: 'VideoCard',
+  filters: {
+    ellipsis(value) {
+      if (!value) return ''
+      const max = 10
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    },
+    ellipsisUsername(value) {
+      if (!value) return ''
+      const max = 5
+      if (value.length > max) {
+        return value.slice(0, max) + '...'
+      }
+      return value
+    }
+  },
+  props: {
+    video: {
+      type: Object,
+      default: null
+    },
+    // 时间前的描述
+    dateTit: {
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    getVisited(visited) {
+      return handleVisited(visited)
+    },
+    convertTimestamp(value) {
+      const date = new Date(value * 1000)
+      var month = date.getMonth()
+      if (month < 10) {
+        if (month === 0) {
+          month = '01'
+        } else {
+          month = '0' + month
+        }
+      }
+
+      var day = date.getDay()
+      if (day < 10) {
+        day = '0' + day
+      }
+      return month + '-' + day
+    }
+  }
+}
+</script>
+
+<style scoped>
+.time {
+  font-size: 15px;
+  color: #999;
+}
+
+.bottom {
+  margin-top: 13px;
+  line-height: 12px;
+}
+
+.tit {
+  font-weight: 700;
+  font-size: 18px;
+
+  height: 50px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-overflow: ellipsisUsername;
+  display: -webkit-box;
+  -webkit-line-clamp: 2; /*行数*/
+  -webkit-box-orient: vertical;
+}
+
+.num {
+  position: relative;
+  font-size: 15px;
+  padding-top: 9px;
+}
+
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px) {
+  .tit {
+    font-weight: 600;
+    font-size: 12px;
+    height: 32px;
+  }
+  .time {
+    font-size: 10px;
+    color: #999;
+  }
+  .num {
+    font-size: 9px;
+    padding-top: 3px;
+  }
+  .bottom {
+    margin-top: 2px;
+    line-height: 7px;
+  }
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.coverImg {
+  width: 100%;
+  height: 175px;
+  display: block;
+}
+
+.clearfix:before,
+.clearfix:after {
+  display: table;
+  content: "";
+}
+
+.clearfix:after {
+  clear: both;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+/*.card:hover {
+  !*鼠标放上之后元素变成1.06倍大小*!
+  transform: scale(1.06);
+}*/
+.imgs {
+  position: relative;
+}
+.play-icon {
+  position: absolute;
+  /*top: -15px;*/
+  right: 2%;
+  bottom: 5px;
+  z-index: 7;
+  width: 40px;
+}
+</style>

+ 449 - 0
src/components/comment/components/CommentForm.vue

@@ -0,0 +1,449 @@
+<template>
+  <div :class="`${className} comment-form`">
+    <div class="avatar-box">
+      <slot />
+    </div>
+
+    <div class="form-box">
+      <div class="rich-input" :class="{ focus: focus || value }">
+        <div class="grow-wrap" :data-replicated-value="value">
+          <textarea
+            ref="input"
+            rows="1"
+            :value="value"
+            :placeholder="placeholder"
+            @input="(e) => (value = e.target.value)"
+            @focus="focus = true"
+            @blur="handleBlur"
+            @mousedown="closeEmojiSelector"
+          />
+        </div>
+        <div v-show="imgSrc" ref="image-preview-box" class="image-preview-box">
+          <div
+            v-show="imgSrc"
+            :style="`background-image: url(${imgSrc})`"
+            class="image"
+          />
+          <div class="clean-btn" @mousedown.prevent="removeImg">
+            <svg
+              aria-hidden="true"
+              width="15"
+              height="15"
+              viewBox="0 0 21 21"
+              class="icon close-icon"
+            >
+              <g fill="none" fill-rule="evenodd" transform="translate(1 1)">
+                <circle
+                  cx="9.5"
+                  cy="9.5"
+                  r="9.5"
+                  fill="#000"
+                  stroke="#FFF"
+                  opacity=".5"
+                />
+                <path
+                  fill="#FFF"
+                  d="M13.743 5.964L10.207 9.5l3.536 3.536-.707.707L9.5 10.207l-3.536 3.536-.707-.707L8.793 9.5 5.257 5.964l.707-.707L9.5 8.793l3.536-3.536z"
+                />
+              </g>
+            </svg>
+          </div>
+        </div>
+      </div>
+      <div
+        v-show="focus || value || imgSrc"
+        class="option-box"
+        @mousedown.prevent="closeEmojiSelector($refs.input.focus())"
+      >
+        <div
+          class="emoji emoji-btn"
+          @mousedown.prevent.stop="openEmojiSelector"
+        >
+          <div class="emoji-box">
+            <div class="icon" />
+            <span>表情</span>
+          </div>
+          <EmojiSelector
+            v-show="showEmojiSelector"
+            @choose="(v) => (value += v)"
+          />
+        </div>
+        <div class="image-btn" @mousedown.prevent="triggerUpload">
+          <svg
+            aria-hidden="true"
+            width="22"
+            height="22"
+            viewBox="0 0 22 22"
+            class="icon image-icon"
+          >
+            <g fill="none" fill-rule="evenodd">
+              <path d="M1 1h20v20H1z" />
+              <g transform="translate(2 3)">
+                <path
+                  stroke="#027FFF"
+                  stroke-width=".9"
+                  d="M2.28.667h13.44c1.075 0 1.947.871 1.947 1.946v10.774a1.947 1.947 0 0 1-1.947 1.946H2.28a1.947 1.947 0 0 1-1.947-1.946V2.613c0-1.075.872-1.946 1.947-1.946zM.333 12.499L5 8l9.01 7.333m-6.343-4.842L10.333 8l7.136 5.914"
+                />
+                <circle cx="13.5" cy="4.5" r="1.5" fill="#027FFF" />
+              </g>
+            </g>
+          </svg>
+          <span>图片</span>
+          <input
+            ref="upload"
+            class="upload-file"
+            type="file"
+            @change="handleChange"
+            @click="onUpload = true"
+          >
+        </div>
+        <slot name="submitBtn">
+          <button
+            class="submit-btn"
+            :disabled="!value && !imgSrc"
+            @click.stop="handleSubmit"
+          >
+            评论
+          </button>
+        </slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import EmojiSelector from './EmojiSelector'
+export default {
+  name: 'CommentForm',
+  components: { EmojiSelector },
+  props: {
+    placeholder: {
+      type: String,
+      default: '输入评论...'
+    },
+    id: {
+      type: [String, Number],
+      default: 'comment-root'
+    },
+    comment: {
+      type: Object,
+      default: () => {}
+    },
+    parent: {
+      type: Object,
+      default: () => {}
+    },
+    uploadImg: {
+      type: Function,
+      default: null
+    }
+  },
+  data() {
+    return {
+      focus: false, // * 聚焦状态
+      value: '', // * 输入框值
+      imgSrc: '', // * 粘贴的图片src
+      showEmojiSelector: false // * 表情选择框状态
+    }
+  },
+  computed: {
+    // 是否为顶部评论表单
+    isRoot() {
+      return this.id === 'comment-root'
+    },
+    // 是否为回复中的表单
+    isSub() {
+      return this.id.split('-').length === 3
+    },
+    className() {
+      return this.isRoot
+        ? 'comment-root'
+        : this.isSub
+          ? 'reply sub-reply'
+          : 'reply'
+    }
+  },
+  mounted() {
+    const richInput = this.$refs.input
+    !this.isRoot && richInput.focus()
+
+    richInput.addEventListener('paste', this.handlePaste)
+    this.$once('hook:beforeDestroy', () =>
+      richInput.removeEventListener('paste', this.handlePaste)
+    )
+  },
+  methods: {
+    // * 选择要上传的图片
+    handleChange(e) {
+      const files = e.target.files
+      if (!(files && files[0])) return
+      this.beforeSetImg(files[0])
+    },
+    // * 处理图片
+    async beforeSetImg(file) {
+      if (!/^image/.test(file.type)) {
+        throw new Error("file type must contain 'image'.")
+      }
+
+      if (typeof this.uploadImg === 'function') {
+        const callback = (src) => {
+          this.imgSrc = src
+        }
+        await this.uploadImg({ file, callback })
+        return
+      }
+
+      const reader = new FileReader()
+      reader.readAsDataURL(file)
+      reader.onload = () => {
+        this.imgSrc = reader.result
+      }
+      reader.onerror = () => {
+        throw new Error(
+          `read file errored, the error code is ${reader.error.code}.`
+        )
+      }
+    },
+    // * 点击图片触发上传
+    triggerUpload() {
+      this.$refs.upload.click()
+    },
+    // * 点击图片上的删除按钮
+    removeImg() {
+      this.imgSrc = ''
+      this.closeEmojiSelector()
+    },
+    // * textarea blur 事件
+    handleBlur(e) {
+      this.showEmojiSelector = false
+
+      if (this.onUpload) {
+        this.$nextTick(() => {
+          this.onUpload = false
+        })
+        return
+      }
+
+      if (this.value || this.imgSrc) return
+
+      this.focus = false
+
+      if (!this.isRoot) {
+        this.close()
+      }
+    },
+    // * textarea paste 事件
+    handlePaste(e) {
+      const file = e.clipboardData.files[0]
+      if (file) {
+        // 只处理复制图片
+        this.beforeSetImg(file)
+        e.preventDefault()
+      }
+    },
+    // * 点击评论
+    handleSubmit() {
+      if (!this.value.trim() && !this.imgSrc) return
+      const user = (this.comment && this.comment.user) || null
+
+      const data = {
+        id: this.id,
+        content: this.value,
+        imgSrc: this.imgSrc,
+        reply: (this.isSub && JSON.parse(JSON.stringify(user))) || null,
+        createAt: new Date().getTime(),
+        likes: 0,
+        callback: () => {
+          this.isRoot ? this.reset() : this.close()
+        }
+      }
+
+      if (!this.isSub) {
+        data.children = []
+      }
+
+      this.$emit('form-submit', { newComment: data, parent: this.parent })
+    },
+    // * 重置组件状态
+    reset() {
+      this.value = ''
+      this.imgSrc = ''
+      this.$refs.input.blur()
+    },
+    // * 销毁组件
+    close() {
+      this.$emit('form-delete', this.id)
+    },
+    // * 选择表情
+    openEmojiSelector() {
+      this.showEmojiSelector = !this.showEmojiSelector
+
+      if (document.activeElement === document.body) {
+        this.$refs.input.focus()
+      }
+      if (this.showEmojiSelector) {
+        // 移动光标到末尾
+        const input = this.$refs.input
+        input.selectionStart = input.selectionEnd = this.value.length
+      }
+    },
+    // * 关闭选择表情组件
+    closeEmojiSelector() {
+      if (this.showEmojiSelector) {
+        this.showEmojiSelector = false
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.comment-form {
+  max-width: 100%;
+  padding: 0.8rem 1.0664rem;
+  display: flex;
+  background-color: #fafbfc;
+  border-radius: 3px;
+  .avatar-box {
+    flex: 0 0 auto;
+    img {
+      margin: 0 0.8rem 0 0;
+    }
+  }
+  .form-box {
+    flex: 1 1 auto;
+    .rich-input {
+      border-radius: 3px;
+      border: 1px solid #f1f1f1;
+      background-color: #fff;
+      overflow: hidden;
+      &.focus {
+        border-color: #007fff;
+      }
+      .grow-wrap {
+        display: grid;
+        &::after {
+          content: attr(data-replicated-value) ' ';
+          white-space: pre-wrap;
+          visibility: hidden;
+        }
+        textarea {
+          outline: none;
+          border: none;
+          resize: none;
+          touch-action: none;
+          overflow: hidden;
+          &::placeholder {
+            color: #c2c2c2;
+          }
+        }
+        & > textarea,
+        &::after {
+          font: inherit;
+          grid-area: 1 / 1 / 2 / 2;
+          padding: 0.48rem 0.8rem;
+          min-height: 1.04rem;
+          line-height: 1.7;
+          font-size: 0.8664rem;
+          color: #17181a;
+          box-sizing: border-box;
+          word-break: break-all;
+        }
+      }
+
+      .image-preview-box {
+        display: inline-block;
+        position: relative;
+        margin: 0 0.8rem 0.4rem;
+        .image {
+          width: 5.3336rem;
+          height: 5.3336rem;
+          background-repeat: no-repeat;
+          background-size: cover;
+          background-position: 50%;
+        }
+        .clean-btn {
+          position: absolute;
+          top: 0.15rem;
+          right: 0.2rem;
+          cursor: pointer;
+        }
+      }
+    }
+    .option-box {
+      margin-top: 0.52rem;
+      display: flex;
+      align-items: center;
+      color: #027fff;
+      font-size: 0.8664rem;
+      .emoji {
+        position: relative;
+        .emoji-box {
+          display: flex;
+          align-items: center;
+          cursor: pointer;
+          .icon {
+            width: 1.2rem;
+            height: 1.2rem;
+            background-repeat: no-repeat;
+            background-size: cover;
+            background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMiIgaGVpZ2h0PSIyMiIgdmlld0JveD0iMCAwIDIyIDIyIj4KICAgIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTTEgMWgyMHYyMEgxeiIvPgogICAgICAgIDxwYXRoIGZpbGw9IiMwMjdGRkYiIGZpbGwtcnVsZT0ibm9uemVybyIgZD0iTTExIDE4LjQzOGE3LjQzOCA3LjQzOCAwIDEgMCAwLTE0Ljg3NiA3LjQzOCA3LjQzOCAwIDAgMCAwIDE0Ljg3NnptMCAxLjA2MmE4LjUgOC41IDAgMSAxIDAtMTcgOC41IDguNSAwIDAgMSAwIDE3ek03LjgxMiA5LjkzN2ExLjA2MiAxLjA2MiAwIDEgMCAwLTIuMTI0IDEuMDYyIDEuMDYyIDAgMCAwIDAgMi4xMjV6bTYuMzc1IDBhMS4wNjMgMS4wNjMgMCAxIDAgMC0yLjEyNSAxLjA2MyAxLjA2MyAwIDAgMCAwIDIuMTI1ek0xMSAxNi4yMzJhMy4yNyAzLjI3IDAgMCAwIDMuMjctMy4yN0g3LjczYTMuMjcgMy4yNyAwIDAgMCAzLjI3IDMuMjd6Ii8+CiAgICA8L2c+Cjwvc3ZnPgo=');
+          }
+          &:hover {
+            opacity: 0.8;
+          }
+        }
+      }
+      .image-btn {
+        flex: 0 0 auto;
+        display: flex;
+        align-items: center;
+        margin-left: 20px;
+        cursor: pointer;
+        .icon {
+          margin-right: 0.2664rem;
+          width: 1.2rem;
+          height: 1.2rem;
+        }
+        &:hover {
+          opacity: 0.8;
+        }
+      }
+      .upload-file {
+        display: none;
+      }
+      .submit-btn {
+        flex: 0 0 auto;
+        margin-left: auto;
+        padding: 0.4rem 1.04rem;
+        font-size: 1rem;
+        color: #fff;
+        background-color: #027fff;
+        border-radius: 2px;
+        outline: none;
+        border: none;
+        cursor: pointer;
+        transition: all 0.3s;
+        &:hover {
+          background-color: #0371df;
+        }
+        &:disabled {
+          opacity: 0.4;
+          cursor: default;
+        }
+      }
+    }
+  }
+  &.reply {
+    margin-top: 0.8664rem;
+    padding: 0.8rem;
+    &.sub-reply {
+      background-color: #fff;
+      border: 1px solid #f1f1f2;
+    }
+    .avatar-box {
+      display: none;
+    }
+  }
+}
+</style>

+ 301 - 0
src/components/comment/components/CommentItem.vue

@@ -0,0 +1,301 @@
+<template>
+  <div class="comment-item" :class="{ 'sub-comment-item': isSubComment }">
+    <div class="comment">
+      <!-- 评论或回复人头像 -->
+      <img
+        class="avatar"
+        :src="comment.user.avatar || ''"
+        @error="(e) => e.target.classList.add('error')"
+      >
+      <div class="content-box">
+        <!-- 评论或回复人具体信息 -->
+        <div class="meta-box">
+          <slot name="userMeta">
+            <div class="user-popover-box">
+              <router-link target="_blank" :to="`/user/` + comment.user.userId">
+                <span v-if="comment.user">{{
+                  comment.user.name +
+                    (comment.user.author === true ? '(作者)' : '')
+                }}</span>
+              </router-link>
+            </div>
+          </slot>
+        </div>
+
+        <!-- 评论或回复内容 -->
+        <div class="content">
+          <span
+            v-if="comment.reply"
+            class="reply"
+          >回复
+            <span class="reply-target" :title="comment.reply.email">{{
+              comment.reply.name + ':'
+            }}</span>
+          </span>
+          {{ comment.content }}
+          <div v-if="comment.imgSrc" class="img-box">
+            <img
+              :src="comment.imgSrc || ''"
+              @error="(e) => e.target.classList.add('error')"
+            >
+          </div>
+        </div>
+
+        <!-- 评论或回复时间及操作 -->
+        <div class="reply-stat">
+          <time
+            :title="formatTime(comment.createAt, true)"
+            :datetime="comment.createAt"
+          >{{ formatTime(comment.createAt) }}</time>
+          <div
+            v-if="comment.user.author === true"
+            class="delete"
+            @click.stop="$emit('comment-delete', { id, comment, parent })"
+          >
+            <span>·</span>删除
+          </div>
+          <div class="action-box">
+            <div
+              class="like-action action"
+              :class="{ active: comment.liked }"
+              @click.stop="$emit('comment-like', { id, comment })"
+            >
+              <svg
+                aria-hidden="true"
+                viewBox="0 0 20 20"
+                class="icon like-icon"
+              >
+                <g fill="none" fill-rule="evenodd">
+                  <path d="M0 0h20v20H0z" />
+                  <path
+                    :stroke="comment.liked ? '#37C700' : '#8A93A0'"
+                    stroke-linejoin="round"
+                    :fill="comment.liked ? '#37c700' : 'none'"
+                    d="M4.58 8.25V17h-1.4C2.53 17 2 16.382 2 15.624V9.735c0-.79.552-1.485 1.18-1.485h1.4zM11.322 2c1.011.019 1.614.833 1.823 1.235.382.735.392 1.946.13 2.724-.236.704-.785 1.629-.785 1.629h4.11c.434 0 .838.206 1.107.563.273.365.363.84.24 1.272l-1.86 6.513A1.425 1.425 0 0 1 14.724 17H6.645V7.898C8.502 7.51 9.643 4.59 9.852 3.249A1.47 1.47 0 0 1 11.322 2z"
+                  />
+                </g>
+              </svg>
+              <span v-show="comment.likes" class="action-title">{{
+                comment.likes
+              }}</span>
+            </div>
+            <div
+              class="comment-action action"
+              @mousedown.prevent="$emit('comment-reply', id)"
+              @click.prevent
+            >
+              <svg
+                aria-hidden="true"
+                viewBox="0 0 20 20"
+                class="icon comment-icon"
+              >
+                <g fill="none" fill-rule="evenodd">
+                  <path d="M0 0h20v20H0z" />
+                  <path
+                    stroke="#8A93A0"
+                    stroke-linejoin="round"
+                    d="M10 17c-4.142 0-7.5-2.91-7.5-6.5S5.858 4 10 4c4.142 0 7.5 2.91 7.5 6.5 0 1.416-.522 2.726-1.41 3.794-.129.156.41 3.206.41 3.206l-3.265-1.134c-.998.369-2.077.634-3.235.634z"
+                  />
+                </g>
+              </svg>
+              <span class="action-title">回复</span>
+            </div>
+          </div>
+        </div>
+
+        <!-- 评论表单组件 -->
+        <slot :id="id" />
+
+        <!-- 回复列表 -->
+        <slot name="subList" :parentId="id" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'CommentItem',
+  props: {
+    comment: {
+      type: Object,
+      default: () => {},
+      required: true
+    },
+    id: {
+      type: [String, Number],
+      required: true
+    },
+    parent: {
+      type: Object,
+      default: () => {}
+    },
+    user: {
+      type: Object,
+      default: () => {}
+    }
+  },
+  computed: {
+    isSubComment() {
+      return this.id.split('-').length === 3
+    }
+  },
+  methods: {
+    formatTime(time, local = false) {
+      const d = new Date(time)
+
+      if (local) {
+        return d.toString()
+      }
+
+      const now = Date.now()
+      const diff = (now - d) / 1000
+
+      switch (true) {
+        case diff < 30:
+          return '刚刚'
+        case diff < 3600:
+          return Math.ceil(diff / 60) + '分钟前'
+        case diff < 3600 * 24:
+          return Math.ceil(diff / 3600) + '小时前'
+        case diff < 3600 * 24 * 30:
+          return Math.floor(diff / 3600 / 24) + '天前'
+        case diff < 3600 * 24 * 365:
+          return Math.floor(diff / 3600 / 24 / 30) + '月前'
+        default:
+          return Math.floor(diff / 3600 / 24 / 365) + '年前'
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.comment-item {
+  margin-bottom: 1.0664rem;
+  &:not(:last-child) {
+    .content-box {
+      border-bottom: 1px solid #f1f1f1;
+    }
+  }
+  &:hover {
+    .comment .reply-stat .delete {
+      visibility: visible;
+    }
+  }
+  .comment {
+    display: flex;
+    .content-box {
+      margin-left: 0.6664rem;
+      flex: 1 1 auto;
+      &.focus {
+        padding-bottom: 0.4rem;
+      }
+      .meta-box {
+        display: flex;
+        align-items: center;
+        font-size: 0.8664rem;
+        line-height: 1.25;
+        white-space: nowrap;
+        .user-popover-box {
+          cursor: pointer;
+        }
+      }
+      .content {
+        margin-top: 0.44rem;
+        font-size: 0.8664rem;
+        line-height: 1.4664rem;
+        white-space: pre-line;
+        word-break: break-all;
+        color: #505050;
+        overflow: hidden;
+        .img-box {
+          margin-top: 0.5rem;
+          img {
+            max-width: 100%;
+            max-height: 20rem;
+            object-fit: cover;
+          }
+        }
+        .reply {
+          vertical-align: top;
+        }
+        .reply-target {
+          cursor: pointer;
+          color: #406599;
+        }
+      }
+    }
+    .reply-stat {
+      display: flex;
+      margin-top: 7px;
+      font-weight: 400;
+      time,
+      .delete {
+        font-size: 0.8664rem;
+        color: #8a9aa9;
+      }
+      .delete {
+        visibility: hidden;
+        cursor: pointer;
+        span {
+          margin: 0 0.2rem;
+        }
+      }
+      .action-box {
+        flex: 0 0 auto;
+        display: flex;
+        justify-content: space-between;
+        margin-left: auto;
+        min-width: 7.04rem;
+        color: #8a93a0;
+        user-select: none;
+        .action {
+          display: flex;
+          align-items: center;
+          margin-left: 0.4rem;
+          cursor: pointer;
+          &:hover {
+            opacity: 0.8;
+          }
+          &.active {
+            color: #37c700;
+          }
+          .icon {
+            min-width: 16.5px;
+            min-height: 16.5px;
+            width: 0.8rem;
+            height: 0.8rem;
+          }
+          .action-title {
+            margin-left: 0.2rem;
+            font-size: 0.8rem;
+          }
+        }
+      }
+    }
+  }
+}
+
+.sub-comment-list {
+  margin: 0.8rem 0;
+  padding: 0 0 0 0.8rem;
+  background-color: #fafbfc;
+  border-radius: 3px;
+  .comment-item {
+    margin-bottom: 0;
+    &:last-child .content-box {
+      border-bottom: none;
+    }
+    .comment {
+      position: relative;
+      padding: 0.8rem 0 0;
+
+      .content-box {
+        margin-right: 0.8rem;
+        padding-bottom: 0.8rem;
+      }
+    }
+  }
+}
+</style>

+ 12 - 0
src/components/comment/components/CommentList.js

@@ -0,0 +1,12 @@
+export default {
+  props: {
+    sub: {
+      type: Boolean,
+      default: false
+    }
+  },
+  render(h) {
+    const className = this.sub ? 'sub-comment-list' : 'comment-list'
+    return h('div', { class: className }, this.$slots.default)
+  }
+}

+ 264 - 0
src/components/comment/components/EmojiSelector.vue

@@ -0,0 +1,264 @@
+<template>
+  <div class="emoji-selector" @mousedown.prevent @mousedown.stop>
+    <div class="triangle" />
+    <div class="emoji-content">
+      <div class="category">
+        <div
+          v-for="(item, i) in currentEmojis"
+          :key="`emoji-${i}`"
+          class="item"
+          @click="$emit('choose', item)"
+        >
+          {{ item }}
+        </div>
+      </div>
+    </div>
+    <div class="next-page">
+      <div
+        v-for="cat in Object.keys(emojis)"
+        :key="cat"
+        :class="{ active: currentCat === cat }"
+        @click="currentCat = cat"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'EmojiSelector',
+  data() {
+    return {
+      currentCat: 'FrequentlyUsed',
+      emojis: {
+        FrequentlyUsed: [
+          '😃',
+          '😘',
+          '😂',
+          '😳',
+          '😍',
+          '👏',
+          '👍',
+          '👎',
+          '😁',
+          '😉',
+          '😠',
+          '😞',
+          '😥',
+          '😭',
+          '😝',
+          '😡',
+          '❤',
+          '💔',
+          '😣',
+          '😔',
+          '😄',
+          '😷',
+          '😚',
+          '😓',
+          '😊',
+          '😢',
+          '😜',
+          '😨',
+          '😰',
+          '😲',
+          '😏',
+          '😱',
+          '😪',
+          '😖',
+          '😌',
+          '😒',
+          '👻',
+          '🎅',
+          '👧',
+          '👦',
+          '👩',
+          '👨',
+          '🐶',
+          '🐱',
+          '👊',
+          '✊',
+          '✌',
+          '💪',
+          '👆',
+          '👇',
+          '👉',
+          '👈',
+          '👌',
+          '💩'
+        ],
+        Symbols0: [
+          '🤗',
+          '😎',
+          '🤓',
+          '👩‍💻',
+          '👨‍💻',
+          '🙄',
+          '😭',
+          '😨',
+          '🤪',
+          '🎉',
+          '🤔',
+          '🐵',
+          '😇',
+          '🤬',
+          '🐈',
+          '😹',
+          '🙀',
+          '🇨🇳',
+          '👮',
+          '🐕',
+          '✅',
+          '👋',
+          '🔥',
+          '🐛',
+          '🍉',
+          '👽',
+          '🤖',
+          '⌚',
+          '🤝',
+          '🏳️‍🌈',
+          '🚩',
+          '💤',
+          '®',
+          '©',
+          '💯',
+          '™',
+          '💻',
+          '📅',
+          '📌',
+          '✉',
+          '⌨',
+          '📗',
+          '🤳',
+          '🛌',
+          '🎣',
+          '🎨',
+          '🎧',
+          '🎸',
+          '🎤',
+          '🏸',
+          '🏀',
+          '⚽',
+          '🎮',
+          '🏊'
+        ],
+        Symbols1: [
+          '🍗',
+          '🦄',
+          '🔞',
+          '🙏',
+          '☀',
+          '🌙',
+          '🌟',
+          '⚡',
+          '☁',
+          '☔',
+          '🍁',
+          '🌻',
+          '🍃',
+          '👗',
+          '🎀',
+          '👄',
+          '🌹',
+          '☕',
+          '🎂',
+          '🕙',
+          '🍺',
+          '🔍',
+          '📱',
+          '🏠',
+          '🚗',
+          '🎁',
+          '⚽',
+          '💣',
+          '💎',
+          '💊',
+          '🤮',
+          '🏆',
+          '👿'
+        ]
+      }
+    }
+  },
+  computed: {
+    currentEmojis() {
+      return this.emojis[this.currentCat]
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.emoji-selector {
+  padding: 0.8rem;
+  position: absolute;
+  top: 2.24rem;
+  z-index: 1;
+  bottom: 0;
+  width: 19rem;
+  height: 14rem;
+  border-radius: 2px;
+  background-color: #fff;
+  box-shadow: 0 5px 18px 0 rgba(0, 0, 0, 0.16);
+  box-sizing: content-box;
+  .triangle {
+    position: absolute;
+    top: -0.56rem;
+    left: 14%;
+    width: 0;
+    height: 0;
+    transform: translate(-50%, -50%);
+    border: 0.64rem solid transparent;
+    border-bottom-color: #fff;
+  }
+  .emoji-content {
+    height: 100%;
+    overflow: auto;
+    margin-bottom: 10px;
+    .category {
+      max-width: 19rem;
+      max-height: 13rem;
+      display: flex;
+      flex-wrap: wrap;
+      align-items: center;
+      justify-content: flex-start;
+      overflow: hidden;
+      .item {
+        width: calc(19rem / 9);
+        height: calc(13rem / 6);
+        font-size: 1.25rem;
+        text-align: center;
+        line-height: calc(10rem / 6);
+        cursor: pointer;
+        &:hover {
+          font-size: 1.6rem;
+        }
+      }
+    }
+  }
+  .next-page {
+    list-style: none;
+    position: absolute;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    bottom: 10px;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    div {
+      list-style: none;
+      margin: 0 5px;
+      width: 6px;
+      height: 6px;
+      border-radius: 100%;
+      background-color: #f0f0f0;
+      cursor: pointer;
+      &.active {
+        cursor: default;
+        background-color: #d8d8d8;
+      }
+    }
+  }
+}
+</style>

+ 11 - 0
src/components/comment/index.js

@@ -0,0 +1,11 @@
+import JuejinComment from './index.vue'
+
+JuejinComment.install = function(Vue) {
+  Vue.component(JuejinComment.name, JuejinComment)
+}
+
+if (typeof window !== 'undefined' && window.Vue) {
+  JuejinComment.install(window.Vue)
+}
+
+export default JuejinComment

+ 561 - 0
src/components/comment/index.vue

@@ -0,0 +1,561 @@
+<template>
+  <div id="comment" ref="comment">
+    <!-- 顶部评论表单 -->
+    <comment-form :upload-img="uploadImg" @form-submit="formSubmit">
+      <img
+        class="avatar"
+        :src="user.avatar || ''"
+        @error="(e) => e.target.classList.add('error')"
+      >
+    </comment-form>
+
+    <!-- 底部评论列表 -->
+    <comment-list v-if="cacheData.length > 0" ref="comment-list">
+      <!-- 单条评论 -->
+      <comment-item
+        v-for="(comment, i) in cacheData"
+        :id="`comment-${i}`"
+        :key="`comment-${i}`"
+        :ref="`comment-${i}`"
+        :user="user"
+        :comment="comment"
+        @comment-reply="hasForm"
+        @comment-like="handleCommentLike"
+        @comment-delete="handleCommentDelete"
+      >
+        <!-- 回复表单 -->
+        <template #default="{ id }">
+          <comment-form
+            v-if="forms.includes(id)"
+            :id="id"
+            :parent="comment"
+            :placeholder="`回复${comment.user.name}...`"
+            :upload-img="uploadImg"
+            @form-submit="formSubmit"
+            @form-delete="deleteForm"
+          />
+        </template>
+
+        <!-- 单条评论下的回复列表 -->
+        <template #subList="{ parentId }">
+          <div>
+            <comment-list sub>
+              <!-- 单条回复 -->
+              <comment-item
+                v-for="(child, j) in comment.children"
+                :id="`${parentId}-${j}`"
+                :key="`${parentId}-${j}`"
+                :ref="`${parentId}-${j}`"
+                :comment="child"
+                :user="user"
+                :parent="comment"
+                @comment-reply="hasForm"
+                @comment-like="handleCommentLike"
+                @comment-delete="handleCommentDelete"
+              >
+                <!-- 单条回复的回复表单 -->
+                <comment-form
+                  v-if="forms.includes(`${parentId}-${j}`)"
+                  :id="`${parentId}-${j}`"
+                  :comment="child"
+                  :parent="comment"
+                  :placeholder="`回复${child.user && child.user.name}...`"
+                  :upload-img="uploadImg"
+                  @form-delete="deleteForm"
+                  @form-submit="formSubmit"
+                />
+              </comment-item>
+            </comment-list>
+            <el-pagination
+              :small="screenWidth <= 768"
+              hide-on-single-page
+              layout="prev, pager, next"
+              :page-size="pageSize"
+              :current-page="currentPage"
+              :total="comment.total"
+              @current-change="(val) => handleCurrentChange(comment, val)"
+            />
+          </div>
+        </template>
+      </comment-item>
+    </comment-list>
+  </div>
+</template>
+
+<script>
+import CommentForm from './components/CommentForm'
+import CommentList from './components/CommentList'
+import CommentItem from './components/CommentItem'
+import { getChildComment } from '@/api/comment'
+
+export default {
+  name: 'JuejinComment',
+  components: { CommentList, CommentItem, CommentForm },
+  inheritAttrs: false,
+  // 接收父组件通过 v-model 绑定的值
+  model: {
+    prop: 'videoComments',
+    event: 'input'
+  },
+  props: {
+    /* 数据 */
+    // model 中的 videoComments prop
+    videoComments: {
+      type: Array,
+      default: () => [],
+      required: true
+    },
+    /* 当前用户 */
+    user: {
+      type: Object,
+      default: () => {},
+      required: true
+    },
+    /* 配置属性 */
+    props: {
+      type: Object,
+      default: () => {}
+    },
+    /* 提交表单前事件 */
+    beforeSubmit: {
+      type: Function,
+      required: true
+    },
+    /* 执行点赞前事件 */
+    beforeLike: {
+      type: Function,
+      required: true
+    },
+    /* 执行删除前事件 */
+    beforeDelete: {
+      type: Function,
+      required: true
+    },
+    /* 上传图片 */
+    uploadImg: {
+      type: Function,
+      required: true
+    }
+  },
+  data() {
+    return {
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      currentPage: 1,
+      pageSize: 10,
+      totalSize: 20,
+      dataList: [],
+      // ********************************************************************/
+      forms: [], // 显示在视图上的所有表单 id
+      cacheData: [],
+      length: 0
+    }
+  },
+  computed: {
+    computedProps({ props }) {
+      if (!props) return null
+      const entries = Object.entries(props)
+      return entries.length > 0 ? entries : null
+    }
+  },
+  watch: {
+    videoComments: {
+      immediate: true,
+      handler(value) {
+        // 数据发生变化时加载新数据
+        this.processVideoComments()
+      }
+    }
+  },
+  mounted() {
+    // 当窗口宽度改变时获取屏幕宽度
+    window.onresize = () => {
+      return () => {
+        window.screenWidth = document.body.clientWidth
+        this.screenWidth = window.screenWidth
+      }
+    }
+  },
+  created() {
+    // 监听并执行一次
+    const cancel = this.$watch('data', () => {
+      this.processData()
+      cancel && cancel()
+    })
+  },
+  methods: {
+    /**
+     * 处理初始数据
+     */
+    processData() {
+      this.cacheData = this.data.map(this.comparePropsAndValues)
+    },
+    processVideoComments() {
+      this.cacheData = this.videoComments.map(this.comparePropsAndValues)
+      // console.log(this.videoComments)
+    },
+    /** 对比和检查每条评论对象字段值 */
+    comparePropsAndValues(comment) {
+      // 初始对象
+      const originObj = {
+        id: '',
+        content: '',
+        imgSrc: '',
+        children: [],
+        likes: 0,
+        liked: false,
+        reply: null,
+        createAt: null,
+        total: 0,
+        user: {
+          userId: -1,
+          name: '',
+          avatar: '',
+          author: false
+        }
+      }
+      // 赋值
+      for (const key in originObj) {
+        originObj[key] =
+          comment[this.props[key]] || comment[key] || originObj[key]
+
+        // 校验
+        this.validate({ key, value: originObj[key] })
+      }
+
+      if (originObj.children.length > 0) {
+        originObj.children = originObj.children.map(this.comparePropsAndValues)
+      }
+
+      return originObj
+    },
+
+    /** 校验数据 */
+    validate({ key, value }) {
+      const map = {
+        user: {
+          validate: function(v) {
+            return (
+              (typeof v !== 'object' || JSON.stringify(v) === '{}') &&
+              this.message
+            )
+          },
+          message: 'User must be an object with props.'
+        },
+        reply: {
+          validate: function(v) {
+            return typeof v !== 'object' && this.message
+          },
+          message: 'Reply must be an object'
+        },
+        children: {
+          validate: function(v) {
+            return !Array.isArray(v) && this.message
+          },
+          message: 'Children must be an array'
+        },
+        createAt: {
+          validate: function() {
+            return new Date(value).toString() === 'Invalid Date' && this.message
+          },
+          message: 'CreateAt is not a valid date.'
+        }
+      }
+
+      const target = map[key]
+      if (!target) return
+
+      const res = target.validate(value)
+      if (res) {
+        throw new Error(`validate(): ${res}`)
+      }
+    },
+
+    /**
+     * 将更新后的数组中的对象数据转换为初始对象结构
+     */
+    transformToOriginObj(comment) {
+      try {
+        const _comment = JSON.parse(JSON.stringify(comment))
+
+        if (_comment.children && _comment.children.length > 0) {
+          _comment.children = _comment.children.map(this.transformToOriginObj)
+        }
+
+        // 返回 props 中自定义的字段名
+        if (!this.computedProps) return _comment
+
+        for (const [key, value] of this.computedProps) {
+          if (key !== value && Object.hasOwnProperty.call(_comment, key)) {
+            _comment[value] = JSON.parse(JSON.stringify(_comment[key]))
+            delete _comment[key]
+          }
+        }
+
+        return _comment
+      } catch (e) {
+        console.error(e)
+      }
+    },
+
+    /**
+     * 判断是否已存在该id的表单,存在删除该表单,不存在则新增该表单,并触发其他表单blur事件
+     */
+    hasForm(id) {
+      this.forms.includes(id) ? this.deleteForm(id) : this.addForm(id)
+      this.broadcastBlur(this.$refs['comment-list'].$children, id)
+    },
+
+    /**
+     * 增加新表单
+     */
+    addForm(id) {
+      this.forms.push(id)
+      // this.scrollIntoView(`${id}-form`)
+    },
+
+    /** 删除表单 */
+    deleteForm(id) {
+      const index = this.forms.indexOf(id)
+      index > -1 && this.forms.splice(index, 1)
+    },
+
+    /**
+     * 评论或回复
+     */
+    async formSubmit({
+      newComment: { id, callback, ...params },
+      parent = null
+    }) {
+      const _params = Object.assign(params, { user: this.user })
+
+      // 等待外部提交事件执行
+      if (typeof this.beforeSubmit === 'function') {
+        try {
+          const data = this.transformToOriginObj(_params)
+
+          const add = (data) => {
+            this.addComment(id, this.comparePropsAndValues(data))
+            callback()
+          }
+
+          await this.beforeSubmit(data, parent, add)
+        } catch (e) {
+          console.error(e)
+        }
+      }
+    },
+
+    async handleCommentLike({ id, comment: { children, ...params }}) {
+      const _params = Object.assign(params, { user: this.user })
+      if (typeof this.beforeLike === 'function') {
+        try {
+          await this.beforeLike(this.transformToOriginObj(_params))
+
+          this.storeLikes(id)
+        } catch (e) {
+          console.error(e)
+        }
+      }
+    },
+
+    /**
+     * 删除评论或回复
+     */
+    async handleCommentDelete({ id, comment, parent = null }) {
+      if (typeof this.beforeDelete === 'function') {
+        try {
+          const data = this.transformToOriginObj(comment)
+          await this.beforeDelete(data, parent)
+
+          this.deleteComment(id)
+        } catch (e) {
+          console.error(e)
+        }
+      }
+    },
+
+    /**
+     * 保存点赞
+     */
+    storeLikes(id) {
+      const { commentIndex, replyIndex } = this.getIndex(id)
+
+      let comment = this.cacheData[commentIndex]
+
+      if (!isNaN(replyIndex)) {
+        comment = comment.children[replyIndex]
+      }
+
+      comment.liked = !comment.liked
+
+      if (comment.likes) {
+        comment.liked ? comment.likes++ : comment.likes--
+      } else {
+        comment.likes = 1
+      }
+
+      const data = this.cacheData.map(this.transformToOriginObj)
+      this.$emit('input', data)
+    },
+
+    /**
+     * 存储新评论或回复
+     */
+    addComment(id, rawData) {
+      const { commentIndex } = this.getIndex(id)
+
+      // 更新视图
+      if (commentIndex === 'root') {
+        this.cacheData.push(rawData)
+      } else {
+        const comment = this.cacheData[commentIndex]
+        comment.children.push(rawData)
+      }
+
+      // 滚动至可见视图上
+      const signal =
+        commentIndex === 'root'
+          ? this.cacheData.length - 1
+          : `${commentIndex}-${
+            this.cacheData[commentIndex].children.length - 1
+          }`
+      this.scrollIntoView(`comment-${signal}`)
+
+      // 更新外部数据
+      const data = this.cacheData.map(this.transformToOriginObj)
+      this.$emit('input', data)
+    },
+
+    /**
+     * 删除评论或回复
+     */
+    deleteComment(id) {
+      const { commentIndex, replyIndex } = this.getIndex(id)
+
+      this.cacheData = this.cacheData.filter((c, i) => {
+        if (isNaN(replyIndex)) {
+          return i !== commentIndex
+        } else {
+          c.children = c.children.filter((r, j) => j !== replyIndex)
+          return c
+        }
+      })
+
+      const data = this.cacheData.map(this.transformToOriginObj)
+      this.$emit('input', data)
+    },
+
+    /**
+     * 向下递归触发表单blur事件
+     */
+    broadcastBlur(target, id) {
+      if (id && target.id === id) return
+
+      if (Array.isArray(target)) {
+        target.map((c) => this.broadcastBlur(c, id))
+      } else {
+        const children = target.$children
+        children && this.broadcastBlur(children, id)
+
+        const richInput = target.$refs['rich-input']
+        richInput && richInput.blur()
+      }
+    },
+
+    /**
+     * 从id中提取出序号
+     */
+    getIndex(id) {
+      const [, c, r] = id.split('-')
+      return { commentIndex: c === 'root' ? c : +c, replyIndex: +r }
+    },
+
+    /**
+     * 将子组件滚动到视图可见区域
+     */
+    scrollIntoView(ref) {
+      this.$nextTick(() => {
+        this.$refs[ref][0].$el.scrollIntoView(false)
+      })
+    },
+    handleCurrentChange(comment, currentPage) {
+      this.currentPage = currentPage
+      this.getChildCommentWrapper(comment, this.currentPage)
+    },
+    getChildCommentWrapper(comment, pageNumber) {
+      getChildComment(comment.id, pageNumber).then(resp => {
+        if (resp.code === 0) {
+          comment.children = resp.data.list
+        } else {
+          console.error(resp.msg)
+        }
+      }).catch(error => {
+        console.error(error.message)
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+#comment {
+  // border-top: 1px solid #ebebeb;
+  padding-top: 1.0664rem;
+  & > .comment-form {
+    margin: 0 1.3328rem 1.0664rem;
+  }
+  & > .comment-list {
+    margin: 0 1.3328rem 0 5.2rem;
+    background-color: #fff;
+  }
+
+  ::v-deep {
+    img {
+      user-select: none;
+      -webkit-user-drag: none;
+      &.avatar {
+        width: 2.1336rem;
+        height: 2.1336rem;
+        border-radius: 50%;
+        cursor: pointer;
+      }
+      &.error {
+        display: inline-block;
+        transform: scale(0.5);
+        content: '';
+        color: transparent;
+        &::before {
+          content: '';
+          position: absolute;
+          left: 0;
+          top: 0;
+          width: 100%;
+          height: 100%;
+          border-radius: 50%;
+          border: 1px solid #e7e7e7;
+          box-sizing: border-box;
+          transform: scale(2);
+          background: #f5f5f5
+            url("data:image/svg+xml,%3Csvg class='icon' viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cpath d='M304.128 456.192c48.64 0 88.064-39.424 88.064-88.064s-39.424-88.064-88.064-88.064-88.064 39.424-88.064 88.064 39.424 88.064 88.064 88.064zm0-116.224c15.36 0 28.16 12.288 28.16 28.16s-12.288 28.16-28.16 28.16-28.16-12.288-28.16-28.16 12.288-28.16 28.16-28.16z' fill='%23e6e6e6'/%3E%3Cpath d='M887.296 159.744H136.704C96.768 159.744 64 192 64 232.448v559.104c0 39.936 32.256 72.704 72.704 72.704h198.144L500.224 688.64l-36.352-222.72 162.304-130.56-61.44 143.872 92.672 214.016-105.472 171.008h335.36C927.232 864.256 960 832 960 791.552V232.448c0-39.936-32.256-72.704-72.704-72.704zm-138.752 71.68v.512H857.6c16.384 0 30.208 13.312 30.208 30.208v399.872L673.28 408.064l75.264-176.64zM304.64 792.064H165.888c-16.384 0-30.208-13.312-30.208-30.208v-9.728l138.752-164.352 104.96 124.416-74.752 79.872zm81.92-355.84l37.376 228.864-.512.512-142.848-169.984c-3.072-3.584-9.216-3.584-12.288 0L135.68 652.8V262.144c0-16.384 13.312-30.208 30.208-30.208h474.624L386.56 436.224zm501.248 325.632c0 16.896-13.312 30.208-29.696 30.208H680.96l57.344-93.184-87.552-202.24 7.168-7.68 229.888 272.896z' fill='%23e6e6e6'/%3E%3C/svg%3E")
+            no-repeat center / 50% 50%;
+        }
+      }
+    }
+  }
+}
+
+@media screen and (max-width: 600px) {
+  #comment {
+    & > .comment-list {
+      margin: 0 1.6rem;
+    }
+    & > .comment-form {
+      margin: 1rem 1.6rem;
+    }
+    & > ::v-deep .comment-root .avatar-box {
+      display: none;
+    }
+  }
+}
+</style>

+ 49 - 0
src/components/layout/FooterBar.vue

@@ -0,0 +1,49 @@
+<template>
+  <div id="footer-bar">
+    <img class="logo" src="@/assets/img/icon/logo.png" alt="">
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'FooterBar'
+}
+</script>
+
+<style scoped>
+#footer-bar {
+  position: relative;
+  bottom: 0;
+  left: 0;
+  right: 0;
+
+  padding-left: 6%;
+  padding-right: 6%;
+  margin-top: 30px;
+  height: 60px;
+  border-top: 2px solid rgba(34, 36, 38, 0.15);
+  box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15);
+  text-align: center;
+}
+
+.content {
+  padding-top: 10px;
+  font-size: 14px;
+}
+.logo {
+  width: 30px;
+  cursor: pointer;
+}
+.author,
+.statement {
+  padding-top: 5px;
+}
+
+.weixin {
+  width: 30px;
+  cursor: pointer;
+}
+.er {
+  width: 150px;
+}
+</style>

+ 71 - 0
src/components/layout/LoginBar.vue

@@ -0,0 +1,71 @@
+<template>
+  <el-row class="el-menu-demo">
+    <el-col :md="2">
+      <ul role="menubar" class="el-menu--horizontal el-menu">
+        <li role="menuitem" class="el-menu-item">
+          <a href="/" style="color: #007bff;text-decoration-line: none">
+            <img src="@/assets/img/icon/logo.png" class="logo" alt="img">
+            tnb
+          </a>
+        </li>
+      </ul>
+    </el-col>
+    <el-col :md="8">
+      <el-menu
+        mode="horizontal"
+      >
+        <el-menu-item index="1">
+          <a href="/video" style="text-decoration-line: none">
+            <span style="color: #007bff">视频</span>
+          </a>
+        </el-menu-item>
+        <el-menu-item index="2">
+          <a href="/shortvideo" style="text-decoration-line: none">
+            <span style="color: #007bff">短视频</span>
+          </a>
+        </el-menu-item>
+        <el-menu-item index="5">
+          <a href="/discover" style="text-decoration-line: none">
+            <span style="color: #007bff">发现</span>
+          </a>
+        </el-menu-item>
+      </el-menu>
+    </el-col>
+    <el-col :md="8">
+      <ul class="el-menu--horizontal el-menu">
+        <li class="el-menu-item">
+        </li>
+      </ul>
+    </el-col>
+    <el-col :md="6">
+      <ul class="el-menu--horizontal el-menu">
+        <li class="el-menu-item">
+        </li>
+      </ul>
+    </el-col>
+  </el-row>
+</template>
+
+<script>
+export default {
+  name: 'LoginBar',
+  data() {
+    return {
+    }
+  },
+  created() {
+  },
+  methods: {
+  }
+}
+</script>
+
+<style scoped>
+@media screen and (max-width: 768px) {
+}
+
+.logo {
+  width: 30px;
+  position: relative;
+}
+</style>

+ 371 - 0
src/components/layout/NavBar.vue

@@ -0,0 +1,371 @@
+<template>
+  <el-row class="el-menu-demo">
+    <el-col :md="2">
+      <ul role="menubar" class="el-menu--horizontal el-menu">
+        <li role="menuitem" class="el-menu-item">
+          <a href="/" style="color: #007bff;text-decoration-line: none">
+            <img src="@/assets/img/icon/logo.png" class="logo" alt="img">
+            tnb
+          </a>
+        </li>
+      </ul>
+    </el-col>
+    <el-col :md="8">
+      <el-menu
+        mode="horizontal"
+      >
+        <el-menu-item index="1">
+          <a href="/video" style="text-decoration-line: none">
+            <span style="color: #007bff">视频</span>
+          </a>
+        </el-menu-item>
+        <el-menu-item index="2">
+          <a href="/shortvideo" style="text-decoration-line: none">
+            <span style="color: #007bff">短视频</span>
+          </a>
+        </el-menu-item>
+        <el-menu-item index="5">
+          <a href="/discover" style="text-decoration-line: none">
+            <span style="color: #007bff">发现</span>
+          </a>
+        </el-menu-item>
+      </el-menu>
+    </el-col>
+    <el-col :md="8">
+      <ul class="el-menu--horizontal el-menu">
+        <li class="el-menu-item">
+          <el-autocomplete
+            v-model="keyword"
+            :fetch-suggestions="querySearchAsync"
+            :placeholder="placeholder"
+            clearable
+            suffix-icon="el-icon-search"
+            size="medium"
+            :debounce="1000"
+            @keyup.enter.native="onSearch"
+            @select="onSearch"
+          />
+        </li>
+      </ul>
+    </el-col>
+    <el-col :md="6">
+      <ul class="el-menu--horizontal el-menu">
+        <li class="el-menu-item">
+          <el-dropdown v-if="user">
+            <img
+              :src="user.avatarUrl"
+              class="el-avatar--circle el-avatar--medium"
+              alt=""
+            >
+            <el-dropdown-menu v-if="user" slot="dropdown" class="iconsize">
+              <el-dropdown-item
+                icon="el-icon-files"
+                class="size"
+                @click.native="goToDisk"
+              >我的网盘</el-dropdown-item>
+              <el-dropdown-item
+                icon="el-icon-user-solid"
+                class="size"
+                @click.native="goToProfile"
+              >我的帐号</el-dropdown-item>
+              <el-dropdown-item
+                icon="el-icon-s-home"
+                class="size"
+                @click.native="goToHome"
+              >我的主页</el-dropdown-item>
+              <el-dropdown-item
+                icon="el-icon-s-data"
+                class="size"
+                @click.native="goToPost"
+              >我的稿件
+              </el-dropdown-item>
+              <el-dropdown-item
+                icon="el-icon-star-on"
+                class="size"
+                @click.native="goToFavlist"
+              >收藏夹
+              </el-dropdown-item>
+              <el-dropdown-item
+                icon="el-icon-video-camera-solid"
+                class="size"
+                @click.native="goToHistory"
+              >历史记录
+              </el-dropdown-item>
+              <el-dropdown-item
+                icon="el-icon-error"
+                class="size"
+                @click.native="goToLogout"
+              >退出</el-dropdown-item>
+            </el-dropdown-menu>
+          </el-dropdown>
+          <span
+            v-else
+            style="color: #007bff"
+            @click="login"
+          >登录</span>
+        </li>
+        <li class="el-menu-item" @click="goToTimeline">
+          <el-badge v-if="statusCount > 0" class="item" :value="statusCount" :max="99">
+            <span class="el-icon-view" style="color: #007bff">状态</span>
+          </el-badge>
+          <span v-else class="el-icon-view" style="color: #007bff">状态</span>
+        </li>
+        <li class="el-menu-item" @click="goToMessage">
+          <el-badge v-if="msgCount > 0" class="item" :value="msgCount" :max="99">
+            <span class="el-icon-bell" style="color: #007bff">消息</span>
+          </el-badge>
+          <span v-else class="el-icon-bell" style="color: #007bff">消息</span>
+        </li>
+        <li class="el-menu-item">
+          <el-button size="mini" type="upload" icon="el-icon-upload" @click="goToPublish">投稿</el-button>
+        </li>
+      </ul>
+    </el-col>
+
+    <!--登录弹窗-->
+    <el-dialog
+      title="用户登录"
+      append-to-body
+      :visible.sync="dialogVisible"
+      width="30%"
+      center
+    >
+      <el-form ref="form" :model="userLogin">
+        <el-form-item label="帐号">
+          <el-input
+            v-model="userLogin.principal"
+            placeholder="请输入手机号或邮箱"
+            style="width: 70%; padding-right: 2px"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="密码">
+          <el-input
+            v-model="userLogin.credential"
+            placeholder="请输入验证码"
+            style="width: 45%; padding-right: 2px"
+          />
+          <el-button :disabled="isBtn" @click="fetchVerifyCode">{{ code }}</el-button>
+        </el-form-item>
+        <el-form-item label="图形验证码" label-width="90px">
+          <el-image :src="captchaCode" @click="getCaptcha" />
+          <el-input
+            v-model="userLogin.captchaCode"
+            placeholder="请输入图形验证码"
+            style="width: 45%; padding-right: 2px"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button
+            type="primary"
+            :loading="isLoading"
+            @click.native="loginBtn"
+          >登 录</el-button>
+          <el-button type="plain" @click="register">注册/忘记密码?</el-button>
+        </el-form-item>
+      </el-form>
+    </el-dialog>
+  </el-row>
+</template>
+
+<script>
+import { userMixin } from 'assets/js/mixin'
+import { keywordSuggest } from '@/api/search'
+import { getAuthedUser } from '@/utils/auth'
+
+export default {
+  name: 'NavBar',
+  mixins: [userMixin],
+  data() {
+    return {
+      user: null,
+      activeIndex: '1',
+      restaurants: [],
+      placeholder: '想要搜点神马呢',
+      keyword: '',
+      statusCount: 0,
+      msgCount: 0
+    }
+  },
+  created() {
+    /* const userdata = Vue.$cookies.get('USERDATA')
+    const userId = userdata.split(':')[0]*/
+    const userInfo = getAuthedUser()
+    if (userInfo !== null) {
+      this.user = userInfo
+    }
+  },
+  methods: {
+    handleSelect(key, keyPath) {
+      console.log(key, keyPath)
+    },
+    // ****************************************************************************************************************
+    // 重点:当框中的改变时触发该方法,elementui自动设置了防抖,参见debounce属性
+    // queryString 为输入框中的值。cb为返回显示列表的回调函数
+    querySearchAsync(queryString, cb) {
+      if (queryString === '') {
+        return
+      }
+
+      setTimeout(() => {
+        keywordSuggest(queryString).then(res => {
+          if (res.code === 0) {
+            this.restaurants = res.data.map((item) => {
+              return {
+                value: item.keyword,
+                rank: 1
+              }
+            })
+
+            // 如果 cb 返回一个空数组, 那么模糊搜索输入建议的下拉选项会因为 length 为 0 而消失
+            // cb([])
+            cb(this.restaurants)
+            // eslint-disable-next-line no-empty
+          } else {
+          }
+        })
+      }, 500)
+    },
+    // select 事件或 enter 键事件
+    onSearch() {
+      console.log('回车事件')
+      // 正则去空格
+      if (this.keyword.replace(/\s*/g, '')) {
+        this.toSearchPage()
+      } else {
+        this.$message({
+          showClose: true,
+          message: '不能为空!',
+          type: 'warning'
+        })
+      }
+    },
+    // 跳转搜索页面,传递搜索框的参数
+    toSearchPage() {
+      const currentPath = this.$route.path
+      if (currentPath === '/search') {
+        this.$router.push({
+          path: '/search',
+          query: {
+            keyword: this.keyword,
+            pageNumber: 1
+          }
+        })
+        this.$router.go(0)
+      } else {
+        const routeUrl = this.$router.resolve({
+          path: '/search',
+          query: {
+            keyword: this.keyword,
+            pageNumber: 1
+          }
+        })
+        window.open(routeUrl.href, '_blank')
+      }
+    },
+    // ****************************************************************************************************************
+    login1() {
+      this.fetchPubkey()
+      this.dialogVisible = true
+    },
+    login() {
+      const path = '/login'
+      if (this.$route.path === path) {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push(path)
+    },
+    register() {
+      console.log('帐号注册')
+    },
+    // ****************************************************************************************************************
+    goToDisk() {
+      const path = '/disk'
+      if (this.$route.path === path) {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push(path)
+    },
+    goToProfile() {
+      const path = '/my'
+      if (this.$route.path === path) {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push(path)
+    },
+    goToTimeline() {
+      const path = '/timeline'
+      if (this.$route.path === path) {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push(path)
+    },
+    goToHome() {
+      const path = '/user/' + this.user.userId
+      if (this.$route.path === path) {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push(path)
+    },
+    goToPost() {
+      if (this.$route.path === '/my/post/list/video') {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push('/my/post/list/video')
+    },
+    goToFavlist() {
+      if (this.$route.path === '/my/favlist/video') {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push('/my/favlist/video')
+    },
+    goToHistory() {
+      if (this.$route.path === '/my/visit') {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push('/my/visit')
+    },
+    goToMessage() {
+      if (this.$route.path === '/my/message/receive') {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push('/my/message/receive')
+    },
+    goToPublish() {
+      if (this.$route.path === '/my/post/publish/video') {
+        this.$router.go(0)
+        return
+      }
+      this.$router.push('/my/post/publish/video')
+    }
+  }
+}
+</script>
+
+<style scoped>
+@media screen and (max-width: 768px) {
+}
+
+.logo {
+  width: 30px;
+  position: relative;
+}
+
+.size {
+  font-size: 16px;
+}
+
+.item {
+  margin-top: 10px;
+  margin-right: 10px;
+}
+</style>

+ 129 - 0
src/components/upload/EditArticle.vue

@@ -0,0 +1,129 @@
+<template>
+  <el-row class="movie-list">
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="24" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div class="text item">
+            <el-button style="float: left; padding: 3px 0" type="text" @click="onReturnArticle">返回文章稿件列表</el-button>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="8" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>更新文章标题</span>
+            <el-button style="float: right; padding: 3px 0" type="text" @click="onUpdateArticleTitle">更新</el-button>
+          </div>
+          <div class="text item">
+            <el-form ref="form" :model="titleForm" label-width="80px">
+              <el-form-item label="标题">
+                <el-input v-model="titleForm.title" style="width: 70%; padding-right: 2px" placeholder="标题不能超过 50 个字符" />
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :md="16" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>更新文章内容</span>
+            <el-button style="float: right; padding: 3px 0" type="text" @click="onUpdateArticleContent">更新</el-button>
+          </div>
+          <div>
+            <rich-text :text="form.content" style="width: 80%; padding-right: 2px" @content="processContent" />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </el-row>
+</template>
+
+<script>
+import RichText from '@/components/RichText'
+
+import { getArticlePost, updateArticleContent } from '@/api/article'
+
+export default {
+  name: 'EditArticle',
+  components: { RichText },
+  data() {
+    return {
+      titleForm: {
+        articleId: null,
+        title: null
+      },
+      form: {
+        articleId: null,
+        content: null
+      }
+    }
+  },
+  created() {
+    document.title = '编辑文章稿件'
+
+    const articleId = this.$route.params.articleId
+    getArticlePost(articleId).then(resp => {
+      if (resp.code === 0) {
+        const respData = resp.data
+        this.titleForm.articleId = respData.articleId
+        this.titleForm.title = respData.title
+
+        this.form.articleId = respData.articleId
+        this.form.content = respData.content
+      } else {
+      }
+    })
+  },
+  methods: {
+    processContent(value) {
+      this.form.content = value
+    },
+    onReturnArticle() {
+      this.$router.push('/my/post/list/article')
+    },
+    onUpdateArticleContent() {
+      updateArticleContent(this.form).then(resp => {
+        if (resp.code === 0) {
+          this.$notify({
+            message: '已更新',
+            type: 'info',
+            duration: 3000
+          })
+        } else {
+          this.$notify({
+            message: '更新失败',
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      })
+    },
+    onUpdateArticleTitle() {
+      this.$notify({
+        message: '接口未实现',
+        type: 'warning',
+        duration: 3000
+      })
+    }
+  }
+}
+</script>
+
+<style>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px){
+  .movie-list {
+    padding-top: 8px;
+    padding-left: 0.5%;
+    padding-right: 0.5%;
+  }
+}
+
+.movie-list {
+  padding-top: 15px;
+  padding-left: 6%;
+  padding-right: 6%;
+}
+</style>

+ 91 - 0
src/components/upload/EditAudio.vue

@@ -0,0 +1,91 @@
+<template>
+  <el-row class="movie-list">
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="24" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div class="text item">
+            <el-button style="float: left; padding: 3px 0" type="text" @click="onReturnAudio">返回音频稿件列表</el-button>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="8" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>更新音频稿件信息</span>
+            <el-button style="float: right; padding: 3px 0" type="text" @click="onUpdateAudioPost">更新</el-button>
+          </div>
+          <div class="text item">
+            <el-form ref="form" :model="form" label-width="80px">
+              <el-form-item label="标题">
+                <el-input v-model="form.title" style="width: 70%; padding-right: 2px" placeholder="标题不能超过 50 个字符" />
+              </el-form-item>
+              <el-form-item label="描述">
+                <el-input v-model="form.description" type="textarea" style="width: 70%; padding-right: 2px" />
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </el-row>
+</template>
+
+<script>
+import { getAudioPost } from '@/api/audio'
+
+export default {
+  name: 'EditAudio',
+  data() {
+    return {
+      form: {
+        title: null,
+        description: null
+      }
+    }
+  },
+  created() {
+    document.title = '编辑音频稿件'
+
+    const audioId = this.$route.params.audioId
+    getAudioPost(audioId).then(res => {
+      if (res.code === 0) {
+        const audioInfo = res.data
+        this.form.title = audioInfo.title
+        this.form.description = audioInfo.description
+      } else {
+      }
+    })
+  },
+  methods: {
+    onReturnAudio() {
+      this.$router.push('/my/post/list/audio')
+    },
+    onUpdateAudioPost() {
+      this.$notify({
+        message: '接口待实现',
+        type: 'info',
+        duration: 3000
+      })
+    }
+  }
+}
+</script>
+
+<style>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px){
+  .movie-list {
+    padding-top: 8px;
+    padding-left: 0.5%;
+    padding-right: 0.5%;
+  }
+}
+
+.movie-list {
+  padding-top: 15px;
+  padding-left: 6%;
+  padding-right: 6%;
+}
+</style>

+ 346 - 0
src/components/upload/EditImage.vue

@@ -0,0 +1,346 @@
+<template>
+  <el-row class="movie-list">
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="24" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div class="text item">
+            <el-button style="float: left; padding: 3px 0" type="text" @click="onReturnAlbum">返回相册稿件列表</el-button>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row>
+      <el-col :md="8">
+        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+          <el-card class="box-card">
+            <div slot="header" class="clearfix">
+              <span>添加图片</span>
+              <el-button style="float: right; padding: 3px 0" type="text" @click="onAddImages">更新</el-button>
+            </div>
+            <div class="text item">
+              <el-upload
+                :action="actionUrl"
+                :headers="imgHeaders"
+                :data="imgData"
+                :file-list="uploadImages"
+                :multiple="true"
+                :limit="limit"
+                :with-credentials="true"
+                list-type="picture-card"
+                :before-upload="handleBeforeUpload"
+                :on-success="handleOnSuccess"
+                :on-error="handleOnError"
+                :on-remove="handleOnRemove"
+                :on-preview="handleOnPreview"
+              >
+                <i class="el-icon-plus" />
+              </el-upload>
+            </div>
+          </el-card>
+        </el-row>
+      </el-col>
+      <el-col :md="16">
+        <div>
+          <el-col
+            v-for="(image, index) in data.images"
+            :key="image.thumbnailUrl"
+            :md="6"
+            :sm="12"
+            :xs="12"
+            style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px"
+          >
+            <el-card :body-style="{ padding: '0px' }" class="card">
+              <div class="imgs">
+                <el-image
+                  lazy
+                  fit="cover"
+                  class="coverImg"
+                  :src="image.thumbnailUrl"
+                  @click="showImages(index)"
+                />
+              </div>
+              <div style="padding: 14px;">
+                <el-tooltip class="item" effect="dark" content="删除图片" placement="top-end">
+                  <el-button
+                    size="mini"
+                    type="danger"
+                    class="el-icon-delete"
+                    @click="onDeleteImage(image.imageFileId)"
+                  />
+                </el-tooltip>
+                <el-tooltip class="item" effect="dark" content="设为封面" placement="top-end">
+                  <el-button
+                    size="mini"
+                    type="warning"
+                    class="el-icon-picture-outline"
+                    @click="onSetCover(image.imageFileId)"
+                  />
+                </el-tooltip>
+              </div>
+            </el-card>
+          </el-col>
+        </div>
+      </el-col>
+    </el-row>
+  </el-row>
+</template>
+
+<script>
+import { getAlbumImage, deleteAlbumImage, addAlbumImage, updateAlbumCover } from '@/api/image'
+import { getServerInfo } from '@/api/content'
+
+var imageFileMap = new Map()
+export default {
+  name: 'EditImage',
+  data() {
+    return {
+      actionUrl: process.env.VUE_APP_OSS_URL,
+      /** *********************************************************************/
+      imgHeaders: {
+        Authorization: ''
+      },
+      imgData: {
+        channelId: 6
+      },
+      dialogImageUrl: '',
+      dialogVisible: false,
+      uploadImages: [],
+      /** *********************************************************************/
+      // 屏幕宽度, 为了控制分页条的大小
+      screenWidth: document.body.clientWidth,
+      data: null,
+      imageCount: 0,
+      limit: 0
+    }
+  },
+  created() {
+    document.title = '编辑相册稿件'
+
+    const albumId = this.$route.params.albumId
+    getAlbumImage(albumId).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.data = resData
+        this.imageCount = this.data.images.length
+        this.limit = 40 - this.data.images.length
+        this.form.albumName = resData.albumName
+      }
+    })
+
+    getServerInfo(this.imgData.channelId).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.imgHeaders.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '失败提示',
+          message: res.msg,
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '错误提示',
+        message: error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+  },
+  mounted() {
+    // 当窗口宽度改变时获取屏幕宽度
+    window.onresize = () => {
+      return () => {
+        window.screenWidth = document.body.clientWidth
+        this.screenWidth = window.screenWidth
+      }
+    }
+  },
+  methods: {
+    /** *********************************************************************/
+    handleBeforeUpload(file) {
+      const fileType = file.type
+      var isJPG = false
+      if (file.type === 'image/jpeg' || file.type === 'image/webp' ||
+        file.type === 'image/gif' || file.type === 'image/png') {
+        isJPG = true
+      }
+
+      const isLt2M = file.size / 1024 / 1024 < 100
+      if (!isJPG) {
+        this.$message.error('图片只能是 jpeg/webp/gif/png 格式!')
+      }
+      if (!isLt2M) {
+        this.$message.error('图片大小不能超过 100MB!')
+      }
+      return isJPG && isLt2M
+    },
+    handleOnSuccess(res, file) {
+      if (res.code === 0) {
+        const resData = res.data
+        imageFileMap.set(file.name, resData.uploadId)
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '图片上传失败,请重试!' + res.msg,
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    },
+    handleOnError(err, file, fileList) {
+      const errMsg = JSON.parse(err.message)
+      this.$notify({
+        title: '图片上传失败',
+        message: errMsg.msg,
+        type: 'error',
+        duration: 3000
+      })
+    },
+    handleOnRemove(file, fileList) {
+      imageFileMap.delete(file.name)
+    },
+    handleOnPreview(file) {
+      this.dialogImageUrl = file.url
+      this.dialogVisible = true
+    },
+    /** *********************************************************************/
+    showImages(index) {
+      const imageUrls = []
+      for (const i of this.data.images) {
+        imageUrls.push(i.originalUrl)
+      }
+
+      this.$viewerApi({
+        images: imageUrls,
+        options: {
+          initialViewIndex: index,
+          movable: true,
+          fullscreen: false,
+          keyboard: true
+        }
+      })
+    },
+    onDeleteImage(imageFileId) {
+      this.$confirm('确定要删除图片?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(() => {
+        deleteAlbumImage(imageFileId).then(res => {
+          if (res.code === 0) {
+            this.$notify({
+              title: '图片已从相册中删除',
+              type: 'info',
+              duration: 3000
+            })
+            this.$router.go(0)
+          }
+        })
+      }).catch(() => {
+        this.$message({
+          type: 'info',
+          message: '已取消'
+        })
+      })
+    },
+    onSetCover(imageFileId) {
+      const jsonData = {}
+      jsonData.albumId = this.data.albumId
+      jsonData.imageFileId = imageFileId
+      updateAlbumCover(jsonData).then(resp => {
+        if (resp.code === 0) {
+          this.$notify({
+            title: '成功',
+            type: 'info',
+            duration: 1000
+          })
+        } else {
+          this.$notify({
+            title: '失败',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify.error({
+          title: '错误',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+    },
+    onReturnAlbum() {
+      this.$router.push('/my/post/list/image')
+    },
+    onAddImages() {
+      const jsonData = {}
+      jsonData.albumId = this.data.albumId
+      jsonData.imageFileIds = Array.from(imageFileMap.values())
+      addAlbumImage(jsonData).then(resp => {
+        if (resp.code === 0) {
+          this.$notify({
+            title: '图片已添加到相册',
+            type: 'info',
+            duration: 1000
+          })
+        } else {
+          this.$notify({
+            title: '图片添加失败',
+            message: resp.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify.error({
+          title: '图片添加错误',
+          message: error.message,
+          type: 'error',
+          duration: 3000
+        })
+      })
+      this.$router.go(0)
+    }
+  }
+}
+</script>
+
+<style scoped>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px){
+  .movie-list {
+    padding-top: 8px;
+    padding-left: 0.5%;
+    padding-right: 0.5%;
+  }
+
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.movie-list {
+  padding-top: 15px;
+  padding-left: 6%;
+  padding-right: 6%;
+}
+
+.coverImg {
+  width: 100%;
+  height: 240px;
+  display: block;
+}
+
+.card {
+  margin-bottom: 20px;
+  transition: all 0.6s; /*所有属性变化在0.6秒内执行动画*/
+}
+
+.imgs {
+  position: relative;
+}
+</style>

+ 447 - 0
src/components/upload/EditVideo.vue

@@ -0,0 +1,447 @@
+<template>
+  <el-row class="movie-list">
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="24" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div class="text item">
+            <el-button style="float: left; padding: 3px 0" type="text" @click="onReturnVideo">返回视频稿件列表</el-button>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+          <el-card class="box-card">
+            <div slot="header" class="clearfix">
+              <span>更新视频封面</span>
+              <el-button style="float: right; padding: 3px 0" type="text" @click="onUpdateVideoCover">更新</el-button>
+            </div>
+            <div class="text item">
+              <el-tooltip class="item" effect="dark" content="点击上传图片" placement="top-end">
+                <el-upload
+                  class="avatar-uploader"
+                  :action="actionUrl"
+                  :headers="imgHeaders"
+                  :data="imgData"
+                  :with-credentials="true"
+                  :show-file-list="false"
+                  :before-upload="beforeAvatarUpload"
+                  :on-success="handleAvatarSuccess"
+                  :on-change="handleOnChange"
+                >
+                  <img :src="coverUrl" class="avatar">
+                </el-upload>
+              </el-tooltip>
+            </div>
+          </el-card>
+        </el-row>
+        <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+          <el-card class="box-card">
+            <div slot="header" class="clearfix">
+              <span>更新视频文件</span>
+              <el-button style="float: right; padding: 3px 0" type="text" @click="onUpdateVideoFile">更新</el-button>
+            </div>
+            <div class="text item">
+              <uploader
+                class="uploader-example"
+                :options="options"
+                :auto-start="true"
+                @file-added="onFileAdded"
+                @file-success="onFileSuccess"
+                @file-progress="onFileProgress"
+                @file-error="onFileError"
+              >
+                <uploader-unsupport />
+                <uploader-drop>
+                  <p>拖动视频文件到此处或</p>
+                  <uploader-btn :attrs="attrs">选择视频文件</uploader-btn>
+                </uploader-drop>
+                <uploader-list />
+              </uploader>
+            </div>
+          </el-card>
+        </el-row>
+      </el-col>
+      <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>更新视频信息</span>
+            <el-button style="float: right; padding: 3px 0" type="text" @click="onUpdateVideoInfo">更新</el-button>
+          </div>
+          <div class="text item">
+            <el-form ref="form" :model="videoInfoForm" label-width="80px">
+              <el-form-item label="标题">
+                <el-input v-model="videoInfoForm.title" style="padding-right: 1px" placeholder="标题不能超过 50 个字符" />
+              </el-form-item>
+              <el-form-item label="描述">
+                <el-input v-model="videoInfoForm.description" type="textarea" autosize style="padding-right: 1px;" />
+              </el-form-item>
+              <el-form-item label="发布时间">
+                <el-date-picker
+                  v-model="videoInfoForm.pubDate"
+                  type="datetime"
+                  placeholder="选择发布的时间"
+                />
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </el-row>
+</template>
+
+<script>
+import { getServerInfo } from '@/api/content'
+import { getVideoPost, updateVideoInfo, updateVideoCover, updateVideoFile } from '@/api/video'
+
+export default {
+  name: 'EditVideo',
+  data() {
+    return {
+      actionUrl: process.env.VUE_APP_OSS_URL,
+      // ****************************************************************************************************************
+      options: {
+        target: process.env.VUE_APP_OSS_URL,
+        chunkSize: 1024 * 1024 * 1024 * 5, // 5GiB
+        fileParameterName: 'file',
+        testChunks: false,
+        query: (file, chunk) => {
+          return {
+            channelId: 2
+          }
+        },
+        headers: {
+          Authorization: ''
+        },
+        withCredentials: true
+      },
+      attrs: {
+        accept: 'video/*'
+      },
+      imgHeaders: {
+        Authorization: ''
+      },
+      imgData: {
+        channelId: 5
+      },
+      // ****************************************************************************************************************
+      coverUrl: null,
+      coverUrl1: null,
+      coverFileId: null,
+      videoFileId: null,
+      // 提交给后端的数据
+      videoInfoForm: {
+        videoId: null,
+        title: null,
+        description: null,
+        pubDate: null
+      },
+      ossToken: null
+    }
+  },
+  created() {
+    document.title = '编辑视频稿件'
+
+    const videoId = this.$route.params.videoId
+    getVideoPost(videoId).then(res => {
+      if (res.code === 0) {
+        const userVideoPost = res.data
+        this.coverUrl = userVideoPost.coverUrl
+        this.videoInfoForm.videoId = userVideoPost.videoId
+        this.videoInfoForm.title = userVideoPost.title
+        this.videoInfoForm.description = userVideoPost.description
+        this.videoInfoForm.pubDate = userVideoPost.pubDate
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '获取 OSS 服务器地址失败, 暂时无法上传文件',
+          type: 'error',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '提示',
+        message: error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+
+    getServerInfo(2).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.options.target = resData.ossUrl
+        this.options.chunkSize = resData.maxSize
+        this.options.headers.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '获取 OSS 服务器地址失败, 暂时无法上传文件',
+          type: 'error',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '提示',
+        message: '视频上传配置失败 ' + error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+
+    getServerInfo(5).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.imgHeaders.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '获取 OSS 服务器地址失败, 暂时无法上传文件',
+          type: 'error',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '提示',
+        message: '图片上传配置失败 ' + error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+  },
+  methods: {
+    // ****************************************************************************************************************
+    onFileAdded(file) {
+      if (file.file.size > 1024 * 1024 * 1024 * 5) {
+        file.cancel()
+        this.$notify(
+          {
+            title: '提示',
+            message: '视频文件应小于 5GiB',
+            type: 'warning',
+            duration: 3000
+          }
+        )
+        return
+      }
+    },
+    onFileProgress(rootFile, file, chunk) {
+    },
+    onFileSuccess(rootFile, file, response, chunk) {
+      const res = JSON.parse(response)
+      if (res.code === 0) {
+        const resData = res.data
+        this.videoFileId = resData.uploadId
+
+        this.$notify(
+          {
+            title: '提示',
+            message: '视频已上传',
+            type: 'warning',
+            duration: 3000
+          }
+        )
+      }
+    },
+    onFileError(rootFile, file, response, chunk) {
+      this.$notify(
+        {
+          title: '提示',
+          message: '文件上传错误',
+          type: 'warning',
+          duration: 3000
+        }
+      )
+    },
+    // ****************************************************************************************************************
+    beforeAvatarUpload(file) {
+      const isJPG = file.type === 'image/jpeg'
+      const isLt2M = file.size / 1024 / 1024 < 2
+      if (!isJPG) {
+        this.$message.error('上传头像图片只能是 JPG 格式!')
+      }
+      if (!isLt2M) {
+        this.$message.error('上传头像图片大小不能超过 2MB!')
+      }
+      return isJPG && isLt2M
+    },
+    handleAvatarSuccess(res, file) {
+      const localImageUrl = URL.createObjectURL(file.raw)
+      if (res.code === 0) {
+        const resData = res.data
+        this.coverFileId = resData.uploadId
+        this.coverUrl = localImageUrl
+        this.coverUrl1 = resData.url
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '视频封面上传失败,请重试!' + res.msg,
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    },
+    handleOnChange(file, fileList) {
+    },
+    // ****************************************************************************************************************
+    onReturnVideo() {
+      this.$router.push('/my/post/list/video')
+    },
+    onUpdateVideoInfo() {
+      updateVideoInfo(this.videoInfoForm).then(res => {
+        if (res.code === 0) {
+          this.$notify({
+            title: '提示',
+            message: '视频信息已更新',
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'warning',
+          duration: 3000
+        })
+      })
+    },
+    onUpdateVideoCover() {
+      if (this.coverUrl1 === null) {
+        this.$notify({
+          title: '提示',
+          message: '你还没有上传视频封面',
+          type: 'warning',
+          duration: 3000
+        })
+        return
+      }
+
+      const videoCover = {
+        videoId: this.videoInfoForm.videoId,
+        coverUrl: this.coverUrl1,
+        coverFileId: this.coverFileId
+      }
+
+      updateVideoCover(videoCover).then(res => {
+        if (res.code === 0) {
+          this.$notify({
+            title: '提示',
+            message: '视频封面已更新',
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'warning',
+          duration: 3000
+        })
+      })
+    },
+    onUpdateVideoFile() {
+      if (this.videoFileId === null) {
+        this.$notify({
+          title: '提示',
+          message: '你还没有上传视频文件',
+          type: 'warning',
+          duration: 3000
+        })
+        return
+      }
+
+      const videoFile = {
+        videoId: this.videoInfoForm.videoId,
+        videoFileId: this.videoFileId
+      }
+
+      updateVideoFile(videoFile).then(res => {
+        if (res.code === 0) {
+          this.$notify({
+            title: '提示',
+            message: '视频文件已更新',
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'warning',
+          duration: 3000
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style>
+/*处于手机屏幕时*/
+@media screen and (max-width: 768px){
+  .movie-list {
+    padding-top: 8px;
+    padding-left: 0.5%;
+    padding-right: 0.5%;
+  }
+
+  .coverImg {
+    height: 120px !important;
+  }
+}
+
+.movie-list {
+  padding-top: 15px;
+  padding-left: 6%;
+  padding-right: 6%;
+}
+
+.uploader-example {
+  width: 500px;
+  padding: 15px;
+  margin: 40px auto 0;
+  font-size: 12px;
+  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
+}
+.uploader-example .uploader-btn {
+  margin-right: 4px;
+}
+.uploader-example .uploader-list {
+  max-height: 440px;
+  overflow: auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+.avatar-uploader-icon {
+  font-size: 28px;
+  color: #8c939d;
+  width: 320px;
+  height: 240px;
+  line-height: 178px;
+  text-align: center;
+}
+.avatar {
+  width: 320px;
+  height: 240px;
+  display: block;
+}
+</style>

+ 80 - 0
src/components/upload/PublishArticle.vue

@@ -0,0 +1,80 @@
+<template>
+  <div>
+    <el-row style="position: center">
+      <h2>发布文章</h2>
+      <el-form ref="form" :model="form" label-width="60px">
+        <el-form-item label="标题">
+          <el-input v-model="form.title" style="width: 70%; padding-right: 2px" />
+        </el-form-item>
+        <el-form-item label="摘要" style="width: 70%; padding-right: 2px">
+          <el-input v-model="form.excerpt" type="textarea" autosize style="padding-right: 1px;" />
+        </el-form-item>
+        <el-form-item label="内容">
+          <rich-text style="width: 80%; padding-right: 2px" @content="processContent" />
+          <!-- <mavon-editor v-model="content" style="width: 90%; padding-right: 2px" />-->
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="onSubmit">发布</el-button>
+        </el-form-item>
+      </el-form>
+    </el-row>
+  </div>
+</template>
+
+<script>
+import RichText from '@/components/RichText'
+import { submitArticle } from '@/api/article'
+
+export default {
+  name: 'PublishArticle',
+  components: { RichText },
+  data() {
+    return {
+      content: '',
+      form: {
+        title: '',
+        excerpt: '',
+        content: ''
+      }
+    }
+  },
+  created() {
+  },
+  methods: {
+    processContent(value) {
+      this.form.content = value
+    },
+    onSubmit() {
+      console.log(this.form)
+      submitArticle(this.form).then(res => {
+        if (res.code === 0) {
+          this.$notify({
+            title: '提示',
+            message: '投稿成功',
+            type: 'warning',
+            duration: 3000
+          })
+          this.$router.push('/my/post/list/article')
+        } else {
+          this.$notify({
+            title: '提示',
+            message: res.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'warning',
+          duration: 3000
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style>
+</style>

+ 295 - 0
src/components/upload/PublishAudio.vue

@@ -0,0 +1,295 @@
+<template>
+  <el-row class="movie-list">
+    <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>上传音频文件</span>
+          </div>
+          <div class="text item">
+            <uploader
+              class="uploader-example"
+              :options="options"
+              :auto-start="true"
+              @file-added="onFileAdded"
+              @file-success="onFileSuccess"
+              @file-progress="onFileProgress"
+              @file-error="onFileError"
+            >
+              <uploader-unsupport />
+              <uploader-drop>
+                <p>拖动音频文件到此处或</p>
+                <uploader-btn :attrs="attrs">选择音频文件</uploader-btn>
+              </uploader-drop>
+              <uploader-list />
+            </uploader>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>稿件信息</span>
+            <el-button style="float: right; padding: 3px 0" type="text" @click="onSubmit">发布</el-button>
+          </div>
+          <div class="text item">
+            <el-form ref="form" :model="form" label-width="80px">
+              <el-form-item label="标题">
+                <el-input v-model="form.title" style="padding-right: 1px" placeholder="标题不能超过 50 个字符" />
+              </el-form-item>
+              <el-form-item label="描述">
+                <el-input v-model="form.description" type="textarea" autosize style="padding-right: 1px;" />
+              </el-form-item>
+              <el-form-item label="可见范围">
+                <el-select v-model="form.scope" placeholder="选择稿件的可见范围">
+                  <el-option label="本人可见" value="1" />
+                  <el-option label="所有人可见" value="2" />
+                  <el-option label="VIP 可见" value="3" />
+                  <el-option label="验证码可见" value="4" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="定时发布">
+                <el-date-picker
+                  v-model="form.scheduledPubDate"
+                  type="datetime"
+                  placeholder="选择定时发布的时间"
+                />
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </el-row>
+</template>
+
+<script>
+import { getServerInfo } from '@/api/content'
+import { addAudioPost } from '@/api/audio'
+
+export default {
+  name: 'PublishAudio',
+  data() {
+    return {
+      // ****************************************************************************************************************
+      options: {
+        target: process.env.VUE_APP_OSS_URL,
+        chunkSize: 1024 * 1024 * 1024, // 1GiB
+        fileParameterName: 'file',
+        testChunks: false,
+        query: (file, chunk) => {
+          return {
+            channelId: 3
+          }
+        },
+        headers: {
+          Authorization: ''
+        }
+      },
+      attrs: {
+        accept: 'audio/*'
+      },
+      // ****************************************************************************************************************
+      form: {
+        audioFileId: null,
+        audioUrl: null,
+        title: null,
+        description: null,
+        scope: '1',
+        scheduledPubDate: null
+      }
+    }
+  },
+  created() {
+    getServerInfo(3).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.options.target = resData.ossUrl
+        this.options.chunkSize = resData.maxSize
+        this.options.headers.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '获取 OSS 服务器地址失败, 暂时无法上传文件',
+          type: 'error',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '提示',
+        message: error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+  },
+  methods: {
+    // ****************************************************************************************************************
+    onFileAdded(file) {
+      this.setTitle(file.file.name)
+    },
+    onFileProgress(rootFile, file, chunk) {
+    },
+    onFileSuccess(rootFile, file, response, chunk) {
+      const res = JSON.parse(response)
+      if (res.code === 0) {
+        const resData = res.data
+        this.form.audioFileId = resData.uploadId
+        this.form.audioUrl = resData.url
+        this.$notify({
+          title: '提示',
+          message: '音频已上传',
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    },
+    onFileError(rootFile, file, response, chunk) {
+      console.log('文件上传错误')
+    },
+    // ****************************************************************************************************************
+    setTitle(title) {
+      if (title.length > 50) {
+        this.form.title = title.substring(0, 50)
+        this.form.description = title
+      } else {
+        this.form.title = title
+      }
+    },
+    onSubmit() {
+      if (!this.form.audioFileId) {
+        this.$notify({
+          title: '提示',
+          message: '你还没有上传音频',
+          type: 'warning',
+          duration: 3000
+        }
+        )
+        return
+      }
+
+      if (this.form.title === null) {
+        this.$notify({
+          title: '提示',
+          message: '稿件标题不能为空',
+          type: 'warning',
+          duration: 3000
+        }
+        )
+        return
+      }
+
+      addAudioPost(this.form).then(res => {
+        if (res.code === 0) {
+          this.$notify({
+            title: '提示',
+            message: '投稿成功',
+            type: 'warning',
+            duration: 3000
+          })
+          this.$router.push('/my/post/list/audio')
+        } else {
+          this.$notify({
+            title: '提示',
+            message: res.msg,
+            type: 'warning',
+            duration: 3000
+          })
+        }
+      }).catch(error => {
+        this.$notify({
+          title: '提示',
+          message: error.message,
+          type: 'warning',
+          duration: 3000
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.uploader-example {
+  width: 500px;
+  padding: 15px;
+  margin: 40px auto 0;
+  font-size: 12px;
+  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
+}
+.uploader-example .uploader-btn {
+  margin-right: 4px;
+}
+.uploader-example .uploader-list {
+  max-height: 440px;
+  overflow: auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+.avatar-uploader-icon {
+  font-size: 28px;
+  color: #8c939d;
+  width: 320px;
+  height: 240px;
+  line-height: 178px;
+  text-align: center;
+}
+.avatar {
+  width: 320px;
+  height: 240px;
+  display: block;
+}
+
+.uploader-example {
+  width: 500px;
+  padding: 15px;
+  margin: 40px auto 0;
+  font-size: 12px;
+  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
+}
+.uploader-example .uploader-btn {
+  margin-right: 4px;
+}
+.uploader-example .uploader-list {
+  max-height: 440px;
+  overflow: auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+.avatar-uploader-icon {
+  font-size: 28px;
+  color: #8c939d;
+  width: 320px;
+  height: 240px;
+  line-height: 178px;
+  text-align: center;
+}
+.avatar {
+  width: 320px;
+  height: 240px;
+  display: block;
+}
+</style>

+ 288 - 0
src/components/upload/PublishFile.vue

@@ -0,0 +1,288 @@
+<template>
+  <el-row class="movie-list">
+    <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>上传视频文件</span>
+          </div>
+          <div class="text item">
+            <uploader
+              class="uploader-example"
+              :options="options"
+              :auto-start="true"
+              @file-added="onFileAdded"
+              @file-success="onFileSuccess"
+              @file-progress="onFileProgress"
+              @file-error="onFileError"
+            >
+              <uploader-unsupport />
+              <uploader-drop>
+                <p>拖动视频文件到此处或</p>
+                <uploader-btn :attrs="attrs">选择视频文件</uploader-btn>
+              </uploader-drop>
+              <uploader-list />
+            </uploader>
+          </div>
+        </el-card>
+      </el-row>
+      <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>上传用户头像</span>
+          </div>
+          <div class="text item">
+            <el-tooltip class="item" effect="dark" content="点击上传图片" placement="top-end">
+              <el-upload
+                class="avatar-uploader"
+                :action="actionUrl"
+                :headers="imgHeaders"
+                :data="imgData"
+                :with-credentials="true"
+                :show-file-list="false"
+                :before-upload="beforeAvatarUpload"
+                :on-success="handleAvatarSuccess"
+                :on-change="handleOnChange"
+              >
+                <img v-if="coverUrl" :src="coverUrl" class="avatar">
+                <i v-else class="el-icon-plus avatar-uploader-icon" />
+              </el-upload>
+            </el-tooltip>
+          </div>
+        </el-card>
+      </el-row>
+    </el-col>
+    <el-col :md="12" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-row style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+        <el-card class="box-card">
+          <div slot="header" class="clearfix">
+            <span>文件地址</span>
+          </div>
+          <div class="text item">
+            <el-form ref="form" :model="form" label-width="80px">
+              <el-form-item label="文件地址">
+                <el-input v-model="form.fileUrl" style="padding-right: 1px" readonly />
+              </el-form-item>
+              <el-form-item label="头像地址">
+                <el-input v-model="form.imageUrl" style="padding-right: 1px" readonly />
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card>
+      </el-row>
+    </el-col>
+  </el-row>
+</template>
+
+<script>
+import { getServerInfo } from '@/api/content'
+
+export default {
+  name: 'PublishFile',
+  data() {
+    return {
+      actionUrl: process.env.VUE_APP_OSS_URL,
+      // ****************************************************************************************************************
+      options: {
+        target: process.env.VUE_APP_OSS_URL,
+        chunkSize: 1024 * 1024 * 1024 * 10, // 10GiB
+        fileParameterName: 'file',
+        testChunks: false,
+        query: (file, chunk) => {
+          return {
+            channelId: 2
+          }
+        },
+        headers: {
+          Authorization: ''
+        },
+        withCredentials: true
+      },
+      attrs: {
+        accept: 'video/*'
+      },
+      // ****************************************************************************************************************
+      imgOssUrl: null,
+      imgHeaders: {
+        Authorization: ''
+      },
+      imgData: {
+        channelId: 4
+      },
+      coverUrl: null,
+      // ****************************************************************************************************************
+      form: {
+        fileUrl: null,
+        imageUrl: null
+      }
+    }
+  },
+  created() {
+    getServerInfo(2).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.options.target = resData.ossUrl
+        this.options.chunkSize = resData.maxSize
+        this.options.headers.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '获取 OSS 服务器地址失败, 暂时无法上传文件',
+          type: 'error',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '提示',
+        message: error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+
+    getServerInfo(4).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.imgOssUrl = resData.ossUrl
+        this.imgHeaders.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '获取 OSS 服务器地址失败, 暂时无法上传文件',
+          type: 'error',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '提示',
+        message: error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+  },
+  mounted() {
+  },
+  methods: {
+    // ****************************************************************************************************************
+    onFileAdded(file) {
+      if (file.file.size > 1024 * 1024 * 1024 * 5) {
+        file.cancel()
+        this.$notify({
+          title: '提示',
+          message: '视频文件应小于 5GiB',
+          type: 'warning',
+          duration: 3000
+        })
+        return
+      }
+    },
+    onFileProgress(rootFile, file, chunk) {
+    },
+    onFileSuccess(rootFile, file, response, chunk) {
+      const res = JSON.parse(response)
+      if (res.code === 0) {
+        const resData = res.data
+        this.form.fileUrl = resData.uploadId
+        this.$notify({
+          title: '提示',
+          message: '视频已上传',
+          type: 'warning',
+          duration: 3000
+        })
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '视频文件上传失败',
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    },
+    onFileError(rootFile, file, response, chunk) {
+      const res = JSON.parse(response)
+      console.log(res.msg)
+      this.$notify({
+        title: '提示',
+        message: '视频文件上传错误',
+        type: 'warning',
+        duration: 3000
+      })
+    },
+    // ****************************************************************************************************************
+    beforeAvatarUpload(file) {
+      const isJPG = file.type === 'image/jpeg'
+      const isLt2M = file.size / 1024 / 1024 < 10
+      if (!isJPG) {
+        this.$message.error('封面图片只能是 JPG 格式!')
+      }
+      if (!isLt2M) {
+        this.$message.error('封面图片大小不能超过 10MB!')
+      }
+      return isJPG && isLt2M
+    },
+    handleAvatarSuccess(res, file) {
+      if (res.code === 0) {
+        const resData = res.data
+        this.coverUrl = URL.createObjectURL(file.raw)
+        this.form.imageUrl = resData.url
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '视频封面上传失败,请重试!' + res.msg,
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    },
+    handleOnChange(file, fileList) {
+    }
+    // ****************************************************************************************************************
+  }
+}
+</script>
+
+<style>
+.uploader-example {
+  width: 500px;
+  padding: 15px;
+  margin: 40px auto 0;
+  font-size: 12px;
+  box-shadow: 0 0 10px rgba(0, 0, 0, .4);
+}
+.uploader-example .uploader-btn {
+  margin-right: 4px;
+}
+.uploader-example .uploader-list {
+  max-height: 440px;
+  overflow: auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+.avatar-uploader-icon {
+  font-size: 28px;
+  color: #8c939d;
+  width: 320px;
+  height: 240px;
+  line-height: 178px;
+  text-align: center;
+}
+.avatar {
+  width: 320px;
+  height: 240px;
+  display: block;
+}
+</style>

+ 262 - 0
src/components/upload/PublishImage.vue

@@ -0,0 +1,262 @@
+<template>
+  <el-row class="movie-list">
+    <el-col :md="16" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-card class="box-card">
+        <div slot="header" class="clearfix">
+          <span>上传图片文件</span>
+        </div>
+        <div class="text item">
+          <el-upload
+            :action="actionUrl"
+            :headers="imgHeaders"
+            :data="imgData"
+            :file-list="uploadImages"
+            :multiple="true"
+            :limit="40"
+            :with-credentials="true"
+            list-type="picture-card"
+            :before-upload="handleBeforeUpload"
+            :on-success="handleOnSuccess"
+            :on-error="handleOnError"
+            :on-remove="handleOnRemove"
+            :on-preview="handleOnPreview"
+          >
+            <i class="el-icon-plus" />
+          </el-upload>
+          <el-dialog :visible.sync="dialogVisible">
+            <img width="100%" :src="dialogImageUrl" alt="">
+          </el-dialog>
+        </div>
+      </el-card>
+    </el-col>
+    <el-col :md="8" style="padding-right: 5px; padding-left: 5px; padding-bottom: 5px">
+      <el-card class="box-card">
+        <div slot="header" class="clearfix">
+          <span>稿件信息</span>
+          <el-button style="float: right; padding: 3px 0" type="text" @click="onSubmit('submitForm')">发布</el-button>
+        </div>
+        <div class="text item">
+          <el-form ref="submitForm" :model="submitForm" :rules="submitFormRules" label-width="80px">
+            <el-form-item label="相册名">
+              <el-input v-model="submitForm.albumName" style="width: 70%; padding-right: 2px" placeholder="相册名不能超过 50 个字符" />
+            </el-form-item>
+            <el-form-item label="可见范围">
+              <el-select v-model="submitForm.scope" placeholder="选择稿件的可见范围">
+                <el-option label="本人可见" value="1" />
+                <el-option label="所有人可见" value="2" />
+                <el-option label="VIP 可见" value="3" />
+                <el-option label="验证码可见" value="4" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="定时发布">
+              <el-date-picker
+                v-model="submitForm.scheduledPubDate"
+                type="datetime"
+                placeholder="选择定时发布的时间"
+              />
+            </el-form-item>
+          </el-form>
+        </div>
+      </el-card>
+    </el-col>
+  </el-row>
+</template>
+
+<script>
+import { getServerInfo } from '@/api/content'
+import { submitAlbum } from '@/api/image'
+
+var imageFileMap = new Map()
+export default {
+  name: 'PublishImage',
+  components: {},
+  data() {
+    return {
+      actionUrl: process.env.VUE_APP_OSS_URL,
+      // ****************************************************************************************************************
+      imgHeaders: {
+        Authorization: ''
+      },
+      imgData: {
+        channelId: 6
+      },
+      dialogImageUrl: '',
+      dialogVisible: false,
+      uploadImages: [],
+      // ****************************************************************************************************************
+      submitForm: {
+        imageFileIds: [],
+        albumName: null,
+        scope: '1',
+        scheduledPubDate: null
+      },
+      submitFormRules: {
+        imageFileIds: [
+          { type: 'array', required: true, message: '至少上传一张图片', trigger: 'change' }
+        ]
+      }
+    }
+  },
+  created() {
+    getServerInfo(this.imgData.channelId).then(res => {
+      if (res.code === 0) {
+        const resData = res.data
+        this.imgHeaders.Authorization = 'Bearer ' + resData.token
+      } else {
+        this.$notify({
+          title: '失败提示',
+          message: res.msg,
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    }).catch(error => {
+      this.$notify({
+        title: '错误提示',
+        message: error.message,
+        type: 'warning',
+        duration: 3000
+      })
+    })
+  },
+  methods: {
+    // ****************************************************************************************************************
+    handleBeforeUpload(file) {
+      // const fileType = file.type
+      var isJPG = false
+      if (file.type === 'image/jpeg' || file.type === 'image/webp' ||
+        file.type === 'image/gif' || file.type === 'image/png') {
+        isJPG = true
+      }
+
+      const isLt2M = file.size / 1024 / 1024 < 100
+      if (!isJPG) {
+        this.$message.error('图片只能是 jpeg/webp/gif/png 格式!')
+      }
+      if (!isLt2M) {
+        this.$message.error('图片大小不能超过 100MB!')
+      }
+      return isJPG && isLt2M
+    },
+    handleOnSuccess(res, file) {
+      if (res.code === 0) {
+        const resData = res.data
+        imageFileMap.set(file.name, resData.uploadId)
+      } else {
+        this.$notify({
+          title: '提示',
+          message: '图片上传失败,请重试!' + res.msg,
+          type: 'warning',
+          duration: 3000
+        })
+      }
+    },
+    handleOnError(err, file, fileList) {
+      const errMsg = JSON.parse(err.message)
+      this.$notify({
+        title: '图片上传失败',
+        message: errMsg.msg,
+        type: 'error',
+        duration: 3000
+      })
+    },
+    handleOnRemove(file, fileList) {
+      imageFileMap.delete(file.name)
+    },
+    handleOnPreview(file) {
+      this.dialogImageUrl = file.url
+      this.dialogVisible = true
+    },
+    // ****************************************************************************************************************
+    onSubmit(formName) {
+      this.$refs[formName].validate(valid => {
+        if (!valid) return false
+
+        this.submitForm.imageFileIds = Array.from(imageFileMap.values())
+        submitAlbum(this.submitForm).then(res => {
+          if (res.code === 0) {
+            this.$router.push('/my/post/list/image')
+          } else {
+            this.$notify({
+              title: '提示',
+              message: res.msg,
+              type: 'warning',
+              duration: 3000
+            })
+          }
+        }).catch(error => {
+          this.$notify({
+            title: '提示',
+            message: error.message,
+            type: 'warning',
+            duration: 3000
+          })
+        })
+      })
+    }
+  }
+}
+</script>
+
+<style>
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+.avatar-uploader-icon {
+  font-size: 28px;
+  color: #8c939d;
+  width: 320px;
+  height: 240px;
+  line-height: 178px;
+  text-align: center;
+}
+
+.uploader-example .uploader-btn {
+  margin-right: 4px;
+}
+.uploader-example .uploader-list {
+  max-height: 440px;
+  overflow: auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+
+.uploader-example .uploader-btn {
+  margin-right: 4px;
+}
+.uploader-example .uploader-list {
+  max-height: 440px;
+  overflow: auto;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.avatar-uploader .el-upload {
+  border: 1px dashed #d9d9d9;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+}
+.avatar-uploader .el-upload:hover {
+  border-color: #409EFF;
+}
+</style>

Some files were not shown because too many files changed in this diff