├── .browserslistrc ├── .env ├── .env.development ├── .env.production ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .stylelintrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── antd-theme.js ├── babel.config.js ├── commitlint.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── js │ └── hm.js ├── src ├── App.vue ├── assets │ └── img │ │ ├── avatar.jpg │ │ ├── chat-avatar.png │ │ ├── comment-avatar.svg │ │ ├── default_category.svg │ │ ├── logo.png │ │ ├── logo2.png │ │ ├── reply-avatar.svg │ │ └── wechat_payme.jpg ├── bean │ ├── base.ts │ ├── dto.ts │ └── xhr.ts ├── components │ ├── base-layout │ │ ├── base-footer.vue │ │ ├── base-menu.vue │ │ ├── hot-column.vue │ │ └── index.vue │ ├── bottom-tips │ │ └── index.vue │ ├── button │ │ └── index.vue │ ├── card │ │ ├── card-article.vue │ │ └── card-comment.vue │ ├── icon-svg │ │ └── index.vue │ ├── index.ts │ └── third │ │ ├── ant-design-vue │ │ └── index.ts │ │ └── element │ │ └── index.ts ├── directives │ ├── index.ts │ └── lazyload.ts ├── hooks │ └── async.ts ├── image.d.ts ├── main.ts ├── router │ ├── backend.ts │ ├── index.ts │ └── not-found.ts ├── services │ ├── article.ts │ ├── category.ts │ ├── comment.ts │ ├── index.ts │ ├── reply.ts │ ├── tag.ts │ ├── user.ts │ └── validator.ts ├── shims-vue.d.ts ├── store │ ├── constants.ts │ └── index.ts ├── styles │ ├── animation.scss │ ├── antd.scss │ ├── atom.scss │ ├── common.scss │ ├── element-vars.scss │ ├── index.scss │ ├── mixins.scss │ ├── preload.scss │ ├── reset.scss │ └── vars.scss ├── types │ └── jshashes.d.ts ├── utils │ ├── date-utils.ts │ ├── dom.ts │ ├── formatter.ts │ ├── helper.ts │ ├── tree.ts │ ├── type.ts │ └── validator.ts └── views │ ├── 404 │ └── index.vue │ ├── article │ ├── comment-user-info.vue │ ├── comments.vue │ ├── index.vue │ └── md-render.scss │ ├── backend │ ├── article │ │ ├── index.module.scss │ │ └── index.vue │ ├── comment │ │ ├── all │ │ │ └── index.vue │ │ ├── review-reply │ │ │ └── index.vue │ │ └── review │ │ │ └── index.vue │ ├── index.vue │ ├── msg │ │ ├── all │ │ │ └── index.vue │ │ ├── review-reply │ │ │ └── index.vue │ │ └── review │ │ │ └── index.vue │ ├── navs.ts │ ├── styles │ │ └── avatar.scss │ └── write │ │ └── index.vue │ ├── categories │ └── index.vue │ ├── category │ └── index.vue │ ├── chat │ └── index.vue │ ├── home │ └── index.vue │ ├── jumpout │ └── index.vue │ ├── login │ └── index.vue │ ├── messages │ └── index.vue │ ├── tag │ └── index.vue │ ├── tags │ └── index.vue │ └── timeline │ └── index.vue ├── tests └── unit │ └── example.spec.ts ├── tsconfig.json ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # 环境变量 2 | VUE_APP_TITLE=文仔博客 3 | VUE_APP_BASE_API=http://106.55.9.180:8607 4 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # 开发环境变量 2 | VUE_APP_SOCKET_SERVER=http://localhost:8002 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # 生产环境变量 2 | VUE_APP_SOCKET_SERVER=https://blog.wbjiang.cn -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/**/*.d.ts 2 | dist 3 | docs 4 | vue.config.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parser: "vue-eslint-parser", 7 | parserOptions: { 8 | // for script 9 | parser: "@typescript-eslint/parser", 10 | ecmaVersion: 2020, 11 | }, 12 | extends: [ 13 | "eslint:recommended", 14 | "plugin:import/recommended", 15 | "plugin:import/typescript", // this line does the trick 16 | "plugin:vue/vue3-strongly-recommended", 17 | "@vue/typescript/recommended", 18 | "@vue/prettier", 19 | "@vue/prettier/@typescript-eslint", 20 | ], 21 | plugins: ["import"], 22 | settings: { 23 | "import/parsers": { 24 | "@typescript-eslint/parser": [".ts", ".tsx"], 25 | }, 26 | // eslint-import-resolver-webpack 27 | "import/resolver": { 28 | webpack: { 29 | // 参考 https://cli.vuejs.org/zh/guide/webpack.html#%E4%BB%A5%E4%B8%80%E4%B8%AA%E6%96%87%E4%BB%B6%E7%9A%84%E6%96%B9%E5%BC%8F%E4%BD%BF%E7%94%A8%E8%A7%A3%E6%9E%90%E5%A5%BD%E7%9A%84%E9%85%8D%E7%BD%AE 30 | config: "./node_modules/@vue/cli-service/webpack.config.js", 31 | }, 32 | }, 33 | }, 34 | rules: { 35 | "no-console": [1, { allow: ["warn", "error"] }], 36 | "no-debugger": 2, 37 | "no-case-declarations": 0, 38 | "import/order": 1, 39 | // https://eslint.vuejs.org/rules/ 40 | "vue/require-default-prop": 0, 41 | }, 42 | overrides: [ 43 | { 44 | files: ["**/__tests__/*.{j,t}s?(x)", "**/tests/unit/**/*.spec.{j,t}s?(x)"], 45 | env: { 46 | jest: true, 47 | }, 48 | }, 49 | { 50 | files: ["*.ts", "*.tsx", "*.vue"], 51 | plugins: ["@typescript-eslint"], 52 | rules: { 53 | "@typescript-eslint/no-explicit-any": [2, { ignoreRestArgs: true }], 54 | }, 55 | }, 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .eslintcache 5 | .stylelintcache 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # webpack output file 18 | output.js 19 | 20 | # Editor directories and files 21 | .idea 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabs": false, 3 | "tabWidth": 4, 4 | "endOfLine": "auto", 5 | "printWidth": 140 6 | } 7 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: stylelint配置 4 | */ 5 | module.exports = { 6 | extends: ["stylelint-config-standard", "stylelint-prettier/recommended"], 7 | plugins: ["stylelint-scss"], 8 | rules: { 9 | "max-empty-lines": 1, 10 | "selector-max-empty-lines": 0, 11 | "function-max-empty-lines": 0, 12 | "value-list-max-empty-lines": 0, 13 | "no-descending-specificity": null, 14 | "selector-pseudo-element-no-unknown": [ 15 | true, 16 | { 17 | ignorePseudoElements: ["deep"], 18 | }, 19 | ], 20 | "selector-pseudo-class-no-unknown": [ 21 | true, 22 | { 23 | ignorePseudoClasses: ["deep", "global"], 24 | }, 25 | ], 26 | "at-rule-no-unknown": [ 27 | true, 28 | { 29 | ignoreAtRules: ["mixin", "include", "extend", "at-root"] 30 | } 31 | ] 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.1 (2021-08-22) 2 | 3 | ### Performance Improvements 4 | 5 | * xss防护优化,对用户输入信息做校验 ([585d4fe](https://github.com/mcwenzai/vue3-ts-blog/commit/585d4feca7463e7492c8ce72f1f6b63cf1c6b4d3)) 6 | 7 | 8 | # 1.0.0 (2021-08-19) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * 修改ci分支名称 ([c14dda9](https://github.com/mcwenzai/vue3-ts-blog/commit/c14dda93b66e3a8891c48a81d14cfacf18c7ed28)) 14 | 15 | 16 | ### Features 17 | 18 | * 个人博客开源 ([b38eb56](https://github.com/mcwenzai/vue3-ts-blog/commit/b38eb5692233d369467b8ecded46a40550e0c9f8)) 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tusi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 联系QQ: 1002821824 2 | 3 | # vue3-ts-blog 4 | 5 | ## Project setup 6 | ``` 7 | yarn install 8 | ``` 9 | 10 | ### Compiles and hot-reloads for development 11 | ``` 12 | yarn serve 13 | ``` 14 | 15 | ### Compiles and minifies for production 16 | ``` 17 | yarn build 18 | ``` 19 | 20 | ### Run your unit tests 21 | ``` 22 | yarn test:unit 23 | ``` 24 | 25 | ### Lints and fixes files 26 | ``` 27 | yarn lint 28 | ``` 29 | 30 | ### Customize configuration 31 | See [Configuration Reference](https://cli.vuejs.org/config/). 32 | -------------------------------------------------------------------------------- /antd-theme.js: -------------------------------------------------------------------------------- 1 | // https://github.com/vueComponent/ant-design-vue/blob/master/components/style/themes/default.less 2 | module.exports = { 3 | "primary-color": "#008dff", 4 | "link-color": "#87b4e2", 5 | }; 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: babel配置 4 | */ 5 | module.exports = { 6 | presets: ["@vue/cli-plugin-babel/preset"], 7 | plugins: [ 8 | [ 9 | "import", 10 | { 11 | libraryName: "ant-design-vue", 12 | libraryDirectory: "es", 13 | // 配合按需加载以及主题定制 14 | style: true, 15 | }, 16 | ], 17 | [ 18 | "import", 19 | { 20 | libraryName: "element-plus", 21 | customStyleName: (name) => { 22 | name = name.slice(3); 23 | return `element-plus/packages/theme-chalk/src/${name}.scss`; 24 | }, 25 | }, 26 | "element", 27 | ], 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | const Configuration = { 2 | /* 3 | * Resolve and load @commitlint/config-conventional from node_modules. 4 | * Referenced packages must be installed 5 | */ 6 | extends: ["@commitlint/config-conventional"], 7 | /* 8 | * Resolve and load conventional-changelog-atom from node_modules. 9 | * Referenced packages must be installed 10 | */ 11 | // parserPreset: "conventional-changelog-atom", 12 | /* 13 | * Resolve and load @commitlint/format from node_modules. 14 | * Referenced package must be installed 15 | */ 16 | // formatter: "@commitlint/format", 17 | /* 18 | * Any rules defined here will override rules from @commitlint/config-conventional 19 | */ 20 | rules: { 21 | "type-enum": [2, "always", ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"]], 22 | }, 23 | /* 24 | * Functions that return true if commitlint should ignore the given message. 25 | */ 26 | ignores: [(commit) => commit === ""], 27 | /* 28 | * Whether commitlint uses the default ignore rules. 29 | */ 30 | defaultIgnores: true, 31 | /* 32 | * Custom URL to show upon failure 33 | */ 34 | helpUrl: "https://github.com/conventional-changelog/commitlint/#what-is-commitlint", 35 | /* 36 | * Custom prompt configs 37 | */ 38 | prompt: { 39 | messages: {}, 40 | questions: { 41 | type: { 42 | description: "please input type:", 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | module.exports = Configuration; 49 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel", 3 | transform: { 4 | "^.+\\.vue$": "vue-jest", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-ts-blog", 3 | "version": "1.0.1", 4 | "scripts": { 5 | "serve": "vue-cli-service serve", 6 | "build": "vue-cli-service build", 7 | "build:analyze": "cross-env npm_config_report=true yarn build", 8 | "test:unit": "vue-cli-service test:unit", 9 | "lint": "vue-cli-service lint", 10 | "vue-ui": "vue ui", 11 | "webpack-output": "vue inspect > output.js", 12 | "version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md", 13 | "postinstall": "husky install", 14 | "prepare": "husky install" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mcwenzai/vue3-ts-blog.git" 19 | }, 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/mcwenzai/vue3-ts-blog/issues" 23 | }, 24 | "homepage": "https://blog.wbjiang.cn/", 25 | "dependencies": { 26 | "ant-design-vue": "^2.1.6", 27 | "axios": "^0.21.1", 28 | "bezier-easing": "^2.1.0", 29 | "core-js": "^3.6.5", 30 | "dayjs": "^1.10.5", 31 | "dompurify": "^2.2.9", 32 | "element-plus": "^1.0.2-beta.48", 33 | "highlight.js": "^11.0.1", 34 | "js-cookie": "^2.2.1", 35 | "jshashes": "^1.0.8", 36 | "lodash-es": "^4.17.21", 37 | "marked": "^2.1.3", 38 | "qs": "^6.10.1", 39 | "socket.io-client": "^2.1.1", 40 | "vue": "^3.0.0", 41 | "vue-router": "^4.0.0-0", 42 | "vuex": "^4.0.0-0" 43 | }, 44 | "devDependencies": { 45 | "@commitlint/cli": "^13.1.0", 46 | "@commitlint/config-conventional": "^13.1.0", 47 | "@types/dompurify": "^2.2.2", 48 | "@types/jest": "^24.0.19", 49 | "@types/js-cookie": "^2.2.6", 50 | "@types/lodash-es": "^4.17.4", 51 | "@types/marked": "^2.0.3", 52 | "@typescript-eslint/eslint-plugin": "^4.18.0", 53 | "@typescript-eslint/parser": "^4.18.0", 54 | "@vue/cli-plugin-babel": "~4.5.0", 55 | "@vue/cli-plugin-eslint": "~4.5.0", 56 | "@vue/cli-plugin-router": "~4.5.0", 57 | "@vue/cli-plugin-typescript": "~4.5.0", 58 | "@vue/cli-plugin-unit-jest": "~4.5.0", 59 | "@vue/cli-plugin-vuex": "~4.5.0", 60 | "@vue/cli-service": "~4.5.0", 61 | "@vue/compiler-sfc": "^3.0.0", 62 | "@vue/eslint-config-prettier": "^6.0.0", 63 | "@vue/eslint-config-typescript": "^7.0.0", 64 | "@vue/test-utils": "^2.0.0-0", 65 | "babel-plugin-import": "^1.13.3", 66 | "cross-env": "^7.0.3", 67 | "cz-conventional-changelog": "^3.3.0", 68 | "eslint": "^6.7.2", 69 | "eslint-import-resolver-typescript": "^2.4.0", 70 | "eslint-import-resolver-webpack": "^0.13.1", 71 | "eslint-plugin-import": "^2.23.4", 72 | "eslint-plugin-prettier": "^3.3.1", 73 | "eslint-plugin-vue": "^7.0.0", 74 | "husky": "^7.0.1", 75 | "less": "^4.1.1", 76 | "less-loader": "^7.3.0", 77 | "lint-staged": "^9.5.0", 78 | "prettier": "^2.2.1", 79 | "sass": "^1.26.5", 80 | "sass-loader": "^8.0.2", 81 | "sass-resources-loader": "^2.2.1", 82 | "script-ext-html-webpack-plugin": "^2.1.5", 83 | "style-resources-loader": "^1.4.1", 84 | "stylelint": "^13.13.1", 85 | "stylelint-config-prettier": "^8.0.2", 86 | "stylelint-config-standard": "^22.0.0", 87 | "stylelint-prettier": "^1.2.0", 88 | "stylelint-scss": "^3.19.0", 89 | "typescript": "~4.1.5", 90 | "vue-jest": "^5.0.0-0", 91 | "webpack-bundle-analyzer": "^4.4.2" 92 | }, 93 | "lint-staged": { 94 | "*.{js,jsx,vue,ts,tsx}": [ 95 | "vue-cli-service lint", 96 | "git add" 97 | ] 98 | }, 99 | "config": { 100 | "commitizen": { 101 | "path": "./node_modules/cz-conventional-changelog" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcwenzai/vue3-ts-blog/0eda2244e4a946b54090266d81c2df879b466e87/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/js/hm.js: -------------------------------------------------------------------------------- 1 | var _hmt = _hmt || []; 2 | (function () { 3 | var hm = document.createElement("script"); 4 | hm.src = "https://hm.baidu.com/hm.js?d2feba2eac8bedae244304195f7b064f"; 5 | var s = document.getElementsByTagName("script")[0]; 6 | s.parentNode.insertBefore(hm, s); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 12 | 13 | 43 | 44 | 52 | -------------------------------------------------------------------------------- /src/assets/img/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcwenzai/vue3-ts-blog/0eda2244e4a946b54090266d81c2df879b466e87/src/assets/img/avatar.jpg -------------------------------------------------------------------------------- /src/assets/img/chat-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcwenzai/vue3-ts-blog/0eda2244e4a946b54090266d81c2df879b466e87/src/assets/img/chat-avatar.png -------------------------------------------------------------------------------- /src/assets/img/comment-avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/default_category.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcwenzai/vue3-ts-blog/0eda2244e4a946b54090266d81c2df879b466e87/src/assets/img/logo.png -------------------------------------------------------------------------------- /src/assets/img/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcwenzai/vue3-ts-blog/0eda2244e4a946b54090266d81c2df879b466e87/src/assets/img/logo2.png -------------------------------------------------------------------------------- /src/assets/img/reply-avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/img/wechat_payme.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcwenzai/vue3-ts-blog/0eda2244e4a946b54090266d81c2df879b466e87/src/assets/img/wechat_payme.jpg -------------------------------------------------------------------------------- /src/bean/base.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue"; 2 | 3 | type IndexType = string | number | symbol; 4 | 5 | export type PlainObject = Record; 6 | 7 | export type PrimitiveType = number | string | boolean | undefined | null | symbol; 8 | 9 | export type GeneralFunction = (...args: any[]) => T; 10 | 11 | export interface PlainNode extends PlainObject { 12 | id: number; 13 | } 14 | 15 | export interface TreeNode extends PlainObject { 16 | key: string; 17 | children?: this[]; 18 | } 19 | 20 | export type Lazy = () => Promise; 21 | 22 | export type DefineComponentOptions = Parameters[0]; 23 | -------------------------------------------------------------------------------- /src/bean/dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: DTO 4 | */ 5 | import { PlainObject } from "./base"; 6 | 7 | export interface RecordDTO extends PlainObject { 8 | id: number; 9 | } 10 | 11 | export interface ArticleDTO extends RecordDTO { 12 | article_name: string; 13 | article_text: string; 14 | author: string; 15 | poster: string; 16 | summary: string; 17 | read_num: number; 18 | create_time: string; 19 | private: 0 | 1; 20 | tags: TagCamelCaseDTO[]; 21 | categories: CategoryCamelCaseDTO[]; 22 | } 23 | 24 | export interface CategoryCamelCaseDTO extends RecordDTO { 25 | categoryName: string; 26 | } 27 | export interface TagCamelCaseDTO extends RecordDTO { 28 | tagName: string; 29 | } 30 | 31 | export interface CategoryDTO extends RecordDTO { 32 | category_name: string; 33 | } 34 | 35 | export interface TagDTO extends RecordDTO { 36 | tag_name: string; 37 | } 38 | 39 | export interface CommentDTO extends RecordDTO { 40 | site_url: string; 41 | nick_name: string; 42 | avatar: string; 43 | create_time: string; 44 | content: string; 45 | article_id: number; 46 | article_name: string; 47 | email: string; 48 | jump_url: string; 49 | replies: ReplyDTO[]; 50 | } 51 | 52 | export interface ReplyDTO extends RecordDTO { 53 | reply_name: string; 54 | nick_name: string; 55 | avatar: string; 56 | create_time: string; 57 | content: string; 58 | email: string; 59 | jump_url: string; 60 | site_url: string; 61 | article_id: number; 62 | article_name: string; 63 | } 64 | 65 | export interface CommentUserInfo extends PlainObject { 66 | email: string; 67 | nick_name: string; 68 | site_url: string; 69 | } 70 | 71 | export interface UserDTO extends RecordDTO { 72 | role_id: number; 73 | role_name: string; 74 | user_name: string; 75 | avatar: string; 76 | last_login_time: string; 77 | } 78 | -------------------------------------------------------------------------------- /src/bean/xhr.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: xhr相关类型定义 4 | */ 5 | import { PlainObject, PrimitiveType } from "./base"; 6 | import { RecordDTO } from "./dto"; 7 | 8 | // 条件Model 9 | export interface QueryPageModel extends PlainObject { 10 | pageNo: number; 11 | pageSize: number; 12 | } 13 | 14 | export interface QuerySearchModel extends QueryPageModel { 15 | keyword: string; 16 | } 17 | 18 | export interface QueryHotColumnModel extends PlainObject { 19 | count: number; 20 | } 21 | 22 | export interface QueryCategoryModel extends PlainObject { 23 | getCount?: boolean; 24 | } 25 | 26 | export interface QueryTagModel extends PlainObject { 27 | getCount?: boolean; 28 | } 29 | 30 | export interface QueryCommentPageModel extends QueryPageModel { 31 | id?: number; // 文章id 32 | } 33 | 34 | export interface LoginModel extends PlainObject { 35 | userName: string; 36 | password: string; 37 | captcha: string; 38 | } 39 | 40 | export interface UpdateArticlePrivateModel extends PlainObject { 41 | id: number; 42 | private: 0 | 1; 43 | } 44 | 45 | export interface UpdateArticleDeletedModel extends PlainObject { 46 | id: number; 47 | deleted: 0 | 1; 48 | } 49 | 50 | // 响应Model 51 | export interface CommonResponse { 52 | code: string; 53 | extra?: PlainObject | null; 54 | msg?: string; 55 | data?: T | T[]; 56 | } 57 | 58 | export interface ArrayResponse extends CommonResponse { 59 | data: T[]; 60 | } 61 | 62 | export interface PageResponse extends CommonResponse { 63 | data: T[]; 64 | total: number; 65 | } 66 | 67 | export interface RecordResponse extends CommonResponse { 68 | data: T; 69 | } 70 | 71 | export interface PlainResponse extends CommonResponse { 72 | data: T; 73 | } 74 | -------------------------------------------------------------------------------- /src/components/base-layout/base-footer.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 87 | 88 | 162 | -------------------------------------------------------------------------------- /src/components/base-layout/base-menu.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 68 | 69 | 158 | -------------------------------------------------------------------------------- /src/components/base-layout/hot-column.vue: -------------------------------------------------------------------------------- 1 | 5 | 30 | 31 | 64 | 65 | 105 | -------------------------------------------------------------------------------- /src/components/base-layout/index.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 149 | 150 | 206 | -------------------------------------------------------------------------------- /src/components/bottom-tips/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 13 | 14 | 44 | 45 | 76 | -------------------------------------------------------------------------------- /src/components/button/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 13 | 14 | 34 | -------------------------------------------------------------------------------- /src/components/card/card-article.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 67 | 68 | 137 | -------------------------------------------------------------------------------- /src/components/icon-svg/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 50 | 51 | 59 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from "vue"; 2 | 3 | // 自动注册全局组件 4 | export default { 5 | install(app: App): App { 6 | const componentsContext = require.context("./", true, /index.(vue|ts|tsx)$/); 7 | componentsContext.keys().forEach((fileName) => { 8 | const componentConfig = componentsContext(fileName).default; 9 | if (/.(vue|tsx)$/.test(fileName)) { 10 | app.component(componentConfig.name, componentConfig); 11 | } else if (fileName !== "./index.ts") { 12 | app.use(componentConfig); 13 | } 14 | }); 15 | return app; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/third/ant-design-vue/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 全局按需引入 antd 组件,对于特别需要优化的情况,可以拆出部分组件到特定页面加载 4 | */ 5 | import { App } from "vue"; 6 | import { Button, Space, Row, Col, Skeleton, Empty } from "ant-design-vue"; 7 | 8 | const components = [Button, Space, Row, Col, Skeleton, Empty]; 9 | 10 | export default { 11 | install(app: App): App { 12 | components.forEach((comp) => { 13 | app.use(comp); 14 | }); 15 | return app; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/third/element/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: 蒋文斌 3 | * @Date: 2021-04-25 19:56:29 4 | * @LastEditors: 蒋文斌 5 | * @LastEditTime: 2021-06-22 20:45:52 6 | * @Description: 全局按需引入 element-plus 组件 7 | */ 8 | 9 | import { App } from "vue"; 10 | 11 | import { ElImage } from "element-plus"; 12 | 13 | import "element-plus/packages/theme-chalk/src/base.scss"; 14 | 15 | const components = [ElImage]; 16 | 17 | export default { 18 | install(app: App): App { 19 | components.forEach((comp) => { 20 | app.component(comp.name, comp); 21 | }); 22 | return app; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import { App } from "vue"; 2 | import Lazyload from "./lazyload"; 3 | 4 | export default { 5 | install(app: App): void { 6 | app.directive("lazyload", Lazyload); 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/directives/lazyload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 图片懒加载 4 | */ 5 | 6 | import { throttle } from "lodash-es"; 7 | import { getOffset } from "@/utils/dom"; 8 | import { GeneralFunction } from "@/bean/base"; 9 | 10 | function inView(element: HTMLElement) { 11 | // + 20 是为了防止底部加载不出来,另外也是为了提前一点点加载出来 12 | return element.style.display !== "none" && window.innerHeight + document.documentElement.scrollTop + 20 >= getOffset(element).offsetTop; 13 | } 14 | 15 | interface LazyloadElement extends HTMLElement { 16 | lazyloadHelper: LazyloadHelper; 17 | } 18 | 19 | class LazyloadHelper { 20 | private el: HTMLElement; 21 | private onScrollThrottle: GeneralFunction; 22 | 23 | constructor(el: HTMLElement) { 24 | this.el = el; 25 | this.onScrollThrottle = throttle(this.onScroll, 300, { leading: true }); 26 | } 27 | 28 | setSrc() { 29 | if (!this.el.getAttribute("src") && inView(this.el)) { 30 | this.el.setAttribute("src", this.el.getAttribute("data-src") || ""); 31 | } 32 | } 33 | 34 | onScroll() { 35 | this.setSrc(); 36 | } 37 | 38 | handleInserted() { 39 | this.setSrc(); 40 | window.addEventListener("scroll", this.onScrollThrottle); 41 | } 42 | 43 | handleUpdated() { 44 | this.setSrc(); 45 | } 46 | 47 | handleBeforeUnmount() { 48 | window.removeEventListener("scroll", this.onScrollThrottle); 49 | } 50 | } 51 | 52 | export default { 53 | mounted(el: LazyloadElement): void { 54 | el.lazyloadHelper = new LazyloadHelper(el); 55 | el.lazyloadHelper.handleInserted(); 56 | }, 57 | updated(el: LazyloadElement): void { 58 | el.lazyloadHelper.handleUpdated(); 59 | }, 60 | beforeUnmount(el: LazyloadElement): void { 61 | el.lazyloadHelper.handleBeforeUnmount(); 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/hooks/async.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 异步 Hook,用于 loading, error 等状态反馈 4 | */ 5 | import { Ref, ref } from "vue"; 6 | import { GeneralFunction } from "@/bean/base"; 7 | 8 | interface AsyncLoadingResponse { 9 | trigger: GeneralFunction; 10 | loading: Ref; 11 | isError: Ref; 12 | error: Ref; 13 | } 14 | 15 | export const useAsyncLoading = (fn: GeneralFunction>): AsyncLoadingResponse => { 16 | const loading = ref(false); 17 | const isError = ref(false); 18 | const error = ref(); 19 | const trigger = async (...args: any[]) => { 20 | try { 21 | loading.value = true; 22 | await fn(...args); 23 | } catch (err) { 24 | isError.value = true; 25 | error.value = err; 26 | } finally { 27 | loading.value = false; 28 | } 29 | }; 30 | return { trigger, loading, isError, error }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/image.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg"; 2 | declare module "*.png"; 3 | declare module "*.jpg"; 4 | declare module "*.jpeg"; 5 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 入口文件 4 | */ 5 | import "./styles/index.scss"; 6 | import { createApp } from "vue"; 7 | import App from "./App.vue"; 8 | import router from "./router"; 9 | import store, { key } from "./store"; 10 | import globalComponents from "./components"; 11 | // import globalDirectives from "./directives"; 12 | import { init } from "./utils/date-utils"; 13 | init(); 14 | 15 | export const app = createApp(App); 16 | 17 | app.use(store, key); 18 | app.use(router); 19 | // app.use(globalDirectives); 20 | app.use(globalComponents); 21 | app.mount("#app"); 22 | -------------------------------------------------------------------------------- /src/router/backend.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 后台路由 4 | */ 5 | 6 | import { RouteRecordRaw } from "vue-router"; 7 | 8 | export const backendRoute: RouteRecordRaw = { 9 | path: "/backend", 10 | name: "Backend", 11 | component: () => import(/* webpackChunkName: "backend" */ "@/views/backend/index.vue"), 12 | meta: { 13 | auth: true, 14 | isAdmin: true, 15 | }, 16 | children: [ 17 | // 文章 18 | { 19 | path: "", 20 | component: () => import(/* webpackChunkName: "backend-article" */ "@/views/backend/article/index.vue"), 21 | meta: { 22 | title: "所有文章", 23 | }, 24 | }, 25 | { 26 | path: "write", 27 | component: () => import(/* webpackChunkName: "write" */ "@/views/backend/write/index.vue"), 28 | meta: { 29 | title: "开始创作", 30 | }, 31 | }, 32 | { 33 | path: "article/edit/:id", 34 | component: () => import(/* webpackChunkName: "article-edit" */ "@/views/backend/write/index.vue"), 35 | meta: { 36 | title: "修改文章", 37 | }, 38 | }, 39 | // 留言 40 | { 41 | path: "all-msg", 42 | component: () => import(/* webpackChunkName: "all-msg" */ "@/views/backend/msg/all/index.vue"), 43 | meta: { 44 | title: "所有留言", 45 | }, 46 | }, 47 | { 48 | path: "review-msg", 49 | component: () => import(/* webpackChunkName: "review-msg" */ "@/views/backend/msg/review/index.vue"), 50 | meta: { 51 | title: "审核留言", 52 | }, 53 | }, 54 | { 55 | path: "review-msg-reply", 56 | component: () => import(/* webpackChunkName: "review-msg-reply" */ "@/views/backend/msg/review-reply/index.vue"), 57 | meta: { 58 | title: "审核留言回复", 59 | }, 60 | }, 61 | // 评论 62 | { 63 | path: "all-comment", 64 | component: () => import(/* webpackChunkName: "all-comment" */ "@/views/backend/comment/all/index.vue"), 65 | meta: { 66 | title: "所有评论", 67 | }, 68 | }, 69 | { 70 | path: "review-comment", 71 | component: () => import(/* webpackChunkName: "review-comment" */ "@/views/backend/comment/review/index.vue"), 72 | meta: { 73 | title: "审核评论", 74 | }, 75 | }, 76 | { 77 | path: "review-comment-reply", 78 | component: () => import(/* webpackChunkName: "review-comment-reply" */ "@/views/backend/comment/review-reply/index.vue"), 79 | meta: { 80 | title: "审核评论回复", 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 路由配置 4 | */ 5 | import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router"; 6 | import Cookies from "js-cookie"; 7 | import { backendRoute } from "./backend"; 8 | import { FALLBACK_ROUTE, NOT_FOUND_ROUTE } from "./not-found"; 9 | import store from "@/store"; 10 | import { SET_USER_INFO } from "@/store/constants"; 11 | 12 | const routes: Array = [ 13 | { 14 | path: "/", 15 | name: "Home", 16 | component: () => import(/* webpackChunkName: "home" */ "@/views/home/index.vue"), 17 | meta: { 18 | auth: false, 19 | title: "首页", 20 | }, 21 | }, 22 | { 23 | path: "/categories", 24 | name: "Categoryies", 25 | component: () => import(/* webpackChunkName: "categories" */ "@/views/categories/index.vue"), 26 | meta: { 27 | auth: false, 28 | title: "所有分类", 29 | }, 30 | }, 31 | { 32 | path: "/category/:name", 33 | name: "Category", 34 | component: () => import(/* webpackChunkName: "category" */ "@/views/category/index.vue"), 35 | meta: { 36 | auth: false, 37 | title: "分类", 38 | }, 39 | }, 40 | { 41 | path: "/tags", 42 | name: "Tags", 43 | component: () => import(/* webpackChunkName: "tags" */ "@/views/tags/index.vue"), 44 | meta: { 45 | auth: false, 46 | title: "所有标签", 47 | }, 48 | }, 49 | { 50 | path: "/tag/:name", 51 | name: "Tag", 52 | component: () => import(/* webpackChunkName: "tag" */ "@/views/tag/index.vue"), 53 | meta: { 54 | auth: false, 55 | title: "标签", 56 | }, 57 | }, 58 | { 59 | path: "/timeline", 60 | name: "Timeline", 61 | component: () => import(/* webpackChunkName: "timeline" */ "@/views/timeline/index.vue"), 62 | meta: { 63 | auth: false, 64 | title: "时间轴", 65 | }, 66 | }, 67 | { 68 | path: "/article/:id", 69 | name: "Article", 70 | component: () => import(/* webpackChunkName: "article" */ "@/views/article/index.vue"), 71 | meta: { 72 | auth: false, 73 | title: "文章详情", 74 | }, 75 | }, 76 | { 77 | path: "/jumpout/:target", 78 | name: "Jumpout", 79 | component: () => import(/* webpackChunkName: "jumpout" */ "@/views/jumpout/index.vue"), 80 | meta: { 81 | auth: false, 82 | title: "即将离开博客", 83 | }, 84 | }, 85 | { 86 | path: "/messages", 87 | name: "Messages", 88 | component: () => import(/* webpackChunkName: "messages" */ "@/views/messages/index.vue"), 89 | meta: { 90 | auth: false, 91 | title: "留言", 92 | }, 93 | }, 94 | { 95 | path: "/chat", 96 | name: "Chat", 97 | component: () => import(/* webpackChunkName: "chat" */ "@/views/chat/index.vue"), 98 | meta: { 99 | auth: false, 100 | title: "在线聊天室", 101 | }, 102 | }, 103 | { 104 | path: "/login", 105 | name: "Login", 106 | component: () => import(/* webpackChunkName: "login" */ "@/views/login/index.vue"), 107 | meta: { 108 | auth: false, 109 | title: "登录", 110 | }, 111 | }, 112 | backendRoute, 113 | NOT_FOUND_ROUTE, 114 | FALLBACK_ROUTE, 115 | ]; 116 | 117 | const router = createRouter({ 118 | history: createWebHistory(process.env.BASE_URL), 119 | routes, 120 | scrollBehavior(to, from, savedPosition) { 121 | if (savedPosition) { 122 | return savedPosition; 123 | } else { 124 | return { top: 0 }; 125 | } 126 | }, 127 | }); 128 | 129 | const clearUserInfo = () => { 130 | store.commit(SET_USER_INFO, null); 131 | }; 132 | 133 | router.beforeEach((to, from, next) => { 134 | // 基本校验 135 | if (to.meta.auth) { 136 | // 需要鉴权的页面,进行前端检查,主要检查cookie中有没有标志位islogined 137 | // 后端依旧需要对需要鉴权的接口访问做校验,不能依赖前端的判定 138 | const isLogined = Cookies.get("islogined"); 139 | if (isLogined === "1") { 140 | next(); 141 | } else { 142 | clearUserInfo(); 143 | next("/login"); 144 | } 145 | } else { 146 | next(); 147 | } 148 | }); 149 | 150 | export default router; 151 | -------------------------------------------------------------------------------- /src/router/not-found.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 404路由 4 | */ 5 | import { RouteRecordRaw } from "vue-router"; 6 | 7 | export const NOT_FOUND_ROUTE: RouteRecordRaw = { 8 | name: "NotFound", 9 | path: "/404", 10 | component: () => import(/* webpackChunkName: "not-found" */ "@/views/404/index.vue"), 11 | meta: { 12 | auto: false, 13 | title: "页面找不到了", 14 | }, 15 | }; 16 | 17 | export const FALLBACK_ROUTE = { 18 | name: "Fallback", 19 | path: "/:pathMatch(.*)*", 20 | redirect: "/404", 21 | }; 22 | -------------------------------------------------------------------------------- /src/services/article.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 文章服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { 7 | ArrayResponse, 8 | PageResponse, 9 | QueryPageModel, 10 | QuerySearchModel, 11 | QueryHotColumnModel, 12 | RecordResponse, 13 | UpdateArticlePrivateModel, 14 | UpdateArticleDeletedModel, 15 | } from "@/bean/xhr"; 16 | import { ArticleDTO } from "@/bean/dto"; 17 | import { PlainObject } from "@/bean/base"; 18 | 19 | class ArticleService extends ApiService { 20 | public page(params: QueryPageModel) { 21 | return this.$get>("page", params); 22 | } 23 | 24 | public pageAdmin(params: QueryPageModel) { 25 | return this.$get>("page_admin", params); 26 | } 27 | 28 | public detail(id: number) { 29 | return this.$get>("detail", { id }); 30 | } 31 | 32 | public pageByCategory(params: QuerySearchModel) { 33 | return this.$get>("page_by_category", params); 34 | } 35 | 36 | public pageByTag(params: QuerySearchModel) { 37 | return this.$get>("page_by_tag", params); 38 | } 39 | 40 | public updateReadNum(id: number) { 41 | return this.$put("update_read_num", { id }); 42 | } 43 | 44 | public topRead(params: QueryHotColumnModel) { 45 | return this.$get>("top_read", params); 46 | } 47 | 48 | public neighbors(id: number) { 49 | return this.$get>("neighbors", { id }); 50 | } 51 | 52 | public updatePrivate(params: UpdateArticlePrivateModel) { 53 | return this.$put("update_private", params); 54 | } 55 | 56 | public updateDeleted(params: UpdateArticleDeletedModel) { 57 | return this.$put("update_deleted", params); 58 | } 59 | 60 | public delete(id: number) { 61 | return this.$del("delete", { id }); 62 | } 63 | 64 | public add(params: PlainObject) { 65 | return this.$postJson("add", params); 66 | } 67 | 68 | public update(params: PlainObject) { 69 | return this.$putJson("update", params); 70 | } 71 | } 72 | 73 | export const articleService = new ArticleService("article"); 74 | -------------------------------------------------------------------------------- /src/services/category.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 分类服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { ArrayResponse, QueryCategoryModel } from "@/bean/xhr"; 7 | import { CategoryDTO } from "@/bean/dto"; 8 | 9 | class CategoryService extends ApiService { 10 | public all(params?: QueryCategoryModel) { 11 | return this.$get>("all", params); 12 | } 13 | } 14 | 15 | export const categoryService = new CategoryService("category"); 16 | -------------------------------------------------------------------------------- /src/services/comment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 评论服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { PageResponse, PlainResponse, QueryCommentPageModel, QueryPageModel, RecordResponse } from "@/bean/xhr"; 7 | import { CommentDTO } from "@/bean/dto"; 8 | import { PlainObject } from "@/bean/base"; 9 | 10 | class CommentService extends ApiService { 11 | public page(params: QueryCommentPageModel) { 12 | return this.$get>("page", params); 13 | } 14 | 15 | public detail(id: number) { 16 | return this.$get>("detail", { id }); 17 | } 18 | 19 | public add(params: PlainObject) { 20 | return this.$post("add", params); 21 | } 22 | 23 | public numberOfPeople() { 24 | return this.$get>("number_of_people"); 25 | } 26 | 27 | public total() { 28 | return this.$get>("total"); 29 | } 30 | 31 | public pageAdmin(params: QueryPageModel) { 32 | return this.$get>("page_admin", params); 33 | } 34 | 35 | public update(params: PlainObject) { 36 | return this.$put("update", params); 37 | } 38 | 39 | public delete(id: number) { 40 | return this.$del("delete", { id }); 41 | } 42 | 43 | public pageNotApproved(params: QueryPageModel) { 44 | return this.$get>("page_not_approved", params); 45 | } 46 | 47 | public review(params: PlainObject) { 48 | return this.$put("review", params); 49 | } 50 | } 51 | 52 | export const commentService = new CommentService("comment"); 53 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-case-declarations */ 2 | /* eslint-disable camelcase */ 3 | import axios from "axios"; 4 | import qs from "qs"; 5 | import { message } from "ant-design-vue"; 6 | import { CommonResponse } from "@/bean/xhr"; 7 | import { PlainObject } from "@/bean/base"; 8 | import { requestParamsFilter } from "@/utils/helper"; 9 | import router from "@/router"; 10 | 11 | enum InnerCode { 12 | Unauthorized = "000001", 13 | TokenExpired = "000002", 14 | Forbidden = "000003", 15 | } 16 | 17 | const api = axios.create({ 18 | baseURL: process.env.VUE_APP_BASE_API, 19 | timeout: 20000, 20 | }); 21 | 22 | // axios初始化配置 23 | api.defaults.headers.common["Content-Type"] = "application/x-www-form-urlencoded"; 24 | api.defaults.transformRequest = (data) => { 25 | return qs.stringify(data, { encode: true }); 26 | }; 27 | 28 | api.interceptors.request.use((config) => { 29 | return config; 30 | }); 31 | 32 | // 返回状态拦截 33 | api.interceptors.response.use( 34 | (response) => { 35 | const res = response.data; 36 | const { code, msg } = res; 37 | if (code === "0") { 38 | // code 为 0 代表返回正常返回 39 | return Promise.resolve(res); 40 | } else { 41 | // inner code handler 42 | switch (code) { 43 | case InnerCode.Unauthorized: 44 | case InnerCode.TokenExpired: 45 | case InnerCode.Forbidden: 46 | router.push("/login"); 47 | break; 48 | } 49 | if (msg) { 50 | message.error(msg); 51 | } 52 | return Promise.reject(res); 53 | } 54 | }, 55 | (error) => { 56 | console.error(error.response); 57 | if (error.toString().indexOf("timeout") !== -1) { 58 | // 超时 59 | message.error("网络请求超时,请检查网络连接!"); 60 | } else if (error.response) { 61 | switch (error.response.status) { 62 | // http status handler 63 | case 400: // 客户端请求有误 64 | message.error("客户端请求有误,请联系管理员!"); 65 | break; 66 | case 401: // 未授权 67 | message.error("未授权,请联系管理员!"); 68 | break; 69 | case 403: // 禁止访问 70 | message.error("禁止访问!"); 71 | break; 72 | case 404: // 找不到 73 | message.error("访问的资源不存在,请稍后重试!"); 74 | break; 75 | case 502: // bad gateway 76 | case 503: // service unavailable 77 | case 504: // gateway timeout 78 | message.error("服务器维护中,请稍后重试!"); 79 | break; 80 | case 500: // 服务器内部错误 81 | default: 82 | const errmsg = error.response.data.message; 83 | if (errmsg) { 84 | message.error(errmsg); 85 | } else { 86 | message.error("系统内部错误!"); 87 | } 88 | break; 89 | } 90 | } 91 | return Promise.reject(error.response); 92 | } 93 | ); 94 | 95 | export class ApiService { 96 | // 特性 97 | private feature: string; 98 | 99 | constructor(feature: string) { 100 | this.feature = feature; 101 | } 102 | 103 | // get请求 104 | protected $get(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 105 | return api.get(`/${this.feature}/${action}`, { 106 | ...config, 107 | params: requestParamsFilter(params, true), 108 | }); 109 | } 110 | 111 | // delete请求 112 | protected $del(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 113 | return api.delete(`/${this.feature}/${action}`, { 114 | ...config, 115 | params: requestParamsFilter(params, true), 116 | }); 117 | } 118 | 119 | // delete application/json请求 120 | protected $delJson(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 121 | const defaultConfig = { 122 | headers: { "Content-Type": "application/json" }, 123 | transformRequest: (data: PlainObject) => JSON.stringify(data), 124 | }; 125 | return api.delete(`/${this.feature}/${action}`, { 126 | ...defaultConfig, 127 | ...config, 128 | data: requestParamsFilter(params), 129 | }); 130 | } 131 | 132 | // post请求 133 | protected $post(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 134 | return api.post(`/${this.feature}/${action}`, requestParamsFilter(params), config); 135 | } 136 | 137 | // post application/json请求 138 | protected $postJson(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 139 | const defaultConfig = { 140 | headers: { "Content-Type": "application/json" }, 141 | transformRequest: (data: PlainObject) => JSON.stringify(data), 142 | }; 143 | return this.$post(action, params, { ...defaultConfig, ...config }); 144 | } 145 | 146 | // 上传请求,formdata 147 | protected $upload( 148 | action: string, 149 | params: FormData = new FormData(), 150 | config: PlainObject = { 151 | headers: { "Content-Type": "multipart/form-data" }, 152 | transformRequest: null, 153 | } 154 | ): Promise { 155 | return api.post(`/${this.feature}/${action}`, params, config); 156 | } 157 | 158 | // put请求 159 | protected $put(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 160 | return api.put(`/${this.feature}/${action}`, requestParamsFilter(params), config); 161 | } 162 | 163 | // put application/json请求 164 | protected $putJson(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 165 | const defaultConfig = { 166 | headers: { "Content-Type": "application/json" }, 167 | transformRequest: (data: PlainObject) => JSON.stringify(data), 168 | }; 169 | return this.$put(action, params, { ...defaultConfig, ...config }); 170 | } 171 | 172 | // patch请求 173 | protected $patch(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 174 | return api.patch(`/${this.feature}/${action}`, requestParamsFilter(params), config); 175 | } 176 | 177 | // patch application/json请求 178 | protected $patchJson(action: string, params: PlainObject = {}, config: PlainObject = {}): Promise { 179 | const defaultConfig = { 180 | headers: { "Content-Type": "application/json" }, 181 | transformRequest: (data: PlainObject) => JSON.stringify(data), 182 | }; 183 | return this.$patch(action, params, { ...defaultConfig, ...config }); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/services/reply.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 回复服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { PlainObject } from "@/bean/base"; 7 | import { PageResponse, QueryPageModel } from "@/bean/xhr"; 8 | import { ReplyDTO } from "@/bean/dto"; 9 | 10 | class ReplyService extends ApiService { 11 | public add(params: PlainObject) { 12 | return this.$post("add", params); 13 | } 14 | 15 | public unreviewdReplyPage(params: QueryPageModel) { 16 | return this.$get>("unreviewd_reply_page", params); 17 | } 18 | 19 | public review(params: PlainObject) { 20 | return this.$put("review", params); 21 | } 22 | } 23 | 24 | export const replyService = new ReplyService("reply"); 25 | -------------------------------------------------------------------------------- /src/services/tag.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 标签服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { ArrayResponse, QueryTagModel } from "@/bean/xhr"; 7 | import { TagDTO } from "@/bean/dto"; 8 | 9 | class TagService extends ApiService { 10 | public all(params: QueryTagModel) { 11 | return this.$get>("all", params); 12 | } 13 | } 14 | 15 | export const tagService = new TagService("tag"); 16 | -------------------------------------------------------------------------------- /src/services/user.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 用户服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { LoginModel, RecordResponse } from "@/bean/xhr"; 7 | import { UserDTO } from "@/bean/dto"; 8 | 9 | class UserService extends ApiService { 10 | public login(params: LoginModel) { 11 | return this.$put>("login", params); 12 | } 13 | 14 | public current() { 15 | return this.$get>("current"); 16 | } 17 | 18 | public logout() { 19 | return this.$put("logout"); 20 | } 21 | } 22 | 23 | export const userService = new UserService("user"); 24 | -------------------------------------------------------------------------------- /src/services/validator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 验证码服务 4 | */ 5 | import { ApiService } from "@/services/index"; 6 | import { PlainResponse } from "@/bean/xhr"; 7 | 8 | class ValidatorService extends ApiService { 9 | public imgCode() { 10 | return this.$get>("img_code"); 11 | } 12 | } 13 | 14 | export const validatorService = new ValidatorService("validator"); 15 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | 8 | declare module "*.scss"; -------------------------------------------------------------------------------- /src/store/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: vuex 常量 4 | */ 5 | // Root Mutations 6 | export const SET_IS_MENU_VISIBLE = "setIsMenuVisible"; 7 | 8 | export const SET_COMMENT_USER_INFO = "setCommentUserInfo"; 9 | 10 | export const SET_USER_INFO = "setUserInfo"; 11 | 12 | // Root Actions 13 | export const LOGIN_ACTION = "loginAction"; 14 | 15 | export const LOGOUT_ACTION = "logoutAction"; 16 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 状态管理 4 | */ 5 | import { InjectionKey } from "vue"; 6 | import { createStore, Store, ActionContext } from "vuex"; 7 | import Cookies from "js-cookie"; 8 | import { SET_IS_MENU_VISIBLE, SET_COMMENT_USER_INFO, SET_USER_INFO, LOGIN_ACTION, LOGOUT_ACTION } from "./constants"; 9 | import { CommentUserInfo, UserDTO } from "@/bean/dto"; 10 | import { userService } from "@/services/user"; 11 | import { LoginModel } from "@/bean/xhr"; 12 | 13 | export interface RootState { 14 | isMenuVisible: boolean; 15 | commentUserInfo: CommentUserInfo | null; 16 | userInfo: UserDTO | null; 17 | } 18 | 19 | export const key: InjectionKey> = Symbol(); 20 | 21 | let commentUserInfo = null; 22 | const commentUserInfoInStorage = localStorage.getItem("commentUserInfo"); 23 | if (commentUserInfoInStorage) { 24 | commentUserInfo = JSON.parse(commentUserInfoInStorage); 25 | } 26 | 27 | let userInfo = null; 28 | const userInfoInStorage = localStorage.getItem("userInfo"); 29 | if (userInfoInStorage) { 30 | userInfo = JSON.parse(userInfoInStorage); 31 | } 32 | 33 | const store = createStore({ 34 | state: { 35 | isMenuVisible: false, 36 | commentUserInfo, 37 | userInfo, 38 | }, 39 | mutations: { 40 | [SET_IS_MENU_VISIBLE](state: RootState, payload: boolean): void { 41 | state.isMenuVisible = payload; 42 | if (payload) { 43 | document.body.style.overflow = "hidden"; 44 | } else { 45 | document.body.style.overflow = ""; 46 | } 47 | }, 48 | [SET_COMMENT_USER_INFO](state: RootState, payload: CommentUserInfo): void { 49 | if (payload) { 50 | state.commentUserInfo = payload; 51 | localStorage.setItem("commentUserInfo", JSON.stringify(payload)); 52 | } else { 53 | state.commentUserInfo = null; 54 | localStorage.removeItem("commentUserInfo"); 55 | } 56 | }, 57 | [SET_USER_INFO](state: RootState, payload: UserDTO): void { 58 | if (payload) { 59 | state.userInfo = payload; 60 | localStorage.setItem("userInfo", JSON.stringify(payload)); 61 | } else { 62 | state.userInfo = null; 63 | localStorage.removeItem("userInfo"); 64 | } 65 | }, 66 | }, 67 | actions: { 68 | // 用户登录 69 | async [LOGIN_ACTION]({ commit }: ActionContext, payload: LoginModel): Promise { 70 | const res = await userService.login(payload); 71 | const userInfo = res.data; 72 | commit(SET_USER_INFO, userInfo); 73 | return userInfo; 74 | }, 75 | // 用户登出 76 | async [LOGOUT_ACTION]({ commit }: ActionContext): Promise { 77 | await userService.logout(); 78 | commit(SET_USER_INFO, null); 79 | }, 80 | }, 81 | }); 82 | 83 | export const checkAuthState = (): void => { 84 | const isLogined = Cookies.get("islogined"); 85 | if (isLogined !== "1") { 86 | // islogined失效 87 | store.commit(SET_USER_INFO, null); 88 | } else { 89 | // islogined有效,获取最新user信息 90 | userService.current().then((res) => { 91 | store.commit(SET_USER_INFO, res.data); 92 | }); 93 | } 94 | }; 95 | 96 | // 初始化时检查一次 97 | checkAuthState(); 98 | 99 | export default store; 100 | -------------------------------------------------------------------------------- /src/styles/animation.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | /*! 4 | * animate.css -https://daneden.github.io/animate.css/ 5 | * Version - 3.7.2 6 | * Licensed under the MIT license - http://opensource.org/licenses/MIT 7 | * 8 | * Copyright (c) 2019 Daniel Eden 9 | */ 10 | @keyframes slideOutLeft { 11 | from { 12 | transform: translate3d(230px, 0, 0); 13 | } 14 | to { 15 | transform: translate3d(0, 0, 0); 16 | } 17 | } 18 | 19 | .slideOutLeft { 20 | animation-name: slideOutLeft; 21 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); 22 | } 23 | 24 | @keyframes slideInLeft { 25 | from { 26 | transform: translate3d(0, 0, 0); 27 | } 28 | to { 29 | transform: translate3d(230px, 0, 0); 30 | } 31 | } 32 | 33 | .slideInLeft { 34 | animation-name: slideInLeft; 35 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); 36 | } 37 | 38 | .animated { 39 | animation-duration: 1s; 40 | animation-fill-mode: both; 41 | } 42 | 43 | .animated.infinite { 44 | animation-iteration-count: infinite; 45 | } 46 | 47 | .animated.delay-1s { 48 | animation-delay: 1s; 49 | } 50 | 51 | .animated.delay-2s { 52 | animation-delay: 2s; 53 | } 54 | 55 | .animated.delay-3s { 56 | animation-delay: 3s; 57 | } 58 | 59 | .animated.delay-4s { 60 | animation-delay: 4s; 61 | } 62 | 63 | .animated.delay-5s { 64 | animation-delay: 5s; 65 | } 66 | 67 | .animated.fast { 68 | animation-duration: 800ms; 69 | } 70 | 71 | .animated.faster { 72 | animation-duration: 500ms; 73 | } 74 | 75 | .animated.slow { 76 | animation-duration: 2s; 77 | } 78 | 79 | .animated.slower { 80 | animation-duration: 3s; 81 | } 82 | -------------------------------------------------------------------------------- /src/styles/antd.scss: -------------------------------------------------------------------------------- 1 | .ant-pagination { 2 | &.pagination-common { 3 | margin-top: 20px; 4 | text-align: center; 5 | } 6 | } 7 | 8 | .ant-drawer { 9 | &.drawer-comment { 10 | .ant-drawer-content-wrapper { 11 | max-width: 400px; 12 | } 13 | .ant-drawer-wrapper-body { 14 | display: flex; 15 | flex-direction: column; 16 | } 17 | .ant-drawer-body { 18 | flex: 1; 19 | padding: 0; 20 | display: flex; 21 | overflow: auto; 22 | } 23 | } 24 | } 25 | 26 | .ant-table { 27 | .ant-table-scroll { 28 | .ant-table-body { 29 | overflow-x: auto !important; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/atom.scss: -------------------------------------------------------------------------------- 1 | // 原子类 2 | .align-left { 3 | text-align: left; 4 | } 5 | 6 | .align-center { 7 | text-align: center; 8 | } 9 | 10 | .align-right { 11 | text-align: right; 12 | } 13 | 14 | .float-l { 15 | float: left; 16 | } 17 | 18 | .float-r { 19 | float: right; 20 | } 21 | 22 | .hidden { 23 | display: none !important; 24 | } 25 | 26 | .overflow-auto { 27 | overflow: auto; 28 | } 29 | 30 | .overflow-hidden { 31 | overflow: hidden; 32 | } 33 | 34 | .align-middle { 35 | vertical-align: middle !important; 36 | } 37 | 38 | .pointer { 39 | cursor: pointer; 40 | } 41 | 42 | .disabled { 43 | cursor: not-allowed; 44 | } 45 | 46 | .margin-0 { 47 | margin: 0 !important; 48 | } 49 | 50 | .padding-0 { 51 | padding: 0 !important; 52 | } 53 | 54 | .mt-0 { 55 | margin-top: 0 !important; 56 | } 57 | 58 | .mb-0 { 59 | margin-bottom: 0 !important; 60 | } 61 | 62 | .mt-20 { 63 | margin-top: 20px !important; 64 | } 65 | 66 | .ml-20 { 67 | margin-left: 20px !important; 68 | } 69 | 70 | .block { 71 | display: block !important; 72 | } 73 | -------------------------------------------------------------------------------- /src/styles/common.scss: -------------------------------------------------------------------------------- 1 | .flex-layout { 2 | @include flex-layout; 3 | } 4 | 5 | .flex-center { 6 | @include flex-center; 7 | } 8 | 9 | .flex-layout--column { 10 | @include flex-layout--column; 11 | } 12 | 13 | .flex-align-center { 14 | position: relative; 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .flex-1 { 20 | flex: 1; 21 | } 22 | 23 | .abs-center { 24 | position: absolute; 25 | top: 50%; 26 | left: 50%; 27 | transform: translate(-50%, -50%); 28 | } 29 | 30 | .clearfix { 31 | @include bfc-clearfix; 32 | } 33 | 34 | .ellipsis { 35 | @include one-line-ellipsis; 36 | } 37 | 38 | .icon-svg.icon--aside { 39 | @include flex-center; 40 | 41 | color: #fff; 42 | font-size: 24px; 43 | height: 100%; 44 | border-radius: 50%; 45 | background-color: rgba(102, 57, 57, 0.4); 46 | cursor: pointer; 47 | + .icon--aside { 48 | margin-top: 10px; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/styles/element-vars.scss: -------------------------------------------------------------------------------- 1 | /* 改变主题色变量,见 node_modules/element-plus/packages/theme-chalk/src/common/var.scss */ 2 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | // global styles 2 | 3 | @import "./reset.scss"; 4 | @import "./animation.scss"; 5 | @import "./atom.scss"; 6 | @import "./common.scss"; 7 | @import "./antd.scss"; 8 | -------------------------------------------------------------------------------- /src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin absolute-center { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate3d(-50%, -50%, 0); 6 | } 7 | 8 | @mixin absolute-x-center { 9 | position: absolute; 10 | left: 50%; 11 | transform: translateX(-50%); 12 | } 13 | 14 | @mixin flex-center { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | @mixin flex-layout { 21 | position: relative; 22 | display: flex; 23 | flex: 1; 24 | overflow: auto; 25 | } 26 | 27 | @mixin flex-layout--column { 28 | position: relative; 29 | display: flex; 30 | flex: 1; 31 | flex-direction: column; 32 | overflow: hidden; 33 | } 34 | 35 | @mixin flex-only--column { 36 | position: relative; 37 | display: flex; 38 | flex-direction: column; 39 | } 40 | 41 | @mixin one-line-ellipsis { 42 | overflow: hidden; 43 | text-overflow: ellipsis; 44 | white-space: nowrap; 45 | } 46 | 47 | @mixin bfc-clearfix { 48 | &::after, 49 | &::before { 50 | display: table; 51 | content: ""; 52 | } 53 | &::after { 54 | clear: both; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/styles/preload.scss: -------------------------------------------------------------------------------- 1 | @import "./vars.scss"; 2 | @import "./mixins.scss"; 3 | // 在按需加载情况下,会按需加载特定 scss,所以定制主题只要让 element-vars.scss 预先加载就行。 4 | // @import "./element-vars.scss"; 5 | -------------------------------------------------------------------------------- /src/styles/reset.scss: -------------------------------------------------------------------------------- 1 | *:fullscreen { 2 | // 必须加背景色,不然进全屏会被:not(:root):-webkit-full-screen::backdrop影响 3 | background-color: $color-white; 4 | } 5 | 6 | body { 7 | position: relative; 8 | margin: 0; 9 | padding: 0; 10 | box-sizing: border-box; 11 | font-size: 16px; 12 | -moz-osx-font-smoothing: grayscale; 13 | -webkit-font-smoothing: antialiased; 14 | text-rendering: optimizeLegibility; 15 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, 16 | Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol; 17 | } 18 | 19 | #app { 20 | height: 100%; 21 | } 22 | 23 | a, 24 | a:focus, 25 | a:hover { 26 | outline: none; 27 | text-decoration: none; 28 | } 29 | 30 | ul, 31 | li { 32 | list-style: none; 33 | padding: 0; 34 | margin: 0; 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/vars.scss: -------------------------------------------------------------------------------- 1 | // Color 2 | // base color 3 | $color-white: #fff; 4 | $color-black: #121212; 5 | $color-black--dark: #000; 6 | $color-black--light: #444; 7 | $color-primary: #3b77e3; 8 | $color-success: #00c900; 9 | $color-info: #999; 10 | $color-warning: #f7ba2a; 11 | $color-danger: #ff4949; 12 | $color-error: #f40000; 13 | 14 | // text color 15 | $color-text--primary: $color-black; 16 | $color-text--secondary: #64686e; 17 | $color-text--placeholder: $color-info; 18 | $color-title--secondary: #aeafaf; 19 | $color-text--nodata: #666; 20 | $color-split-line: #ccc; 21 | 22 | // bg color 23 | $color-bg--white: $color-white; 24 | $color-bg--secondary: #f3f3f3; 25 | $color-bg--content: #d5edff; 26 | $color-bg--readonly: #dfdfdf; 27 | 28 | // Border 29 | // border color 30 | $color-border--base: #ccc; 31 | 32 | $border-base: 1px solid $color-border--base; 33 | 34 | // Size 35 | // font size 36 | $size-text--normal: 12px; 37 | $size-text--big: 22px; 38 | $size-nav-text: 16px; 39 | $size-nav-text--secondary: 14px; 40 | 41 | // font-weight 42 | $font-weight--normal: 400; 43 | $font-weight--bold: 700; 44 | 45 | // border-radius 46 | $border-radius--non: 0; 47 | $border-radius--tiny: 2px; 48 | $border-radius--small: 4px; 49 | $border-radius--middle: 10px; 50 | $border-radius--big: 40px; 51 | $border-radius--full: 100%; 52 | 53 | // box-shadow 54 | $base-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04); 55 | $light-box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); 56 | $header-box-shadow: 0 2px 9px 0 rgba(8, 45, 97, 0.24); 57 | 58 | // input 59 | 60 | // scrollbar 61 | $scrollbar-thumb-bg-color: rgba(255, 255, 255, 0.15); 62 | $scrollbar-track-bg-color: transparent; 63 | $scrollbar-track-shadow-color: rgba(0, 0, 0, 0.2); 64 | 65 | :root { 66 | --color-primary: #3b77e3; 67 | } 68 | -------------------------------------------------------------------------------- /src/types/jshashes.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: jshashes类型定义 4 | */ 5 | declare module "jshashes" { 6 | export class SHA256 { 7 | public hex: (string) => string; 8 | } 9 | } -------------------------------------------------------------------------------- /src/utils/date-utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs, { OpUnitType, QUnitType } from "dayjs"; 2 | import { merge } from "lodash-es"; 3 | import zhCN from "dayjs/locale/zh-cn"; 4 | import relativeTime from "dayjs/plugin/relativeTime"; 5 | import { PlainObject } from "@/bean/base"; 6 | 7 | export function init(): void { 8 | dayjs.locale(zhCN); 9 | dayjs.extend(relativeTime); 10 | } 11 | 12 | export const DATE_STANDARD_FORMAT = "YYYY-MM-DD HH:mm:ss"; 13 | 14 | export const DATE_STANDARD_FORMAT_CN = "YYYY年M月D日 HH:mm:ss"; 15 | 16 | export const HOUR_FORMAT = "HH:mm:ss"; 17 | 18 | export const ONE_DAY_MILLSECONDS = 86400000; 19 | 20 | export const ONE_WEEK_MILLSECONDS = ONE_DAY_MILLSECONDS * 7; 21 | 22 | export function format(date: Date | string = new Date(), fmt = DATE_STANDARD_FORMAT): string { 23 | return dayjs(date).format(fmt); 24 | } 25 | 26 | export function getValueOfDate(date = new Date()): number { 27 | return dayjs(date).valueOf(); 28 | } 29 | 30 | export function getTime(date = new Date()): number { 31 | return dayjs(date).toDate().getTime(); 32 | } 33 | 34 | interface DayjsAddOption extends PlainObject { 35 | offset: number; 36 | unit: OpUnitType; 37 | format: string; 38 | } 39 | 40 | /** 41 | * 根据指定日期和选项获取另一个日期 42 | * @param {String|Date} date 指定日期 43 | * @param {Object} options 选项,options.offset大于0则获得更大的日期,否则获取更小的日期,options.unit是时间单位,默认是天('d'),options.format是输出的时间格式 44 | */ 45 | export function getDateByOffset(date = new Date(), options: DayjsAddOption): string { 46 | const defaultOptions: DayjsAddOption = { 47 | offset: 0, 48 | unit: "d", 49 | format: DATE_STANDARD_FORMAT, 50 | }; 51 | const mergedOptions = merge(defaultOptions, options) as DayjsAddOption; 52 | const targetDate = dayjs(date).add(mergedOptions.offset, mergedOptions.unit); 53 | return targetDate.format(mergedOptions.format); 54 | } 55 | 56 | /** 57 | * 获取两个日期间隔 58 | * @param {String} dateStr1 日期1 59 | * @param {String} dateStr2 日期2 60 | * @param {String} unit 结果时间单位 61 | */ 62 | export function getTimeInterval(dateStr1: string, dateStr2: string, unit: QUnitType | OpUnitType = "minute"): number { 63 | const date1 = dayjs(dateStr1); 64 | const date2 = dayjs(dateStr2); 65 | return Math.abs(date1.diff(date2, unit, true)); 66 | } 67 | 68 | export function getDayStart(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string | number { 69 | let res = dayjs(date).startOf("day"); 70 | if (typeof offset === "number" && offset !== 0) { 71 | res = offset > 0 ? res.add(offset, "d") : res.subtract(Math.abs(offset), "d"); 72 | } 73 | if (fmt === "valueOf") { 74 | return res.valueOf(); 75 | } 76 | return res.format(fmt); 77 | } 78 | 79 | export function getDayEnd(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string | number { 80 | let res = dayjs(date).endOf("day"); 81 | if (typeof offset === "number" && offset !== 0) { 82 | res = offset > 0 ? res.add(offset, "d") : res.subtract(Math.abs(offset), "d"); 83 | } 84 | if (fmt === "valueOf") { 85 | return res.valueOf(); 86 | } 87 | return res.format(fmt); 88 | } 89 | 90 | export function getOneDayRange(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): (string | number)[] { 91 | return [getDayStart(date, fmt, offset), getDayEnd(date, fmt, offset)]; 92 | } 93 | 94 | export function isMoreThanOneDay(date1: Date | string, date2: Date | string): boolean { 95 | const dayjs1 = dayjs(date1); 96 | const dayjs2 = dayjs(date2); 97 | const diff = Math.abs(dayjs1.diff(dayjs2, "days")); 98 | return diff >= 1; 99 | } 100 | 101 | export function isBefore(date1: Date | string, date2: Date | string): boolean { 102 | const dayjs1 = dayjs(date1); 103 | const dayjs2 = dayjs(date2); 104 | return dayjs1.isBefore(dayjs2); 105 | } 106 | 107 | export function isSameDay(date1: Date, date2: Date): boolean { 108 | return format(date1) === format(date2); 109 | } 110 | 111 | export function getWeekStart(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string { 112 | let res = dayjs(date).startOf("week"); 113 | if (typeof offset === "number" && offset !== 0) { 114 | res = offset > 0 ? res.add(offset, "w") : res.subtract(Math.abs(offset), "w"); 115 | } 116 | return res.format(fmt); 117 | } 118 | 119 | export function getWeekEnd(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string { 120 | let res = dayjs(date).endOf("week"); 121 | if (typeof offset === "number" && offset !== 0) { 122 | res = offset > 0 ? res.add(offset, "w") : res.subtract(Math.abs(offset), "w"); 123 | } 124 | return res.format(fmt); 125 | } 126 | 127 | export function getOneWeekRange(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string[] { 128 | return [getWeekStart(date, fmt, offset), getWeekEnd(date, fmt, offset)]; 129 | } 130 | 131 | export function getMonthStart(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string { 132 | let res = dayjs(date).startOf("month"); 133 | if (typeof offset === "number" && offset !== 0) { 134 | res = offset > 0 ? res.add(offset, "M") : res.subtract(Math.abs(offset), "M"); 135 | } 136 | return res.format(fmt); 137 | } 138 | 139 | export function getMonthEnd(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string { 140 | let res = dayjs(date).endOf("month"); 141 | if (typeof offset === "number" && offset !== 0) { 142 | res = offset > 0 ? res.add(offset, "M") : res.subtract(Math.abs(offset), "M"); 143 | } 144 | return res.format(fmt); 145 | } 146 | 147 | export function getOneMonthRange(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string[] { 148 | return [getMonthStart(date, fmt, offset), getMonthEnd(date, fmt, offset)]; 149 | } 150 | 151 | export function getYearStart(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string { 152 | let res = dayjs(date).startOf("year"); 153 | if (typeof offset === "number" && offset !== 0) { 154 | res = offset > 0 ? res.add(offset, "y") : res.subtract(Math.abs(offset), "y"); 155 | } 156 | return res.format(fmt); 157 | } 158 | 159 | export function getYearEnd(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string { 160 | let res = dayjs(date).endOf("year"); 161 | if (typeof offset === "number" && offset !== 0) { 162 | res = offset > 0 ? res.add(offset, "y") : res.subtract(Math.abs(offset), "y"); 163 | } 164 | return res.format(fmt); 165 | } 166 | 167 | export function getOneYearRange(date = new Date(), fmt = DATE_STANDARD_FORMAT, offset = 0): string[] { 168 | return [getYearStart(date, fmt, offset), getYearEnd(date, fmt, offset)]; 169 | } 170 | 171 | export function humanizeDuration(seconds: string | number): string { 172 | if (typeof seconds !== "number") { 173 | return ""; 174 | } 175 | if (seconds < 60) { 176 | // 如果小于1分钟 177 | return `${seconds}秒`; 178 | } else if (seconds >= 60 && seconds < 3600) { 179 | // 如果大于1分钟,小于1小时 180 | const minutes = Math.floor(seconds / 60); 181 | const remainingSeconds = seconds - minutes * 60; 182 | const secondsDescription = remainingSeconds > 0 ? `${remainingSeconds}秒` : ""; 183 | return `${minutes}分钟${secondsDescription}`; 184 | } else if (seconds >= 3600 && seconds < 86400) { 185 | // 如果大于1小时,小于1天 186 | const hours = Math.floor(seconds / 3600); 187 | const minutes = Math.floor((seconds - hours * 3600) / 60); 188 | // const remainingSeconds = seconds - hours * 3600 - minutes * 60 189 | const minutesDescription = minutes > 0 ? `${minutes}分钟` : ""; 190 | return `${hours}小时${minutesDescription}`; 191 | } else if (seconds >= 86400) { 192 | // 如果大于1天 193 | const days = Math.floor(seconds / 86400); 194 | const hours = Math.floor((seconds - days * 86400) / 3600); 195 | // const minutes = Math.floor((seconds - days * 86400 - hours * 3600) / 60) 196 | // const remainingSeconds = seconds - days * 86400 - hours * 3600 - minutes * 60 197 | const hoursDescription = hours > 0 ? `${hours}小时` : ""; 198 | return `${days}天${hoursDescription}`; 199 | } else { 200 | return ""; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import BezierEasing from "bezier-easing"; 2 | import { GeneralFunction } from "@/bean/base"; 3 | 4 | interface OffsetResponse { 5 | offsetLeft: number; 6 | offsetTop: number; 7 | } 8 | 9 | export function getOffset(el: HTMLElement, relativeNode = document.body): OffsetResponse { 10 | let offsetLeft = 0; 11 | let offsetTop = 0; 12 | let parent: HTMLElement | null = el; 13 | while (parent !== null && parent !== relativeNode) { 14 | offsetLeft += parent.offsetLeft; 15 | offsetTop += parent.offsetTop; 16 | parent = parent.offsetParent as HTMLElement | null; 17 | } 18 | return { 19 | offsetLeft, 20 | offsetTop, 21 | }; 22 | } 23 | 24 | const easingFunc = BezierEasing(0.42, 0, 1, 1); 25 | 26 | function setElementScrollTop({ target = document.documentElement, value }: { target?: HTMLElement; value: number }) { 27 | if (target === document.body || target === document.documentElement) { 28 | document.body.scrollTop = value; 29 | document.documentElement.scrollTop = value; 30 | } else { 31 | target.scrollTop = value; 32 | } 33 | } 34 | 35 | function getNextScrollTopValue(start: number, end: number, stepNo: number, stepTotal: number): number { 36 | if (start > end) { 37 | return start - easingFunc(stepNo / stepTotal) * (start - end); 38 | } else { 39 | return start + easingFunc(stepNo / stepTotal) * (end - start); 40 | } 41 | } 42 | 43 | interface StepOptions { 44 | target?: HTMLElement; 45 | start: number; 46 | end: number; 47 | stepNo?: number; 48 | stepTotal: number; 49 | } 50 | 51 | function animateSetScrollTop({ target = document.documentElement, start, end, stepNo = 1, stepTotal }: StepOptions) { 52 | const next = getNextScrollTopValue(start, end, stepNo, stepTotal); 53 | window.requestAnimationFrame(() => { 54 | setElementScrollTop({ 55 | target, 56 | value: next, 57 | }); 58 | if (stepNo !== stepTotal) { 59 | const nextStepNo = stepNo + 1; 60 | animateSetScrollTop({ 61 | target, 62 | start, 63 | end, 64 | stepNo: nextStepNo, 65 | stepTotal, 66 | }); 67 | } 68 | }); 69 | } 70 | 71 | interface SetScrollTopOptions { 72 | target?: HTMLElement; 73 | targetValue?: number; 74 | useAnimation?: boolean; 75 | duration?: number; 76 | } 77 | 78 | function getEleScrollTop(target = document.body): number { 79 | if (target === document.body || document.documentElement) { 80 | return document.body.scrollTop || document.documentElement.scrollTop; 81 | } else { 82 | return target.scrollTop; 83 | } 84 | } 85 | 86 | export function setScrollTop({ 87 | target = document.documentElement, 88 | targetValue = 0, 89 | useAnimation = false, 90 | duration = 0.5, 91 | }: SetScrollTopOptions = {}): void { 92 | if (useAnimation) { 93 | const currScrollTop = getEleScrollTop(target); 94 | const stepTotal = duration * 60; 95 | if (currScrollTop === targetValue) { 96 | return; 97 | } 98 | animateSetScrollTop({ 99 | target, 100 | start: currScrollTop, 101 | end: targetValue, 102 | stepTotal, 103 | }); 104 | } else { 105 | setElementScrollTop({ 106 | target, 107 | value: targetValue, 108 | }); 109 | } 110 | } 111 | 112 | // 重新激活动画,需要传入移除动画class的方法,和设置动画class的方法 113 | export default function triggerC3Animation(removeAnimClass: GeneralFunction, setAnimClass: GeneralFunction): void { 114 | removeAnimClass(); 115 | window.requestAnimationFrame(() => { 116 | window.requestAnimationFrame(() => { 117 | setAnimClass(); 118 | }); 119 | }); 120 | } 121 | 122 | export function addClass(ele: HTMLElement | string, cls: string | Array): void { 123 | const element = typeof ele === "string" ? document.querySelector("ele") : ele; 124 | if (element !== null) { 125 | if (typeof cls === "string") { 126 | element.classList.add(cls); 127 | } else if (Array.isArray(cls)) { 128 | cls.forEach((item) => { 129 | element.classList.add(item); 130 | }); 131 | } 132 | } 133 | } 134 | 135 | export function removeClass(ele: HTMLElement | string, cls: string | Array): void { 136 | const element = typeof ele === "string" ? document.querySelector("ele") : ele; 137 | if (element !== null) { 138 | if (typeof cls === "string") { 139 | element.classList.remove(cls); 140 | } else if (Array.isArray(cls)) { 141 | cls.forEach((item) => { 142 | element.classList.remove(item); 143 | }); 144 | } 145 | } 146 | } 147 | 148 | function isScroll(el: HTMLElement, direction?: string) { 149 | const isDirectionDefined = typeof direction === "string"; 150 | let overflowVal: string | null = ""; 151 | if (isDirectionDefined) { 152 | overflowVal = direction === "horizontal" ? el.style.overflowX : el.style.overflowY; 153 | } else { 154 | overflowVal = el.style.overflow; 155 | } 156 | return typeof overflowVal === "string" && /(scroll|auto)/.test(overflowVal); 157 | } 158 | 159 | export function getScrollContainer(el: HTMLElement, direction?: string): HTMLElement | null { 160 | let parent = el; 161 | while (parent) { 162 | if (parent === document.body || parent === document.documentElement) { 163 | return parent; 164 | } 165 | if (isScroll(parent, direction)) { 166 | return parent; 167 | } 168 | parent = parent.parentNode as HTMLElement; 169 | } 170 | return parent; 171 | } 172 | 173 | // type WritableCSSProperty = Omit; 174 | // type WritableCSSPropertyKeys = keyof WritableCSSProperty; 175 | // type WritableCSSPropertyValues = CSSStyleDeclaration[WritableCSSPropertyKeys]; 176 | 177 | // export function setStyle(el: HTMLElement | string, property: WritableCSSPropertyKeys, val: WritableCSSPropertyValues): void { 178 | // const dom: HTMLElement | null = typeof el === "string" ? document.querySelector(el) : el; 179 | // if (dom) { 180 | // dom.style[property] = val; 181 | // } 182 | // } 183 | -------------------------------------------------------------------------------- /src/utils/formatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 格式化,代替filter功能 4 | */ 5 | 6 | export function approvedFormatter(val: 0 | 1 | 2): string { 7 | switch (val) { 8 | case 1: 9 | return "通过"; 10 | case 2: 11 | return "不通过"; 12 | case 0: 13 | default: 14 | return "待审核"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import { getType, isArray, isDefined } from "./type"; 2 | import { PlainObject } from "@/bean/base"; 3 | 4 | /** 5 | * 处理参数对象 6 | * @param {Object} obj 参数对象 7 | * @param {options} isArrayToString 是否需要将数组处理成逗号分隔的string 8 | * @returns {Object} 处理后的参数对象 9 | */ 10 | export function requestParamsFilter(obj: PlainObject, isArrayToString = false): PlainObject { 11 | if (isArray(obj)) { 12 | return obj; 13 | } else if (getType(obj) !== "object") { 14 | return {}; 15 | } 16 | const newObj = {} as PlainObject; 17 | Object.keys(obj).forEach((key) => { 18 | const element = obj[key]; 19 | if (Array.isArray(element)) { 20 | if (element.length > 0) { 21 | newObj[key] = isArrayToString ? element.join(",") : [...element]; 22 | } 23 | } else if (isDefined(element)) { 24 | newObj[key] = element; 25 | } 26 | }); 27 | return newObj; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/tree.ts: -------------------------------------------------------------------------------- 1 | import { TreeNode } from "@/bean/base"; 2 | 3 | // overload 4 | export function tree2Arr(tree: Array, replaceChildren?: string): Array; 5 | export function tree2Arr( 6 | tree: Array, 7 | replaceChildren?: string, 8 | mapper?: (item: T, index: number, arr: Array) => D 9 | ): Array; 10 | export function tree2Arr( 11 | tree: Array, 12 | replaceChildren = "children", 13 | mapper?: (item: T, index: number, arr: Array) => D 14 | ): Array | Array { 15 | const result = tree.reduce((prev, curr) => { 16 | const children = curr[replaceChildren] as T[]; 17 | const list = children && children.length > 0 ? [curr, ...tree2Arr(children, replaceChildren, mapper)] : [curr]; 18 | return prev.concat(list as ConcatArray); 19 | }, [] as Array); 20 | return typeof mapper === "function" ? result.map(mapper) : result; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/type.ts: -------------------------------------------------------------------------------- 1 | import { GeneralFunction, PlainObject } from "@/bean/base"; 2 | 3 | enum DataType { 4 | Number = "number", 5 | String = "string", 6 | Boolean = "boolean", 7 | Undefined = "undefined", 8 | Null = "null", 9 | Symbol = "symbol", 10 | Object = "object", 11 | Date = "date", 12 | Map = "map", 13 | Set = "set", 14 | BigInt = "bigint", 15 | Function = "function", 16 | Promise = "promise", 17 | File = "file", 18 | Blob = "blob", 19 | } 20 | 21 | /** 22 | * 判断变量的数据类型 23 | * @param {unknown} val 变量值 24 | * @returns {string} 数据类型 25 | */ 26 | export function getType(val: unknown): string { 27 | return Object.prototype.toString 28 | .call(val) 29 | .replace(/\[object\s(\w+)\]/, "$1") 30 | .toLowerCase(); 31 | } 32 | 33 | /** 34 | * 判断变量是否有具体定义,即非null,非undefined,非空字符串 35 | * @param {unknown} val 变量值 36 | * @returns {boolean} 变量是否有具体定义 37 | */ 38 | export function isDefined(val: unknown): boolean { 39 | return val !== null && val !== undefined && val !== ""; 40 | } 41 | 42 | export function isObject(val: unknown): val is T { 43 | return getType(val) === DataType.Object; 44 | } 45 | 46 | export function isArray(val: unknown): val is T[] { 47 | return Array.isArray(val); 48 | } 49 | 50 | export function isNumber(val: unknown): val is number { 51 | return typeof val === DataType.Number; 52 | } 53 | 54 | export function isString(val: unknown): val is string { 55 | return typeof val === DataType.String; 56 | } 57 | 58 | export function isBool(val: unknown): val is boolean { 59 | return typeof val === DataType.Boolean; 60 | } 61 | 62 | export function isUndefined(val: unknown): val is undefined { 63 | return typeof val === DataType.Undefined; 64 | } 65 | 66 | export function isNull(val: unknown): val is null { 67 | return val === DataType.Null; 68 | } 69 | 70 | export function isUndefOrNull(val: unknown): val is undefined | null { 71 | return isUndefined(val) || isNull(val); 72 | } 73 | 74 | export function isFunction(val: unknown): val is GeneralFunction { 75 | return getType(val) === DataType.Function; 76 | } 77 | 78 | export function isSymbol(val: unknown): val is symbol { 79 | return typeof val === DataType.Symbol; 80 | } 81 | 82 | export function isMap(val: unknown): val is Map { 83 | return getType(val) === DataType.Map; 84 | } 85 | 86 | export function isSet(val: unknown): val is Set { 87 | return getType(val) === DataType.Set; 88 | } 89 | 90 | export function isPromise(val: unknown): val is Promise { 91 | return getType(val) === DataType.Promise; 92 | } 93 | 94 | export function isFile(val: unknown): val is T { 95 | return getType(val) === DataType.File; 96 | } 97 | 98 | export function isBlob(val: unknown): val is Blob { 99 | return getType(val) === DataType.Blob; 100 | } 101 | 102 | export function isBasicType(val: unknown): boolean { 103 | const type = getType(val) as DataType; 104 | return [DataType.Number, DataType.Boolean, DataType.String, DataType.Symbol, DataType.Undefined, DataType.Null].includes(type); 105 | } 106 | -------------------------------------------------------------------------------- /src/utils/validator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 通用校验 4 | */ 5 | 6 | export const REQUIRED_VALIDATOR_BLUR = { 7 | required: true, 8 | message: "必填项", 9 | trigger: "blur", 10 | }; 11 | 12 | export const EMAIL_VALIDATOR = { 13 | pattern: 14 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, 15 | message: "邮箱格式不正确", 16 | trigger: "blur", 17 | }; 18 | 19 | export const URL_VALIDATOR = { 20 | pattern: /(http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?/, 21 | message: "链接格式不正确,注意以http或https开头", 22 | trigger: "blur", 23 | }; 24 | -------------------------------------------------------------------------------- /src/views/404/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 15 | 16 | 42 | -------------------------------------------------------------------------------- /src/views/article/comment-user-info.vue: -------------------------------------------------------------------------------- 1 | 5 | 24 | 25 | 94 | -------------------------------------------------------------------------------- /src/views/article/md-render.scss: -------------------------------------------------------------------------------- 1 | :deep(.md-preview) { 2 | h1, 3 | h2, 4 | h3, 5 | h4, 6 | h5, 7 | h6 { 8 | position: relative; 9 | padding-left: 30px; 10 | &::before { 11 | position: absolute; 12 | left: 0; 13 | top: 0; 14 | background: #0670dc; 15 | padding: 0 2px; 16 | border-radius: 4px; 17 | color: #fff; 18 | font-size: 12px; 19 | font-weight: 400; 20 | line-height: 1.6; 21 | } 22 | } 23 | h1 { 24 | font-size: 1.6em; 25 | margin: 1em 0; 26 | &::before { 27 | content: "h1"; 28 | } 29 | } 30 | h2 { 31 | font-size: 1.4em; 32 | margin: 0.8em 0; 33 | &::before { 34 | content: "h2"; 35 | } 36 | } 37 | h3 { 38 | font-size: 1.2em; 39 | margin: 0.6em 0; 40 | &::before { 41 | content: "h3"; 42 | } 43 | } 44 | h4 { 45 | font-size: 1.1em; 46 | margin: 0.5em 0; 47 | &::before { 48 | content: "h4"; 49 | } 50 | } 51 | h5 { 52 | font-size: 1em; 53 | margin: 0.4em 0; 54 | &::before { 55 | content: "h5"; 56 | } 57 | } 58 | h6 { 59 | font-size: 1em; 60 | margin: 0.4em 0; 61 | &::before { 62 | content: "h5"; 63 | } 64 | } 65 | ol { 66 | margin: 0 0 10px 6px; 67 | padding: 0 0 0 4px; 68 | &:not([start]) { 69 | counter-reset: order; 70 | } 71 | > li { 72 | position: relative; 73 | padding-left: 20px; 74 | margin-top: 8px; 75 | &::before { 76 | counter-increment: order; 77 | content: counter(order); 78 | position: absolute; 79 | left: -3px; 80 | top: 3px; 81 | width: 16px; 82 | height: 16px; 83 | line-height: 16px; 84 | font-size: 12px; 85 | background: #63b8ea; 86 | border-radius: 50%; 87 | color: #fff; 88 | text-align: center; 89 | } 90 | } 91 | } 92 | ul { 93 | margin: 0 0 10px 6px; 94 | } 95 | ul > li, 96 | ul > li > ul > li { 97 | position: relative; 98 | padding-left: 15px; 99 | margin-top: 8px; 100 | &::before { 101 | content: ""; 102 | position: absolute; 103 | top: 8px; 104 | left: 2px; 105 | width: 6px; 106 | height: 6px; 107 | background-color: #333; 108 | border-radius: 100%; 109 | } 110 | } 111 | ul > li > ul > li::before { 112 | background-color: transparent; 113 | border: 1px solid #ccc; 114 | } 115 | blockquote { 116 | position: relative; 117 | margin: 10px 0; 118 | padding: 10px 12px 10px 34px; 119 | background-color: #f8f8f8; 120 | border-radius: 4px; 121 | &::before { 122 | content: "\201C"; 123 | position: absolute; 124 | top: 0; 125 | left: 10px; 126 | font-size: 3em; 127 | } 128 | > p { 129 | color: #666; 130 | margin: 0; 131 | } 132 | } 133 | pre > code { 134 | display: block; 135 | overflow-x: auto; 136 | padding: 5px; 137 | background: #474949; 138 | color: #d1d9e1; 139 | } 140 | :not(pre) > code { 141 | margin: 0 4px; 142 | padding: 2px 4px; 143 | background-color: #fff5f5; 144 | color: #8e6666; 145 | border-radius: 4px; 146 | word-break: break-word; 147 | } 148 | .img-wrapper { 149 | display: inline-block; 150 | width: 100%; 151 | text-align: center; 152 | > img { 153 | max-width: 100%; 154 | } 155 | } 156 | hr { 157 | border-style: dashed; 158 | color: #ccc; 159 | } 160 | table { 161 | border-spacing: 0; 162 | font-size: 14px; 163 | text-align: left; 164 | margin-bottom: 10px; 165 | thead tr { 166 | background-color: #fbeeee; 167 | } 168 | th, 169 | td { 170 | padding: 2px 6px; 171 | line-height: 1.8; 172 | } 173 | tbody { 174 | color: #755a5a; 175 | tr:nth-child(2n) { 176 | background-color: #e8f7f7; 177 | } 178 | } 179 | } 180 | strong, 181 | .link { 182 | margin: 0 2px; 183 | } 184 | 185 | p { 186 | line-height: 1.8; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/views/backend/article/index.module.scss: -------------------------------------------------------------------------------- 1 | .articlePoster { 2 | width: 160px; 3 | height: 92px; 4 | > img { 5 | width: 100%; 6 | height: 100%; 7 | object-fit: contain; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/views/backend/article/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 35 | 36 | 196 | -------------------------------------------------------------------------------- /src/views/backend/comment/all/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 26 | 27 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /src/views/backend/comment/review-reply/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 17 | 18 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/views/backend/comment/review/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 24 | 25 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /src/views/backend/index.vue: -------------------------------------------------------------------------------- 1 | 49 | 180 | 181 | 226 | -------------------------------------------------------------------------------- /src/views/backend/msg/all/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 26 | 27 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /src/views/backend/msg/review-reply/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 17 | 18 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /src/views/backend/msg/review/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 24 | 25 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /src/views/backend/navs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @author: Tusi 3 | * @description: 菜单配置 4 | */ 5 | 6 | import { BookOutlined, MessageOutlined, CommentOutlined } from "@ant-design/icons-vue"; 7 | import { app } from "@/main"; 8 | import { TreeNode } from "@/bean/base"; 9 | 10 | const icons = [BookOutlined, MessageOutlined, CommentOutlined]; 11 | 12 | const registerIcons = () => { 13 | icons.forEach((icon) => { 14 | app.component(icon.name, icon); 15 | }); 16 | }; 17 | 18 | registerIcons(); 19 | 20 | export interface NavItem extends TreeNode { 21 | key: string; 22 | icon?: string; 23 | title: string; 24 | parentKeys?: string[]; 25 | } 26 | 27 | export const navs: NavItem[] = [ 28 | { 29 | key: "sub1", 30 | icon: BookOutlined.name, 31 | title: "文章管理", 32 | children: [ 33 | { 34 | key: "/backend", 35 | title: "所有文章", 36 | parentKeys: ["sub1"], 37 | }, 38 | { 39 | key: "/backend/write", 40 | title: "开始创作", 41 | parentKeys: ["sub1"], 42 | }, 43 | ], 44 | }, 45 | { 46 | key: "sub2", 47 | icon: MessageOutlined.name, 48 | title: "留言管理", 49 | children: [ 50 | { 51 | key: "/backend/review-msg", 52 | title: "审核留言", 53 | parentKeys: ["sub2"], 54 | }, 55 | { 56 | key: "/backend/review-msg-reply", 57 | title: "审核留言回复", 58 | parentKeys: ["sub2"], 59 | }, 60 | { 61 | key: "/backend/all-msg", 62 | title: "所有留言", 63 | parentKeys: ["sub2"], 64 | }, 65 | ], 66 | }, 67 | { 68 | key: "sub3", 69 | icon: CommentOutlined.name, 70 | title: "评论管理", 71 | children: [ 72 | { 73 | key: "/backend/review-comment", 74 | title: "审核评论", 75 | parentKeys: ["sub3"], 76 | }, 77 | { 78 | key: "/backend/review-comment-reply", 79 | title: "审核评论回复", 80 | parentKeys: ["sub3"], 81 | }, 82 | { 83 | key: "/backend/all-comment", 84 | title: "所有评论", 85 | parentKeys: ["sub3"], 86 | }, 87 | ], 88 | }, 89 | ]; 90 | -------------------------------------------------------------------------------- /src/views/backend/styles/avatar.scss: -------------------------------------------------------------------------------- 1 | :deep(.comment-avatar) { 2 | width: 40px; 3 | height: 40px; 4 | > img { 5 | width: 100%; 6 | height: 100%; 7 | border-radius: 100%; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/views/categories/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 31 | 32 | 65 | 66 | 102 | -------------------------------------------------------------------------------- /src/views/category/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 38 | 39 | 120 | -------------------------------------------------------------------------------- /src/views/home/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 30 | 31 | 108 | -------------------------------------------------------------------------------- /src/views/jumpout/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 41 | 42 | 60 | -------------------------------------------------------------------------------- /src/views/login/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 35 | 36 | 122 | 123 | 154 | -------------------------------------------------------------------------------- /src/views/messages/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 28 | 29 | 84 | 85 | 111 | -------------------------------------------------------------------------------- /src/views/tag/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 38 | 39 | 120 | -------------------------------------------------------------------------------- /src/views/tags/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 27 | 28 | 61 | 62 | 93 | -------------------------------------------------------------------------------- /src/views/timeline/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 43 | 44 | 107 | 108 | 151 | -------------------------------------------------------------------------------- /tests/unit/example.spec.ts: -------------------------------------------------------------------------------- 1 | // import { shallowMount } from "@vue/test-utils"; 2 | // import HelloWorld from "@/components/HelloWorld.vue"; 3 | 4 | // describe("HelloWorld.vue", () => { 5 | // it("renders props.msg when passed", () => { 6 | // const msg = "new message"; 7 | // const wrapper = shallowMount(HelloWorld, { 8 | // props: { msg }, 9 | // }); 10 | // expect(wrapper.text()).toMatch(msg); 11 | // }); 12 | // }); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; 4 | const theme = require("./antd-theme.js"); 5 | 6 | // function addStyleResource(rule) { 7 | // rule.use("style-resource") 8 | // .loader("style-resources-loader") 9 | // .options({ 10 | // patterns: [path.resolve(__dirname, "./src/styles/preload.scss")], 11 | // }); 12 | // } 13 | 14 | module.exports = { 15 | publicPath: "/", 16 | devServer: { 17 | port: 3000, 18 | open: true, 19 | proxy: { 20 | "/api": { 21 | target: "http://127.0.0.1:8002", 22 | changeOrigin: true, 23 | pathRewrite: { 24 | "^/api": "", 25 | }, 26 | }, 27 | }, 28 | }, 29 | chainWebpack: (config) => { 30 | // html-webpack-plugin 31 | config.plugin("html").tap((args) => { 32 | args[0].title = process.env.VUE_APP_TITLE; 33 | return args; 34 | }); 35 | 36 | // 本来打算使用 style-resources-loader 自动注入scss,但是发现对 element 使用的一些 sass 特性支持有点问题 37 | // const types = ["vue-modules", "vue", "normal-modules", "normal"]; 38 | // types.forEach((type) => addStyleResource(config.module.rule("scss").oneOf(type))); 39 | 40 | // 这里改用 sass-resources-loader 注入scss 41 | const oneOfsMap = config.module.rule("scss").oneOfs.store; 42 | oneOfsMap.forEach((item) => { 43 | item.use("sass-resources-loader") 44 | .loader("sass-resources-loader") 45 | .options({ 46 | // Provide path to the file with resources 47 | resources: path.resolve(__dirname, "./src/styles/preload.scss"), 48 | hoistUseStatements: true, 49 | }) 50 | .end(); 51 | }); 52 | 53 | // preload 处理, tap 可以返回一个新的配置 54 | config.plugin("preload").tap((args) => { 55 | // runtime 做了内联,这里不做 preload 56 | args[0].fileBlacklist.push(/runtime\..*\.js$/); 57 | return args; 58 | }); 59 | 60 | // 移除 prefetch 插件 61 | config.plugins.delete("prefetch"); 62 | 63 | // 去除掉多余的moment语言包 64 | config.plugin("context-replacement").use(require.resolve("webpack/lib/ContextReplacementPlugin"), [/moment[/\\]locale$/, /zh-cn/]); 65 | 66 | // production env 67 | config.when( 68 | process.env.NODE_ENV === "production", 69 | (config) => { 70 | // 生产环境 71 | 72 | // devtool设置 73 | config.devtool("nosources-source-map"); 74 | 75 | // 内联 runtimeChunk 76 | config 77 | .plugin("ScriptExtHtmlWebpackPlugin") 78 | .after("html") 79 | .use("script-ext-html-webpack-plugin", [ 80 | { 81 | inline: /runtime\..*\.js$/, 82 | }, 83 | ]) 84 | .end(); 85 | 86 | // 优化配置 87 | config.optimization 88 | .runtimeChunk({ 89 | name: "runtime", 90 | }) 91 | .splitChunks({ 92 | cacheGroups: { 93 | vendors: { 94 | name: "vendor-chunk", 95 | test: /[\\/]node_modules[\\/](@vue|vue|vue-router|vuex|axios|qs|js-cookie|core-js|moment)[\\/]/, 96 | priority: 20, 97 | chunks: "initial", 98 | }, 99 | antd: { 100 | name: "antd", 101 | test: /[\\/]node_modules[\\/](ant-design-vue|@ant-design)[\\/]/, 102 | priority: 10, 103 | chunks: "initial", 104 | }, 105 | }, 106 | }); 107 | 108 | // 支持 webpack bundle 分析 109 | config.when(process.env.npm_config_report, (config) => { 110 | config.plugin("analyzer").use(BundleAnalyzerPlugin); 111 | }); 112 | }, 113 | (config) => { 114 | // 开发环境 115 | } 116 | ); 117 | }, 118 | css: { 119 | requireModuleExtension: true, 120 | loaderOptions: { 121 | less: { 122 | lessOptions: { 123 | // 按需定制 antd 主题 124 | modifyVars: theme, 125 | javascriptEnabled: true, 126 | }, 127 | }, 128 | }, 129 | }, 130 | }; 131 | --------------------------------------------------------------------------------