├── .browserslistrc ├── .editorconfig ├── .env.staging ├── .eslintrc.js ├── .gitignore ├── README.md ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ ├── background.png │ ├── background.svg │ ├── loading.svg │ ├── login.png │ ├── logo-simple.png │ ├── logo.png │ ├── logo2.png │ ├── new-logo.png │ ├── phone-back.png │ ├── transparent.png │ └── user-interface.svg ├── components │ ├── BackgroundProcesser.vue │ ├── ColorPicker.vue │ ├── ComponentsList.vue │ ├── ContextMenu.vue │ ├── EditGroup.vue │ ├── EditWrapper.vue │ ├── IconSwitch.vue │ ├── ImageProcess.vue │ ├── InputEdit.vue │ ├── LayerList.vue │ ├── PropsTable.vue │ ├── RenderVnode.ts │ ├── ShadowPicker.vue │ ├── StyledUploader.vue │ ├── TemplateList.vue │ ├── TextareaFix.vue │ ├── Uploader.vue │ ├── UserProfile.vue │ └── WorksList.vue ├── defaultProps.ts ├── helper.ts ├── hooks │ ├── useClickOutside.ts │ ├── useComponentClick.ts │ ├── useCreateDesign.ts │ ├── useHotKey.ts │ ├── useLoadMore.ts │ ├── useShowError.ts │ └── useStylePick.ts ├── main.ts ├── plugins │ ├── dataOperations.ts │ └── hotKeys.ts ├── propsMap.ts ├── router │ └── index.ts ├── shims-vue.d.ts ├── store │ ├── editor.ts │ ├── index.ts │ ├── user.ts │ └── works.ts ├── test │ └── unit │ │ ├── Foo.vue │ │ └── IconSwitch.spec.ts └── views │ ├── ChannelForm.vue │ ├── Editor.vue │ ├── HistoryArea.vue │ ├── Home.vue │ ├── Index.vue │ ├── Login.vue │ ├── MyWork.vue │ ├── PublishForm.vue │ ├── Setting.vue │ └── TemplateDetail.vue ├── tsconfig.json └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.env.staging: -------------------------------------------------------------------------------- 1 | NODE_ENV = production 2 | VUE_APP_IS_STAGING = 'staging' -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | '@vue/standard', 9 | '@vue/typescript/recommended' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off' 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # editor 2 | 3 | * 基本信息:typescript + vue3 4 | * 基本库 ant-design-vue 5 | * 业务库基于 ant-design-vue 未来将抽离出来单独开发 6 | * 现阶段 POC 完成,可以展示组件,添加组件,编辑属性值,并且可以直接反射回到组件界面中去 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ], 5 | plugins: ['@vue/babel-plugin-jsx'] 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript', 3 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 4 | transform: { 5 | '^.+\\.vue$': 'vue-jest' 6 | }, 7 | transformIgnorePatterns: [ 8 | '/!node_modules\\/lodash-es/' 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "editor", 3 | "version": "0.1.18", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "cross-env NODE_ENV=production vue-cli-service build", 8 | "build:staging": "vue-cli-service build --mode staging", 9 | "lint": "vue-cli-service lint", 10 | "test": "vue-cli-service test:unit", 11 | "test:watch": "vue-cli-service test:unit --watch" 12 | }, 13 | "dependencies": { 14 | "@ant-design/icons-vue": "^5.1.5", 15 | "ant-design-vue": "^2.0.0-beta.9", 16 | "axios": "^0.20.0", 17 | "clipboard": "^2.0.6", 18 | "core-js": "^3.6.5", 19 | "cropperjs": "^1.5.9", 20 | "echarts": "^4.9.0", 21 | "file-saver": "^2.0.5", 22 | "hotkeys-js": "^3.8.1", 23 | "html2canvas": "^1.0.0-rc.7", 24 | "lego-components": "^0.1.6", 25 | "lodash": "^4.17.20", 26 | "qrcodejs2": "0.0.2", 27 | "uuid": "^8.3.0", 28 | "vue": "^3.0.0-0", 29 | "vue-draggable-next": "^1.0.5", 30 | "vue-router": "^4.0.0-beta.12", 31 | "vuex": "^4.0.0-0" 32 | }, 33 | "devDependencies": { 34 | "@types/clipboard": "^2.0.1", 35 | "@types/echarts": "^4.8.3", 36 | "@types/file-saver": "^2.0.1", 37 | "@types/jest": "^26.0.15", 38 | "@types/lodash": "^4.14.161", 39 | "@types/uuid": "^8.3.0", 40 | "@typescript-eslint/eslint-plugin": "^2.33.0", 41 | "@typescript-eslint/parser": "^2.33.0", 42 | "@vue/babel-plugin-jsx": "^1.0.0-rc.2", 43 | "@vue/cli-plugin-babel": "~4.5.0", 44 | "@vue/cli-plugin-eslint": "~4.5.0", 45 | "@vue/cli-plugin-router": "~4.5.0", 46 | "@vue/cli-plugin-typescript": "~4.5.0", 47 | "@vue/cli-plugin-unit-jest": "^4.5.4", 48 | "@vue/cli-plugin-vuex": "~4.5.0", 49 | "@vue/cli-service": "~4.5.0", 50 | "@vue/compiler-sfc": "^3.0.0-0", 51 | "@vue/eslint-config-standard": "^5.1.2", 52 | "@vue/eslint-config-typescript": "^5.0.2", 53 | "@vue/test-utils": "^2.0.0-beta.8", 54 | "compression-webpack-plugin": "^6.0.3", 55 | "cross-env": "^7.0.2", 56 | "eslint": "^6.7.2", 57 | "eslint-plugin-import": "^2.20.2", 58 | "eslint-plugin-node": "^11.1.0", 59 | "eslint-plugin-promise": "^4.2.1", 60 | "eslint-plugin-standard": "^4.0.0", 61 | "eslint-plugin-vue": "^7.0.0-0", 62 | "less": "^3.12.2", 63 | "less-loader": "^7.1.0", 64 | "typescript": "~3.9.3", 65 | "vue-jest": "^5.0.0-alpha.5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 16 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /src/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/background.png -------------------------------------------------------------------------------- /src/assets/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | 13 | 17 | 21 | 22 | 23 | 27 | 31 | 32 | 33 | 37 | 41 | 42 | 43 | 47 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/assets/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/login.png -------------------------------------------------------------------------------- /src/assets/logo-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/logo-simple.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/logo2.png -------------------------------------------------------------------------------- /src/assets/new-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/new-logo.png -------------------------------------------------------------------------------- /src/assets/phone-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/phone-back.png -------------------------------------------------------------------------------- /src/assets/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/transparent.png -------------------------------------------------------------------------------- /src/assets/user-interface.svg: -------------------------------------------------------------------------------- 1 | 13 -------------------------------------------------------------------------------- /src/components/BackgroundProcesser.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 56 | 57 | 66 | -------------------------------------------------------------------------------- /src/components/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 42 | 43 | 86 | -------------------------------------------------------------------------------- /src/components/ComponentsList.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 300 | 301 | 360 | -------------------------------------------------------------------------------- /src/components/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 65 | 66 | 84 | -------------------------------------------------------------------------------- /src/components/EditGroup.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 93 | 94 | 97 | -------------------------------------------------------------------------------- /src/components/EditWrapper.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 193 | 194 | 243 | -------------------------------------------------------------------------------- /src/components/IconSwitch.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 47 | 48 | 51 | -------------------------------------------------------------------------------- /src/components/ImageProcess.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 136 | 137 | 164 | -------------------------------------------------------------------------------- /src/components/InputEdit.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 80 | 81 | 95 | -------------------------------------------------------------------------------- /src/components/LayerList.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 88 | 89 | 115 | -------------------------------------------------------------------------------- /src/components/PropsTable.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 114 | 154 | -------------------------------------------------------------------------------- /src/components/RenderVnode.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, isVNode } from 'vue' 2 | 3 | const RenderVnode = defineComponent({ 4 | props: { 5 | vNode: { 6 | type: [Object, String], 7 | required: true 8 | } 9 | }, 10 | render () { 11 | if (isVNode(this.vNode)) { 12 | return this.vNode 13 | } else { 14 | return h('div', this.vNode as any) 15 | } 16 | } 17 | }) 18 | 19 | export default RenderVnode 20 | -------------------------------------------------------------------------------- /src/components/ShadowPicker.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 70 | 71 | 84 | -------------------------------------------------------------------------------- /src/components/StyledUploader.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 61 | 62 | 94 | -------------------------------------------------------------------------------- /src/components/TemplateList.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 62 | 63 | 158 | -------------------------------------------------------------------------------- /src/components/TextareaFix.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 49 | 50 | 53 | -------------------------------------------------------------------------------- /src/components/Uploader.vue: -------------------------------------------------------------------------------- 1 | 22 | 102 | 107 | -------------------------------------------------------------------------------- /src/components/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 63 | 71 | -------------------------------------------------------------------------------- /src/components/WorksList.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 199 | 200 | 209 | -------------------------------------------------------------------------------- /src/defaultProps.ts: -------------------------------------------------------------------------------- 1 | import { mapValues } from 'lodash' 2 | interface DefaultPropsType { 3 | [key: string]: { 4 | props: object; 5 | extraProps?: { [key: string]: any }; 6 | }; 7 | } 8 | 9 | // the common default props, all the components should have these props 10 | export const commonDefaultProps = { 11 | // actions 12 | actionType: '', 13 | url: '', 14 | // size 15 | height: '', 16 | width: '', 17 | paddingLeft: '0px', 18 | paddingRight: '0px', 19 | paddingTop: '0px', 20 | paddingBottom: '0px', 21 | // border type 22 | borderStyle: 'none', 23 | borderColor: '#000', 24 | borderWidth: '0', 25 | borderRadius: '0', 26 | // shadow and opacity 27 | boxShadow: '0 0 0 #000000', 28 | opacity: 1, 29 | // position and x,y 30 | position: 'absolute', 31 | left: '0', 32 | top: '0', 33 | right: '0' 34 | } 35 | export const textDefaultProps = { 36 | // basic props - font styles 37 | fontSize: '14px', 38 | fontFamily: '', 39 | fontWeight: 'normal', 40 | fontStyle: 'normal', 41 | textDecoration: 'none', 42 | lineHeight: '1', 43 | textAlign: 'center', 44 | color: '#000000', 45 | backgroundColor: '', 46 | ...commonDefaultProps, 47 | width: '318px' 48 | } 49 | 50 | export const imageDefaultProps = { 51 | imageSrc: '', 52 | ...commonDefaultProps 53 | } 54 | // this contains all default props for all the components 55 | // useful for inserting new component into the store 56 | export const componentsDefaultProps: DefaultPropsType = { 57 | 'l-text': { 58 | props: { 59 | text: '正文内容', 60 | ...textDefaultProps, 61 | fontSize: '14px', 62 | width: '125px', 63 | height: '36px', 64 | left: (320 / 2) - (125 / 2) + 'px', 65 | top: (500 / 2) - (36 / 2) + 'px' 66 | } 67 | }, 68 | 'l-image': { 69 | props: { 70 | ...imageDefaultProps 71 | } 72 | }, 73 | 'l-shape': { 74 | props: { 75 | backgroundColor: '', 76 | ...commonDefaultProps 77 | } 78 | } 79 | } 80 | 81 | export const transformToComponentProps = (props: { [key: string]: any }) => { 82 | return mapValues(props, (item) => { 83 | return { 84 | type: item.constructor, 85 | default: item 86 | } 87 | }) as { [key: string]: any } 88 | } 89 | export default componentsDefaultProps 90 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | import { message } from 'ant-design-vue' 2 | import axios from 'axios' 3 | import html2canvas from 'html2canvas' 4 | import { saveAs } from 'file-saver' 5 | import { map } from 'lodash' 6 | interface CheckCondition { 7 | format?: string[]; 8 | size?: number; 9 | } 10 | 11 | type ErrorType = 'size' | 'format' | null 12 | 13 | export interface UploadImgProps { 14 | data: { 15 | urls: string[]; 16 | }; 17 | errno: number; 18 | file: File; 19 | } 20 | export function beforeUploadCheck (file: File, condition: CheckCondition) { 21 | const { format, size } = condition 22 | const isValidFormat = format ? format.includes(file.type) : true 23 | const isValidSize = size ? (file.size / 1024 / 1024 < size) : true 24 | let error: ErrorType = null 25 | if (!isValidFormat) { 26 | error = 'format' 27 | } 28 | if (!isValidSize) { 29 | error = 'size' 30 | } 31 | return { 32 | passed: isValidFormat && isValidSize, 33 | error 34 | } 35 | } 36 | export const commonUploadCheck = (file: File) => { 37 | const result = beforeUploadCheck(file, { format: ['image/jpeg', 'image/png'], size: 1 }) 38 | const { passed, error } = result 39 | if (error === 'format') { 40 | message.error('上传图片只能是 JPG/PNG 格式!') 41 | } 42 | if (error === 'size') { 43 | message.error('上传图片大小不能超过 1Mb') 44 | } 45 | return passed 46 | } 47 | 48 | export function clickInsideElement (e: Event, className: string) { 49 | let el = e.target as HTMLElement 50 | if (el.classList.contains(className)) { 51 | return el 52 | } else { 53 | while (el) { 54 | if (el.classList && el.classList.contains(className)) { 55 | return el 56 | } else { 57 | el = el.parentNode as HTMLElement 58 | } 59 | } 60 | } 61 | return false 62 | } 63 | 64 | export const imageDimensions = (file: File) => { 65 | return new Promise<{ width: number; height: number }>((resolve, reject) => { 66 | const img = new Image() 67 | img.src = URL.createObjectURL(file) 68 | // the following handler will fire after the successful loading of the image 69 | img.onload = () => { 70 | const { naturalWidth: width, naturalHeight: height } = img 71 | resolve({ width, height }) 72 | } 73 | // and this handler will fire if there was an error with the image (like if it's not really an image or a corrupted one) 74 | img.onerror = () => { 75 | reject(new Error('There was some problem with the image.')) 76 | } 77 | }) 78 | } 79 | 80 | export function isMobile (mobile: string) { 81 | return /^1[3-9]\d{9}$/.test(mobile) 82 | } 83 | 84 | export const takeScreenshotAndUpload = (id: string) => { 85 | const el = document.getElementById(id) as HTMLElement 86 | return html2canvas(el, 87 | { allowTaint: false, useCORS: true, width: 375 }).then(canvas => { 88 | return new Promise((resolve, reject) => { 89 | canvas.toBlob((data) => { 90 | if (data) { 91 | const newFile = new File([data], 'screenshot.png') 92 | const formData = new FormData() 93 | formData.append('file', newFile) 94 | axios.post('/utils/upload-img', formData, { 95 | headers: { 96 | 'Content-Type': 'multipart/form-data' 97 | }, 98 | timeout: 5000 99 | }).then(data => { 100 | resolve(data.data) 101 | }).catch(err => { 102 | reject(err) 103 | }) 104 | } else { 105 | reject(new Error('blob data error')) 106 | } 107 | }, 'image/png') 108 | }) 109 | }) 110 | } 111 | 112 | export const objToQueryString = (queryObj: { ['string']: any}) => { 113 | return map(queryObj, (value: any, key: string) => `${key}=${value}`).join('&') 114 | } 115 | 116 | export const toDateFormat = (date: Date) => { 117 | return date.toISOString().split('T')[0] 118 | } 119 | 120 | export const toDateFromDays = (date: Date, n: number) => { 121 | const newDate = new Date(date.getTime()) 122 | newDate.setDate(date.getDate() + n) 123 | return newDate 124 | } 125 | 126 | export const getDaysArray = (start: Date, end: Date) => { 127 | const arr = [] 128 | // eslint-disable-next-line no-unmodified-loop-condition 129 | for (let dt = new Date(start); dt <= end; dt.setDate(dt.getDate() + 1)) { 130 | arr.push(new Date(dt)) 131 | } 132 | return arr 133 | } 134 | 135 | export const objToArr = (obj: {[key: string]: T}) => { 136 | return Object.keys(obj).map(key => obj[key]) 137 | } 138 | 139 | export const insertAt = (arr: any[], index: number, newItem: any) => [ 140 | ...arr.slice(0, index), 141 | newItem, 142 | ...arr.slice(index) 143 | ] 144 | 145 | export const downloadImage = (url: string) => { 146 | const fileName = url.substring(url.lastIndexOf('/') + 1) 147 | saveAs(url, fileName) 148 | } 149 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { ref, onMounted, onUnmounted, Ref } from 'vue' 2 | 3 | const useClickOutside = (elementRef: Ref, trigger: any = false) => { 4 | const isClickOutside = ref(false) 5 | const handler = (e: MouseEvent) => { 6 | if (elementRef.value && trigger.value) { 7 | if (elementRef.value.contains(e.target as HTMLElement)) { 8 | isClickOutside.value = false 9 | } else { 10 | isClickOutside.value = true 11 | } 12 | } 13 | } 14 | onMounted(() => { 15 | document.addEventListener('click', handler) 16 | }) 17 | onUnmounted(() => { 18 | document.removeEventListener('click', handler) 19 | }) 20 | return isClickOutside 21 | } 22 | 23 | export default useClickOutside 24 | -------------------------------------------------------------------------------- /src/hooks/useComponentClick.ts: -------------------------------------------------------------------------------- 1 | const useComponentClick = (props: any) => { 2 | const handleClick = () => { 3 | if (props.actionType && props.url && !props.isEditing) { 4 | window.location.href = props.url 5 | } 6 | } 7 | return handleClick 8 | } 9 | 10 | export default useComponentClick 11 | -------------------------------------------------------------------------------- /src/hooks/useCreateDesign.ts: -------------------------------------------------------------------------------- 1 | import { useStore } from 'vuex' 2 | import { computed } from 'vue' 3 | import { useRouter } from 'vue-router' 4 | import { GlobalDataProps } from '../store/index' 5 | 6 | function useCreateDesign () { 7 | const store = useStore() 8 | const router = useRouter() 9 | const userInfo = computed(() => store.state.user) 10 | const createDesign = () => { 11 | if (userInfo.value.isLogin) { 12 | const payload = { 13 | title: '未命名作品', 14 | desc: '未命名作品', 15 | coverImg: 'http://typescript-vue.oss-cn-beijing.aliyuncs.com/vue-marker/5f81cca3f3bf7a0e1ebaf885.png' 16 | } 17 | store.dispatch('createWork', payload).then(({ data }) => { 18 | router.push(`/editor/${data.id}`) 19 | }) 20 | } else { 21 | router.push('/login') 22 | } 23 | } 24 | return createDesign 25 | } 26 | 27 | export default useCreateDesign 28 | -------------------------------------------------------------------------------- /src/hooks/useHotKey.ts: -------------------------------------------------------------------------------- 1 | import hotkeys, { KeyHandler } from 'hotkeys-js' 2 | import { onMounted, onUnmounted } from 'vue' 3 | export type Options = { 4 | filter?: typeof hotkeys.filter; 5 | element?: HTMLElement; 6 | splitKey?: string; 7 | scope?: string; 8 | keyup?: boolean; 9 | keydown?: boolean; 10 | }; 11 | hotkeys.filter = () => { 12 | return true 13 | } 14 | 15 | const useHotKey = (keys: string, callback: KeyHandler, options: Options = {}) => { 16 | onMounted(() => { 17 | hotkeys(keys, options, callback) 18 | }) 19 | onUnmounted(() => { 20 | hotkeys.unbind(keys, callback) 21 | }) 22 | } 23 | 24 | export default useHotKey 25 | -------------------------------------------------------------------------------- /src/hooks/useLoadMore.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed, ComputedRef } from 'vue' 2 | import { useStore } from 'vuex' 3 | interface LoadPrams { 4 | pageIndex?: number; 5 | pageSize?: number; 6 | [key: string]: any; 7 | } 8 | const useLoadMore = (actionName: string, total: ComputedRef, params: LoadPrams = {}, pageSize = 8) => { 9 | const store = useStore() 10 | // current page should equals 1, start from the second page 11 | const pageIndex = ref((params && params.pageIndex) || 0) 12 | const requestParams = computed(() => { 13 | return { 14 | ...params, 15 | pageIndex: pageIndex.value + 1 16 | } 17 | }) 18 | // function to trigger load more 19 | const loadMorePage = () => { 20 | store.dispatch(actionName, requestParams.value).then(() => { 21 | pageIndex.value++ 22 | }) 23 | } 24 | const isLastPage = computed(() => { 25 | return Math.ceil((total.value || 1) / pageSize) === pageIndex.value + 1 26 | }) 27 | return { 28 | pageIndex, 29 | loadMorePage, 30 | isLastPage 31 | } 32 | } 33 | 34 | export default useLoadMore 35 | -------------------------------------------------------------------------------- /src/hooks/useShowError.ts: -------------------------------------------------------------------------------- 1 | import { useStore } from 'vuex' 2 | import { computed, watch } from 'vue' 3 | import { message } from 'ant-design-vue' 4 | import { GlobalDataProps } from '../store/index' 5 | 6 | function useShowError () { 7 | const store = useStore() 8 | const error = computed(() => store.state.status.error) 9 | watch(() => error.value.status, (errorValue) => { 10 | if (errorValue) { 11 | message.error(error.value.message, 2) 12 | } 13 | }) 14 | } 15 | 16 | export default useShowError 17 | -------------------------------------------------------------------------------- /src/hooks/useStylePick.ts: -------------------------------------------------------------------------------- 1 | import { pick, without } from 'lodash' 2 | import { computed } from 'vue' 3 | import { textDefaultProps } from '../defaultProps' 4 | 5 | export const defaultStyles = without(Object.keys(textDefaultProps), 'actionType', 'url', 'text') 6 | const useStylePick = (props: Readonly, pickStyles = defaultStyles) => { 7 | return computed(() => pick(props, pickStyles)) 8 | } 9 | 10 | export default useStylePick 11 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import axios from 'axios' 3 | import Antd from 'ant-design-vue' 4 | import Lego from 'lego-components' 5 | import 'ant-design-vue/dist/antd.less' 6 | import 'lego-components/dist/lego-components.css' 7 | import 'cropperjs/dist/cropper.css' 8 | import App from './App.vue' 9 | import router from './router' 10 | import store, { ICustomAxiosConfig } from './store' 11 | export interface CustomWindow extends Window { 12 | __bl: { api: (url: string, success: boolean, time: number, code?: number, msg?: string) => null }; 13 | } 14 | declare let window: CustomWindow 15 | // generating different urls based on env 16 | let baseBackendURL = '' 17 | let baseH5URL = '' 18 | let baseStaticURL = '' 19 | 20 | if (process.env.NODE_ENV === 'development' || process.env.VUE_APP_IS_STAGING) { 21 | // 这里是本地的请求 URL 22 | // staging 也就是测试环境 URL 23 | baseBackendURL = 'http://182.92.168.192:8081' 24 | baseH5URL = 'http://182.92.168.192:8082' 25 | baseStaticURL = 'http://182.92.168.192:8080' 26 | } else { 27 | // 生产环境 URL 28 | baseBackendURL = 'https://api.imooc-lego.com' 29 | baseH5URL = 'https://h5.imooc-lego.com' 30 | baseStaticURL = 'https://statistic-res.imooc-lego.com' 31 | } 32 | 33 | export { baseBackendURL, baseH5URL, baseStaticURL } 34 | 35 | axios.defaults.baseURL = `${baseBackendURL}/api/` 36 | const app = createApp(App) 37 | let timeGap = 0 38 | let currentURL = '' 39 | axios.interceptors.request.use(config => { 40 | const newConfig = config as ICustomAxiosConfig 41 | store.commit('setLoading', { status: true, opName: newConfig.mutationName }) 42 | store.commit('setError', { status: false, message: '' }) 43 | timeGap = Date.now() 44 | const { baseURL, url } = config 45 | currentURL = `${baseURL?.slice(0, -1)}${url}` as string 46 | return config 47 | }) 48 | 49 | axios.interceptors.response.use(resp => { 50 | store.commit('setLoading', { status: false }) 51 | if (resp.data.errno !== 0) { 52 | store.commit('setError', { status: true, message: resp.data.message }) 53 | return Promise.reject(resp.data) 54 | } 55 | timeGap = Date.now() - timeGap 56 | window.__bl && window.__bl.api && window.__bl.api(currentURL, true, timeGap, 0, resp.data.message) 57 | return resp 58 | }, e => { 59 | const error = e.response ? e.response.data : e.message 60 | store.commit('setError', { status: true, message: error }) 61 | store.commit('setLoading', { status: false }) 62 | timeGap = Date.now() - timeGap 63 | window.__bl && window.__bl.api && window.__bl.api(currentURL, false, timeGap, -1, error) 64 | return Promise.reject(error) 65 | }) 66 | 67 | app.use(store).use(router).use(Antd).use(Lego) 68 | app.mount('#app') 69 | -------------------------------------------------------------------------------- /src/plugins/dataOperations.ts: -------------------------------------------------------------------------------- 1 | import { ComputedRef, Ref, computed } from 'vue' 2 | import { message } from 'ant-design-vue' 3 | import { GlobalDataProps } from '../store/index' 4 | import { useStore } from 'vuex' 5 | export type MoveDirection = 'Up' | 'Down' | 'Left' | 'Right' 6 | export default function dataOperations (componentId: ComputedRef | Ref) { 7 | const store = useStore() 8 | return { 9 | copy: () => { 10 | if (componentId.value) { 11 | store.commit('copyComponent', componentId.value) 12 | message.success('已拷贝当前图层', 1) 13 | } 14 | }, 15 | paste: () => { 16 | if (componentId.value && store.state.editor.copiedComponent) { 17 | store.commit('pasteCopiedComponent') 18 | message.success('已黏贴当前图层', 1) 19 | } 20 | }, 21 | delete: () => { 22 | if (componentId.value) { 23 | store.commit('deleteComponent', componentId.value) 24 | message.success('删除当前图层成功', 1) 25 | } 26 | }, 27 | cancel: () => { 28 | if (componentId.value) { 29 | store.commit('setActive', '') 30 | } 31 | }, 32 | undo: () => { 33 | const undoIsDisabled = computed(() => store.getters.checkUndoDisable) 34 | if (!undoIsDisabled.value) { 35 | store.commit('undo') 36 | } 37 | }, 38 | redo: () => { 39 | const redoIsDisabled = computed(() => store.getters.checkRedoDisable) 40 | if (!redoIsDisabled.value) { 41 | store.commit('redo') 42 | } 43 | }, 44 | move: (direction: MoveDirection, amount: number) => { 45 | if (componentId.value) { 46 | store.commit('moveComponent', { direction, amount }) 47 | } 48 | } 49 | } 50 | } 51 | 52 | export const operationText: { [key: string]: {text: string; shortcut: string} } = { 53 | copy: { 54 | text: '拷贝图层', 55 | shortcut: '⌘C / Ctrl+C' 56 | }, 57 | paste: { 58 | text: '粘贴图层', 59 | shortcut: '⌘V / Ctrl+V' 60 | }, 61 | delete: { 62 | text: '删除图层', 63 | shortcut: 'Backspace / Delete' 64 | }, 65 | cancel: { 66 | text: '取消选中', 67 | shortcut: 'ESC' 68 | }, 69 | undo: { 70 | text: '撤销', 71 | shortcut: '⌘Z / Ctrl+Z' 72 | }, 73 | redo: { 74 | text: '重做', 75 | shortcut: '⌘⇧Z / Ctrl+Shift+Z' 76 | }, 77 | move: { 78 | text: '上下左右移动一像素', 79 | shortcut: '↑ ↓ → ←' 80 | }, 81 | moveTen: { 82 | text: '上下左右移动十像素', 83 | shortcut: 'Shift + ↑ ↓ → ←' 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/plugins/hotKeys.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | import useHotKey from '../hooks/useHotKey' 3 | import { GlobalDataProps } from '../store/index' 4 | import { useStore } from 'vuex' 5 | import { KeyHandler } from 'hotkeys-js' 6 | import DataOperation from './dataOperations' 7 | function useHotKeyExtra (command: string, callback: KeyHandler) { 8 | useHotKey(command, (event, keyEvent) => { 9 | const tagName = (event.target as HTMLElement).tagName 10 | const isInput = tagName === 'TEXTAREA' || tagName === 'INPUT' 11 | if (!isInput) { 12 | event.preventDefault() 13 | callback(event, keyEvent) 14 | } 15 | }) 16 | } 17 | export function initHotKeys () { 18 | const store = useStore() 19 | const currentId = computed(() => store.state.editor.currentElement) 20 | const operations = DataOperation(currentId) 21 | useHotKeyExtra('ctrl+c, command+c', () => { 22 | operations.copy() 23 | }) 24 | useHotKeyExtra('backspace, delete', () => { 25 | operations.delete() 26 | }) 27 | useHotKeyExtra('ctrl+v, command+v', (event) => { 28 | operations.paste() 29 | }) 30 | useHotKeyExtra('esc', () => { 31 | operations.cancel() 32 | }) 33 | useHotKeyExtra('ctrl+z, command+z', () => { 34 | operations.undo() 35 | }) 36 | useHotKeyExtra('ctrl+shift+z, command+shift+z', () => { 37 | operations.redo() 38 | }) 39 | useHotKeyExtra('up', () => { 40 | operations.move('Up', 1) 41 | }) 42 | useHotKeyExtra('down', () => { 43 | operations.move('Down', 1) 44 | }) 45 | useHotKeyExtra('left', () => { 46 | operations.move('Left', 1) 47 | }) 48 | useHotKeyExtra('right', () => { 49 | operations.move('Right', 1) 50 | }) 51 | useHotKeyExtra('shift+up', () => { 52 | operations.move('Up', 10) 53 | }) 54 | useHotKeyExtra('shift+down', () => { 55 | operations.move('Down', 10) 56 | }) 57 | useHotKeyExtra('shift+left', () => { 58 | operations.move('Left', 10) 59 | }) 60 | useHotKeyExtra('shift+right', () => { 61 | operations.move('Right', 10) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /src/propsMap.ts: -------------------------------------------------------------------------------- 1 | // this file map the component props to ant-design-vue form element 2 | // every prop should have five props 3 | // 1 component 确定对应是哪个 component 4 | // 2 更改 value 的 事件名称 5 | // 3 intialTransform 初始值的变换,有些初始值需要处理以后在传递给组件 6 | // 4 afterTransform 触发更改以后,不同类型需要不同处理,因为 e 的值是不同的,或者需要回灌的值不同 7 | // 5 text 属性对应的中文名称 8 | // 6 给组件赋值的时候 属性的名称,一般是 value,也有可能是另外的,比如 checkbox 就是 checked 9 | import { h, VNode } from 'vue' 10 | interface PropDetailType { 11 | component: string; 12 | eventName: string; 13 | intialTransform: (v: any) => any; 14 | afterTransform: (v: any) => any; 15 | text?: string; 16 | valueProp: string; 17 | subComponent?: string; 18 | options?: { text: string | VNode; value: any }[]; 19 | extraProps?: { [key: string]: any }; 20 | // 该属性有可能和其他联动,由改父属性控制它的行为 21 | parent?: string; 22 | // 可能还要向外传递更多事件 23 | extraEvent?: string; 24 | } 25 | interface MapTypes { 26 | [key: string]: PropDetailType; 27 | } 28 | 29 | const defaultMap = { 30 | component: 'a-input', 31 | eventName: 'change', 32 | valueProp: 'value', 33 | intialTransform: (v: any) => v, 34 | afterTransform: (e: any) => e 35 | } 36 | const numberToPxHandle = { 37 | ...defaultMap, 38 | component: 'a-input-number', 39 | intialTransform: (v: string) => v ? parseInt(v) : 0, 40 | afterTransform: (e: number) => e ? `${e}px` : '0' 41 | } 42 | const fontFamilyArr = [ 43 | { text: '宋体', value: '"SimSun","STSong"' }, 44 | { text: '黑体', value: '"SimHei","STHeiti"' }, 45 | { text: '楷体', value: '"KaiTi","STKaiti"' }, 46 | { text: '仿宋', value: '"FangSong","STFangsong"' }, 47 | { text: 'Arial', value: '"Arial", sans-serif' }, 48 | { text: 'Arial Black', value: '"Arial Black", sans-serif' }, 49 | { text: 'Comic Sans MS', value: '"Comic Sans MS"' }, 50 | { text: 'Courier New', value: '"Courier New", monospace' }, 51 | { text: 'Georgia', value: '"Georgia", serif' }, 52 | { text: 'Times New Roman', value: '"Times New Roman", serif' } 53 | ] 54 | 55 | const fontFamilyOptions = fontFamilyArr.map(font => { 56 | return { 57 | value: font.value, 58 | text: h('span', { style: { fontFamily: font.value } }, font.text) 59 | } 60 | }) 61 | const mapPropsToComponents: MapTypes = { 62 | text: { 63 | ...defaultMap, 64 | component: 'textarea-fix', 65 | afterTransform: (e: any) => e.target.value, 66 | text: '文本', 67 | extraProps: { rows: 3 } 68 | }, 69 | href: { 70 | ...defaultMap, 71 | afterTransform: (e: any) => e.target.value, 72 | text: '链接' 73 | }, 74 | fontSize: { 75 | ...numberToPxHandle, 76 | text: '字号' 77 | }, 78 | fontFamily: { 79 | ...defaultMap, 80 | component: 'a-select', 81 | subComponent: 'a-select-option', 82 | text: '字体', 83 | options: [ 84 | { value: '', text: '无' }, 85 | ...fontFamilyOptions 86 | ] 87 | }, 88 | fontWeight: { 89 | ...defaultMap, 90 | component: 'icon-switch', 91 | intialTransform: (v: string) => v === 'bold', 92 | afterTransform: (e: boolean) => e ? 'bold' : 'normal', 93 | valueProp: 'checked', 94 | extraProps: { iconName: 'BoldOutlined', tip: '加粗' } 95 | }, 96 | fontStyle: { 97 | ...defaultMap, 98 | component: 'icon-switch', 99 | intialTransform: (v: string) => v === 'italic', 100 | afterTransform: (e: boolean) => e ? 'italic' : 'normal', 101 | valueProp: 'checked', 102 | extraProps: { iconName: 'ItalicOutlined', tip: '斜体' } 103 | }, 104 | textDecoration: { 105 | ...defaultMap, 106 | component: 'icon-switch', 107 | intialTransform: (v: string) => v === 'underline', 108 | afterTransform: (e: boolean) => e ? 'underline' : 'none', 109 | valueProp: 'checked', 110 | extraProps: { iconName: 'UnderlineOutlined', tip: '下划线' } 111 | }, 112 | lineHeight: { 113 | ...defaultMap, 114 | component: 'a-slider', 115 | text: '行高', 116 | intialTransform: (v: string) => v ? parseFloat(v) : 0, 117 | afterTransform: (e: number) => e.toString(), 118 | extraProps: { min: 0, max: 3, step: 0.1 } 119 | }, 120 | textAlign: { 121 | ...defaultMap, 122 | component: 'a-radio-group', 123 | subComponent: 'a-radio-button', 124 | afterTransform: (e: any) => e.target.value, 125 | text: '对齐', 126 | options: [ 127 | { value: 'left', text: '左' }, 128 | { value: 'center', text: '中' }, 129 | { value: 'right', text: '右' } 130 | ] 131 | }, 132 | color: { 133 | ...defaultMap, 134 | component: 'color-picker', 135 | text: '文字颜色' 136 | }, 137 | backgroundColor: { 138 | ...defaultMap, 139 | component: 'color-picker', 140 | text: '背景颜色' 141 | }, 142 | // actions 143 | actionType: { 144 | ...defaultMap, 145 | component: 'a-select', 146 | subComponent: 'a-select-option', 147 | text: '点击', 148 | options: [ 149 | { value: '', text: '无' }, 150 | { value: 'to', text: '跳转到 URL' } 151 | ] 152 | }, 153 | url: { 154 | ...defaultMap, 155 | afterTransform: (e: any) => e.target.value, 156 | text: '链接', 157 | parent: 'actionType' 158 | }, 159 | // sizes 160 | height: { 161 | ...defaultMap, 162 | component: 'a-input-number', 163 | intialTransform: (v: string) => v ? parseInt(v) : '', 164 | afterTransform: (e: number) => e ? `${e}px` : '', 165 | text: '高度' 166 | }, 167 | width: { 168 | ...defaultMap, 169 | component: 'a-input-number', 170 | intialTransform: (v: string) => v ? parseInt(v) : '', 171 | afterTransform: (e: number) => e ? `${e}px` : '', 172 | text: '宽度' 173 | }, 174 | paddingLeft: { 175 | ...numberToPxHandle, 176 | text: '左边距' 177 | }, 178 | paddingRight: { 179 | ...numberToPxHandle, 180 | text: '右边距' 181 | }, 182 | paddingTop: { 183 | ...numberToPxHandle, 184 | text: '上边距' 185 | }, 186 | paddingBottom: { 187 | ...numberToPxHandle, 188 | text: '下边距' 189 | }, 190 | // border types 191 | borderStyle: { 192 | ...defaultMap, 193 | component: 'a-select', 194 | subComponent: 'a-select-option', 195 | text: '边框类型', 196 | options: [ 197 | { value: 'none', text: '无' }, 198 | { value: 'solid', text: '实线' }, 199 | { value: 'dashed', text: '破折线' }, 200 | { value: 'dotted', text: '点状线' } 201 | ] 202 | }, 203 | borderColor: { 204 | ...defaultMap, 205 | component: 'color-picker', 206 | text: '边框颜色' 207 | }, 208 | borderWidth: { 209 | ...defaultMap, 210 | component: 'a-slider', 211 | intialTransform: (v: string) => parseInt(v), 212 | afterTransform: (e: number) => e + 'px', 213 | text: '边框宽度', 214 | extraProps: { min: 0, max: 20 } 215 | }, 216 | borderRadius: { 217 | ...defaultMap, 218 | component: 'a-slider', 219 | intialTransform: (v: string) => parseInt(v), 220 | afterTransform: (e: number) => e + 'px', 221 | text: '边框圆角', 222 | extraProps: { min: 0, max: 200 } 223 | }, 224 | // shadow and opactiy 225 | opacity: { 226 | ...defaultMap, 227 | component: 'a-slider', 228 | text: '透明度', 229 | intialTransform: (v: number) => v ? v * 100 : 100, 230 | afterTransform: (e: number) => (e / 100), 231 | extraProps: { min: 0, max: 100, reverse: true } 232 | }, 233 | boxShadow: { 234 | ...defaultMap, 235 | component: 'shadow-picker' 236 | }, 237 | position: { 238 | ...defaultMap, 239 | component: 'a-select', 240 | subComponent: 'a-select-option', 241 | text: '定位', 242 | options: [ 243 | { value: '', text: '默认' }, 244 | { value: 'absolute', text: '绝对定位' } 245 | ] 246 | }, 247 | left: { 248 | ...numberToPxHandle, 249 | text: 'X轴坐标' 250 | }, 251 | top: { 252 | ...numberToPxHandle, 253 | text: 'Y轴坐标' 254 | }, 255 | imageSrc: { 256 | ...defaultMap, 257 | component: 'image-processer' 258 | }, 259 | backgroundImage: { 260 | ...defaultMap, 261 | component: 'background-processer', 262 | intialTransform: (v: string) => { 263 | if (v) { 264 | const matches = v.match(/\((.*?)\)/) 265 | if (matches && matches.length > 1) { 266 | return matches[1].replace(/('|")/g, '') 267 | } else { 268 | return '' 269 | } 270 | } else { 271 | return '' 272 | } 273 | }, 274 | afterTransform: (e: string) => e ? `url('${e}')` : '', 275 | extraProps: { ratio: 8 / 15, showDelete: true }, 276 | extraEvent: 'uploaded' 277 | }, 278 | backgroundSize: { 279 | ...defaultMap, 280 | component: 'a-select', 281 | subComponent: 'a-select-option', 282 | text: '背景大小', 283 | options: [ 284 | { value: 'contain', text: '自动缩放' }, 285 | { value: 'cover', text: '自动填充' }, 286 | { value: '', text: '默认' } 287 | ] 288 | }, 289 | backgroundRepeat: { 290 | ...defaultMap, 291 | component: 'a-select', 292 | subComponent: 'a-select-option', 293 | text: '背景重复', 294 | options: [ 295 | { value: 'no-repeat', text: '无重复' }, 296 | { value: 'repeat-x', text: 'X轴重复' }, 297 | { value: 'repeat-y', text: 'Y轴重复' }, 298 | { value: 'repeat', text: '全部重复' } 299 | ] 300 | } 301 | } 302 | 303 | export default mapPropsToComponents 304 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' 2 | import Index from '../views/Index.vue' 3 | import Home from '../views/Home.vue' 4 | import MyWork from '../views/MyWork.vue' 5 | import TemplateDetail from '../views/TemplateDetail.vue' 6 | import Setting from '../views/Setting.vue' 7 | import axios from 'axios' 8 | import store from '../store' 9 | 10 | const routes: Array = [ 11 | { 12 | path: '/', 13 | name: 'Index', 14 | component: Index, 15 | children: [ 16 | { path: '', name: 'Home', component: Home, meta: { title: '欢迎来到慕课乐高' } }, 17 | { path: 'mywork', name: 'MyWork', component: MyWork, meta: { requiredLogin: true, title: '我的设计列表' } }, 18 | { path: '/template/:id', name: 'TemplateDetail', component: TemplateDetail, meta: { title: '模版详情' } }, 19 | { path: 'setting', name: 'Setting', component: Setting, meta: { requiredLogin: true, title: '我的信息' } } 20 | ] 21 | }, 22 | { 23 | path: '/editor/:id', 24 | name: 'Editor', 25 | component: () => import(/* webpackChunkName: "editor" */ '../views/Editor.vue'), 26 | meta: { requiredLogin: true, title: '编辑我的设计' } 27 | }, 28 | { 29 | path: '/login', 30 | name: 'Login', 31 | component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue'), 32 | meta: { redirectAlreadyLogin: true, title: '登录到慕课乐高' } 33 | } 34 | ] 35 | const router = createRouter({ 36 | history: createWebHistory(), 37 | routes, 38 | scrollBehavior: (to, from, savedPosition) => { 39 | if (savedPosition) { 40 | return savedPosition 41 | } else { 42 | return Promise.resolve({ left: 0, top: 0 }) 43 | } 44 | } 45 | }) 46 | 47 | router.beforeEach((to, from, next) => { 48 | const { user } = store.state 49 | const { token, isLogin } = user 50 | const { requiredLogin, redirectAlreadyLogin, title } = to.meta 51 | if (title) { 52 | document.title = title 53 | } 54 | if (!isLogin) { 55 | if (token) { 56 | axios.defaults.headers.common.Authorization = `Bearer ${token}` 57 | store.dispatch('fetchCurrentUser').then((data: any) => { 58 | if (redirectAlreadyLogin) { 59 | next('/') 60 | } else { 61 | if (data.errno !== 0) { 62 | store.commit('logout') 63 | next('login') 64 | } else { 65 | next() 66 | } 67 | } 68 | }).catch(e => { 69 | console.error(e) 70 | store.commit('logout') 71 | next('login') 72 | }) 73 | } else { 74 | if (requiredLogin) { 75 | next('login') 76 | } else { 77 | next() 78 | } 79 | } 80 | } else { 81 | if (redirectAlreadyLogin) { 82 | next('/') 83 | } else { 84 | next() 85 | } 86 | } 87 | }) 88 | 89 | export default router 90 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { defineComponent } from 'vue' 3 | const component: ReturnType 4 | export default component 5 | } 6 | declare module 'vue-draggable-next' 7 | declare module 'qrcodejs2' 8 | declare module 'echarts/lib/echarts' -------------------------------------------------------------------------------- /src/store/editor.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { cloneDeep, isUndefined } from 'lodash' 4 | import { GlobalDataProps, asyncAndCommit } from './index' 5 | import { insertAt } from '../helper' 6 | import { MoveDirection } from '../plugins/dataOperations' 7 | export interface ComponentData { 8 | props: { [key: string]: any }; 9 | id: string; 10 | name: string; 11 | layerName?: string; 12 | isHidden?: boolean; 13 | isLocked?: boolean; 14 | } 15 | export interface PageData { 16 | props: { [key: string]: any }; 17 | setting: { [key: string]: any }; 18 | id?: number; 19 | title?: string; 20 | desc?: string; 21 | coverImg?: string; 22 | uuid?: string; 23 | latestPublishAt?: string; 24 | updatedAt?: string; 25 | isTemplate?: boolean; 26 | isHot?: boolean; 27 | isNew?: boolean; 28 | author?: string; 29 | copiedCount?: number; 30 | status?: string; 31 | user? : { 32 | gender: string; 33 | nickName: string; 34 | picture: string; 35 | userName: string; 36 | }; 37 | } 38 | export interface ChannelProps { 39 | id: number; 40 | name: string; 41 | workId: number; 42 | } 43 | 44 | export interface HistoryProps { 45 | id: string; 46 | componentId?: string; 47 | type: 'add' | 'delete' | 'modify'; 48 | data: any; 49 | index?: number; 50 | } 51 | export interface EditProps { 52 | // 页面所有组件 53 | components: ComponentData[]; 54 | // 当前被选中的组件 id 55 | currentElement: string; 56 | // 当前正在 inline editing 的组件 57 | currentEditing: string; 58 | // 当前数据已经被修改 59 | isDirty: boolean; 60 | // 当前模版是否修改但未发布 61 | isChangedNotPublished: boolean; 62 | // 当前被复制的组件 63 | copiedComponent?: ComponentData; 64 | // 当前 work 的数据 65 | page: PageData; 66 | // 当前 work 的 channels 67 | channels: ChannelProps[]; 68 | // 当前操作的历史记录 69 | histories: HistoryProps[]; 70 | // 当前历史记录的操作位置 71 | historyIndex: number; 72 | } 73 | const pageDefaultProps = { backgroundColor: '#ffffff', backgroundImage: '', backgroundRepeat: 'no-repeat', backgroundSize: 'cover', height: '560px' } 74 | // the max numbers for history items 75 | const maxHistoryNumber = 20 76 | const pushHistory = (state: EditProps, historyRecord: HistoryProps) => { 77 | // check if historyIndex is already moved 78 | if (state.historyIndex !== -1) { 79 | // if already moved, we need delete all the records greater than the index 80 | state.histories = state.histories.slice(0, state.historyIndex) 81 | // move the historyIndex to the last -1 82 | state.historyIndex = -1 83 | } 84 | // if histories length is less than max number, just push it to the end 85 | if (state.histories.length < maxHistoryNumber) { 86 | state.histories.push(historyRecord) 87 | } else { 88 | // if histories length is larger then max number, 89 | // 1 shift the first, 90 | // 2 push to last 91 | state.histories.shift() 92 | state.histories.push(historyRecord) 93 | } 94 | } 95 | 96 | const modifyHistory = (state: EditProps, history: HistoryProps, type: 'undo' | 'redo') => { 97 | const { componentId, data } = history 98 | const { key, oldValue, newValue } = data 99 | // modify the page setting 100 | if (!componentId) { 101 | state.page.props[key] = type === 'undo' ? oldValue : newValue 102 | } else { 103 | const updatedComponent = state.components.find((component) => component.id === componentId) as any 104 | if (Array.isArray(key)) { 105 | key.forEach((keyName: string, index) => { 106 | updatedComponent.props[keyName] = type === 'undo' ? oldValue[index] : newValue[index] 107 | }) 108 | } else { 109 | updatedComponent.props[key] = type === 'undo' ? oldValue : newValue 110 | } 111 | } 112 | } 113 | let globalTimeout = 0 114 | let cachedOldValue: any 115 | 116 | const debounceChange = (cachedValue: any, callback: () => void, timeout = 1000) => { 117 | if (globalTimeout) { 118 | clearTimeout(globalTimeout) 119 | } 120 | if (isUndefined(cachedOldValue)) { 121 | cachedOldValue = cachedValue 122 | } 123 | globalTimeout = setTimeout(() => { 124 | callback() 125 | globalTimeout = 0 126 | cachedOldValue = undefined 127 | }, timeout) 128 | } 129 | const editorModule: Module = { 130 | state: { 131 | components: [], 132 | currentElement: '', 133 | currentEditing: '', 134 | isDirty: false, 135 | isChangedNotPublished: false, 136 | page: { props: pageDefaultProps, setting: {} }, 137 | channels: [], 138 | histories: [], 139 | historyIndex: -1 140 | }, 141 | mutations: { 142 | // reset editor to clear 143 | resetEditor (state) { 144 | state.page = { props: pageDefaultProps, setting: {} } 145 | state.components = [] 146 | state.histories = [] 147 | state.isDirty = false 148 | state.isChangedNotPublished = false 149 | }, 150 | addComponentToEditor (state, component) { 151 | component.id = uuidv4() 152 | component.layerName = '图层' + (state.components.length + 1) 153 | state.components.push(component) 154 | pushHistory(state, { 155 | id: uuidv4(), 156 | componentId: component.id, 157 | type: 'add', 158 | data: cloneDeep(component) 159 | }) 160 | state.isDirty = true 161 | state.isChangedNotPublished = true 162 | }, 163 | // undo history 164 | undo (state) { 165 | // never undo before 166 | if (state.historyIndex === -1) { 167 | // undo to the last item of the histories array 168 | state.historyIndex = state.histories.length - 1 169 | } else { 170 | // undo to the previous step 171 | state.historyIndex-- 172 | } 173 | // get the record 174 | const history = state.histories[state.historyIndex] 175 | // process the history data 176 | switch (history.type) { 177 | case 'add': 178 | // if we create a component, then we should remove it 179 | state.components = state.components.filter(component => component.id !== history.componentId) 180 | break 181 | case 'delete': 182 | // if we delete a component, we should restore it at the right position 183 | state.components = insertAt(state.components, history.index as number, history.data) 184 | break 185 | case 'modify': { 186 | modifyHistory(state, history, 'undo') 187 | break 188 | } 189 | default: 190 | break 191 | } 192 | }, 193 | redo (state) { 194 | // can't redo when historyIndex is the last item or historyIndex is never moved 195 | if (state.historyIndex === -1) { 196 | return 197 | } 198 | // get the record 199 | const history = state.histories[state.historyIndex] 200 | // process the history data 201 | switch (history.type) { 202 | case 'add': 203 | state.components.push(history.data) 204 | // state.components = insertAt(state.components, history.index as number, history.data) 205 | break 206 | case 'delete': 207 | state.components = state.components.filter(component => component.id !== history.componentId) 208 | break 209 | case 'modify': { 210 | modifyHistory(state, history, 'redo') 211 | break 212 | } 213 | default: 214 | break 215 | } 216 | state.historyIndex++ 217 | }, 218 | setActive (state, id) { 219 | state.currentElement = id 220 | }, 221 | setEditing (state, id) { 222 | state.currentEditing = id 223 | }, 224 | updatePage (state, { key, value, level }) { 225 | const pageData = state.page as { [key: string]: any } 226 | if (level) { 227 | if (level === 'props') { 228 | const oldValue = pageData[level][key] 229 | debounceChange(oldValue, () => { 230 | pushHistory(state, { 231 | id: uuidv4(), 232 | type: 'modify', 233 | data: { oldValue: cachedOldValue, newValue: value, key } 234 | }) 235 | }) 236 | } 237 | pageData[level][key] = value 238 | } else { 239 | pageData[key] = value 240 | } 241 | state.isDirty = true 242 | state.isChangedNotPublished = true 243 | }, 244 | moveComponent (state, data: { direction: MoveDirection; amount: number }) { 245 | const updatedComponent = state.components.find((component) => component.id === state.currentElement) 246 | if (updatedComponent) { 247 | const store = this as any 248 | const oldTop = parseInt(updatedComponent.props.top) 249 | const oldLeft = parseInt(updatedComponent.props.left) 250 | const { direction, amount } = data 251 | switch (direction) { 252 | case 'Up': { 253 | const newValue = oldTop - amount + 'px' 254 | store.commit('updateComponent', { key: 'top', value: newValue, isProps: true }) 255 | break 256 | } 257 | case 'Down': { 258 | const newValue = oldTop + amount + 'px' 259 | store.commit('updateComponent', { key: 'top', value: newValue, isProps: true }) 260 | break 261 | } 262 | case 'Left': { 263 | const newValue = oldLeft - amount + 'px' 264 | store.commit('updateComponent', { key: 'left', value: newValue, isProps: true }) 265 | break 266 | } 267 | case 'Right': { 268 | const newValue = oldLeft + amount + 'px' 269 | store.commit('updateComponent', { key: 'left', value: newValue, isProps: true }) 270 | break 271 | } 272 | default: 273 | break 274 | } 275 | } 276 | }, 277 | updateComponent (state, { id, key, value, isProps }) { 278 | const updatedComponent = state.components.find((component) => component.id === (id || state.currentElement)) as any 279 | if (updatedComponent) { 280 | if (isProps) { 281 | const oldValue = Array.isArray(key) ? key.map((key: string) => updatedComponent.props[key]) : updatedComponent.props[key] 282 | 283 | debounceChange(oldValue, () => { 284 | pushHistory(state, { 285 | id: uuidv4(), 286 | componentId: (id || state.currentElement), 287 | type: 'modify', 288 | data: { oldValue: cachedOldValue, newValue: value, key } 289 | }) 290 | }) 291 | if (Array.isArray(key)) { 292 | key.forEach((keyName: string, index) => { 293 | updatedComponent.props[keyName] = value[index] 294 | }) 295 | } else { 296 | updatedComponent.props[key] = value 297 | } 298 | } else { 299 | updatedComponent[key] = value 300 | } 301 | state.isDirty = true 302 | state.isChangedNotPublished = true 303 | } 304 | }, 305 | copyComponent (state, index) { 306 | const currentComponent = state.components.find((component) => component.id === index) 307 | if (currentComponent) { 308 | state.copiedComponent = currentComponent 309 | } 310 | }, 311 | pasteCopiedComponent (state) { 312 | if (state.copiedComponent) { 313 | const clone = cloneDeep(state.copiedComponent) 314 | clone.id = uuidv4() 315 | clone.layerName = clone.layerName + '副本' 316 | state.components.push(clone) 317 | state.isDirty = true 318 | state.isChangedNotPublished = true 319 | pushHistory(state, { 320 | id: uuidv4(), 321 | componentId: clone.id, 322 | type: 'add', 323 | data: cloneDeep(clone) 324 | }) 325 | } 326 | }, 327 | deleteComponent (state, id) { 328 | // find the current component and index 329 | const componentData = state.components.find(component => component.id === id) as ComponentData 330 | const componentIndex = state.components.findIndex(component => component.id === id) 331 | state.components = state.components.filter(component => component.id !== id) 332 | pushHistory(state, { 333 | id: uuidv4(), 334 | componentId: componentData.id, 335 | type: 'delete', 336 | data: componentData, 337 | index: componentIndex 338 | }) 339 | state.isDirty = true 340 | state.isChangedNotPublished = true 341 | }, 342 | getWork (state, { data }) { 343 | const { content, ...rest } = data 344 | state.page = { ...state.page, ...rest } 345 | if (content.props) { 346 | state.page.props = { ...state.page.props, ...content.props } 347 | } 348 | if (content.setting) { 349 | state.page.setting = { ...state.page.setting, ...content.setting } 350 | } 351 | state.components = content.components 352 | }, 353 | getChannels (state, { data }) { 354 | state.channels = data.list 355 | }, 356 | createChannel (state, { data }) { 357 | state.channels = [...state.channels, data] 358 | }, 359 | deleteChannel (state, { extraData }) { 360 | state.channels = state.channels.filter(channel => channel.id !== extraData.id) 361 | }, 362 | saveWork (state) { 363 | state.isDirty = false 364 | state.page.updatedAt = new Date().toISOString() 365 | }, 366 | copyWork (state) { 367 | state.page.updatedAt = new Date().toISOString() 368 | }, 369 | publishWork (state) { 370 | state.isChangedNotPublished = false 371 | state.page.latestPublishAt = new Date().toISOString() 372 | }, 373 | publishTemplate (state) { 374 | state.page.isTemplate = true 375 | } 376 | }, 377 | actions: { 378 | getWork ({ commit }, id) { 379 | return asyncAndCommit(`/works/${id}`, 'getWork', commit) 380 | }, 381 | getChannels ({ commit }, id) { 382 | return asyncAndCommit(`/channel/getWorkChannels/${id}`, 'getChannels', commit) 383 | }, 384 | createChannel ({ commit }, payload) { 385 | return asyncAndCommit('/channel', 'createChannel', commit, { method: 'post', data: payload }) 386 | }, 387 | deleteChannel ({ commit }, id) { 388 | return asyncAndCommit(`channel/${id}`, 'deleteChannel', commit, { method: 'delete' }, { id }) 389 | }, 390 | saveWork ({ commit, state }, payload) { 391 | const { id, data } = payload 392 | if (data) { 393 | 394 | } else { 395 | // save current work 396 | const { title, desc, props, coverImg, setting } = state.page 397 | const postData = { 398 | title, 399 | desc, 400 | coverImg, 401 | content: { 402 | components: state.components, 403 | props, 404 | setting 405 | } 406 | } 407 | return asyncAndCommit(`/works/${id}`, 'saveWork', commit, { method: 'patch', data: postData }) 408 | } 409 | }, 410 | copyWork ({ commit }, id) { 411 | return asyncAndCommit(`/works/copy/${id}`, 'copyWork', commit, { method: 'post' }) 412 | }, 413 | publishWork ({ commit }, id) { 414 | return asyncAndCommit(`/works/publish/${id}`, 'publishWork', commit, { method: 'post' }) 415 | }, 416 | publishTemplate ({ commit }, id) { 417 | return asyncAndCommit(`/works/publish-template/${id}`, 'publishTemplate', commit, { method: 'post' }) 418 | }, 419 | saveAndPublishWork ({ dispatch, state }, payload) { 420 | const { id } = state.page 421 | return dispatch('saveWork', payload) 422 | .then(() => dispatch('publishWork', id)) 423 | .then(() => dispatch('getChannels', id)) 424 | .then(() => { 425 | if (state.channels.length === 0) { 426 | return dispatch('createChannel', { name: '默认', workId: id }) 427 | } else { 428 | return Promise.resolve({}) 429 | } 430 | }) 431 | } 432 | }, 433 | getters: { 434 | getCurrentElement: (state) => { 435 | return state.components.find((component) => component.id === state.currentElement) 436 | }, 437 | checkUndoDisable: (state) => { 438 | // no history item or move to the first item 439 | if (state.histories.length === 0 || state.historyIndex === 0) { 440 | return true 441 | } 442 | return false 443 | }, 444 | checkRedoDisable: (state) => { 445 | // 1 no history item 446 | // 2 move to the last item 447 | // 3 never undo before 448 | if (state.histories.length === 0 || 449 | state.historyIndex === state.histories.length || 450 | state.historyIndex === -1) { 451 | return true 452 | } 453 | return false 454 | } 455 | } 456 | } 457 | 458 | export default editorModule 459 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Commit } from 'vuex' 2 | import axios, { AxiosRequestConfig } from 'axios' 3 | import editor, { EditProps } from './editor' 4 | import user, { UserProps } from './user' 5 | import works, { WorksProp } from './works' 6 | export interface GlobalStatus { 7 | loading: boolean; 8 | error: any; 9 | opName?: string; 10 | } 11 | 12 | export interface GlobalDataProps { 13 | // user info 14 | user: UserProps; 15 | // 全局状态,loading,error 等等 16 | status: GlobalStatus; 17 | editor: EditProps; 18 | works: WorksProp; 19 | } 20 | export type ICustomAxiosConfig = AxiosRequestConfig & { 21 | mutationName: string; 22 | } 23 | export const asyncAndCommit = async (url: string, mutationName: string, 24 | commit: Commit, 25 | config: AxiosRequestConfig = { method: 'get' }, 26 | extraData?: any) => { 27 | const newConfig: ICustomAxiosConfig = { ...config, mutationName } 28 | const { data } = await axios(url, newConfig) 29 | if (extraData) { 30 | commit(mutationName, { data, extraData }) 31 | } else { 32 | commit(mutationName, data) 33 | } 34 | return data 35 | } 36 | export default createStore({ 37 | state: { 38 | user: {} as UserProps, 39 | status: { loading: false, error: { status: false, message: '' }, opName: '' }, 40 | editor: {} as EditProps, 41 | works: {} as WorksProp 42 | }, 43 | mutations: { 44 | setLoading (state, { status, opName }) { 45 | state.status.loading = status 46 | if (opName) { 47 | state.status.opName = opName 48 | } 49 | }, 50 | setError (state, e) { 51 | state.status.error = e 52 | } 53 | }, 54 | modules: { 55 | editor, 56 | user, 57 | works 58 | } 59 | }) 60 | -------------------------------------------------------------------------------- /src/store/user.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex' 2 | import axios from 'axios' 3 | import { GlobalDataProps, asyncAndCommit } from './index' 4 | 5 | export interface UserDataProps { 6 | username?: string; 7 | id?: string; 8 | phoneNumber?: string; 9 | nickName?: string; 10 | description?: string; 11 | updatedAt?: string; 12 | createdAt?: string; 13 | iat?: number; 14 | exp?: number; 15 | picture?: string; 16 | gender?: string; 17 | } 18 | 19 | export interface UserProps { 20 | isLogin: boolean; 21 | token: string; 22 | data: UserDataProps; 23 | } 24 | 25 | const userModule: Module = { 26 | state: { 27 | token: localStorage.getItem('token') || '', 28 | isLogin: false, 29 | data: { } 30 | }, 31 | mutations: { 32 | fetchCurrentUser (state, rawData) { 33 | state.isLogin = true 34 | state.data = { ...rawData.data } 35 | }, 36 | updateUser (state, { data, extraData }) { 37 | const { token } = data.data 38 | state.data = { ...state.data, ...extraData } 39 | state.token = token 40 | localStorage.setItem('token', token) 41 | axios.defaults.headers.common.Authorization = `Bearer ${token}` 42 | }, 43 | login (state, rawData) { 44 | const { token } = rawData.data 45 | state.token = token 46 | localStorage.setItem('token', token) 47 | axios.defaults.headers.common.Authorization = `Bearer ${token}` 48 | }, 49 | logout (state) { 50 | state.token = '' 51 | state.isLogin = false 52 | localStorage.removeItem('token') 53 | delete axios.defaults.headers.common.Authorization 54 | } 55 | }, 56 | actions: { 57 | fetchCurrentUser ({ commit }) { 58 | return asyncAndCommit('/users/getUserInfo', 'fetchCurrentUser', commit) 59 | }, 60 | login ({ commit }, payload) { 61 | return asyncAndCommit('/users/loginByPhoneNumber', 'login', commit, { method: 'post', data: payload }) 62 | }, 63 | loginAndFetch ({ dispatch }, loginData) { 64 | return dispatch('login', loginData).then(() => { 65 | return dispatch('fetchCurrentUser') 66 | }) 67 | }, 68 | updateUser ({ commit }, payload) { 69 | return asyncAndCommit('/users/updateUserInfo', 'updateUser', commit, { method: 'patch', data: payload }, payload) 70 | }, 71 | updateUserAndFetch ({ dispatch }, payload) { 72 | return dispatch('updateUser', payload).then(() => { 73 | return dispatch('fetchCurrentUser') 74 | }) 75 | } 76 | } 77 | } 78 | 79 | export default userModule 80 | -------------------------------------------------------------------------------- /src/store/works.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex' 2 | import { GlobalDataProps, asyncAndCommit } from './index' 3 | import { PageData } from './editor' 4 | import { objToQueryString } from '../helper' 5 | import { baseStaticURL } from '../main' 6 | export type WorkProp = Required> & { 7 | barcodeUrl?: string; 8 | } 9 | 10 | export interface StaticProps { 11 | eventDate: string; 12 | eventData: { pv: number }; 13 | eventKey: string; 14 | _id: string; 15 | } 16 | 17 | export interface WorksProp { 18 | templates: WorkProp[]; 19 | works: WorkProp[]; 20 | statics: { id: number; name: string; list: StaticProps[]}[]; 21 | totalWorks: number; 22 | totalTemplates: number; 23 | searchText: string; 24 | } 25 | 26 | const workModule: Module = { 27 | state: { 28 | templates: [], 29 | works: [], 30 | totalWorks: 0, 31 | statics: [], 32 | totalTemplates: 0, 33 | searchText: '' 34 | }, 35 | mutations: { 36 | fetchTemplates (state, { data, extraData }) { 37 | const { pageIndex, searchText } = extraData 38 | const { list, count } = data.data 39 | if (pageIndex === 0) { 40 | state.templates = list 41 | } else { 42 | state.templates = [...state.templates, ...list] 43 | } 44 | state.totalTemplates = count 45 | state.searchText = searchText || '' 46 | }, 47 | fetchTemplate (state, { data }) { 48 | state.templates = [data] 49 | }, 50 | fetchWorks (state, { data, extraData }) { 51 | const { pageIndex, searchText } = extraData 52 | const { list, count } = data.data 53 | // if (pageIndex === 0) { 54 | // state.works = list 55 | // } else { 56 | // state.works = [...state.works, ...list] 57 | // } 58 | state.works = list 59 | state.totalWorks = count 60 | state.searchText = searchText || '' 61 | }, 62 | createWork (state, { data }) { 63 | state.works.unshift(data) 64 | }, 65 | deleteWork (state, { extraData }) { 66 | state.works = state.works.filter(work => work.id !== extraData.id) 67 | }, 68 | recoverWork (state, { extraData }) { 69 | state.works = state.works.filter(work => work.id !== extraData.id) 70 | }, 71 | transferWork (state, { data, extraData }) { 72 | if (data.errno === 0) { 73 | state.works = state.works.filter(work => work.id !== extraData.id) 74 | } 75 | }, 76 | fetchStatic (state, { data, extraData }) { 77 | const list = data.data 78 | const { name, id } = extraData 79 | state.statics.push({ name, id, list }) 80 | }, 81 | clearStatic (state) { 82 | state.statics = [] 83 | } 84 | }, 85 | actions: { 86 | fetchTemplates ({ commit }, queryObj = { pageIndex: 0, pageSize: 8, title: '' }) { 87 | if (!queryObj.title) { 88 | delete queryObj.title 89 | } 90 | const queryString = objToQueryString(queryObj) 91 | return asyncAndCommit(`/templates?${queryString}`, 'fetchTemplates', commit, { method: 'get' }, { pageIndex: queryObj.pageIndex, searchText: queryObj.title }) 92 | }, 93 | fetchTemplate ({ commit }, id) { 94 | return asyncAndCommit(`/templates/${id}`, 'fetchTemplate', commit) 95 | }, 96 | fetchWorks ({ commit }, queryObj = { pageIndex: 0, pageSize: 8, title: '' }) { 97 | if (!queryObj.title) { 98 | delete queryObj.title 99 | } 100 | const queryString = objToQueryString(queryObj) 101 | return asyncAndCommit(`/works?${queryString}`, 'fetchWorks', commit, { method: 'get' }, { pageIndex: queryObj.pageIndex, searchText: queryObj.title }) 102 | }, 103 | deleteWork ({ commit }, id) { 104 | return asyncAndCommit(`/works/${id}`, 'deleteWork', commit, { method: 'delete' }, { id }) 105 | }, 106 | createWork ({ commit }, payload: WorkProp) { 107 | return asyncAndCommit('/works', 'createWork', commit, { method: 'post', data: payload }) 108 | }, 109 | fetchStatic ({ commit }, queryObj) { 110 | const newObj = { category: 'h5', action: 'pv', ...queryObj } 111 | const queryString = objToQueryString(newObj) 112 | return asyncAndCommit(`${baseStaticURL}/api/event?${queryString}`, 'fetchStatic', commit, { method: 'get' }, { name: queryObj.name, id: queryObj.label }) 113 | }, 114 | recoverWork ({ commit }, id) { 115 | return asyncAndCommit(`/works/put-back/${id}`, 'recoverWork', commit, { method: 'post' }, { id }) 116 | }, 117 | transferWork ({ commit }, { id, username }) { 118 | return asyncAndCommit(`/works/transfer/${id}/${username}`, 'transferWork', commit, { method: 'post' }, { id }) 119 | } 120 | }, 121 | getters: { 122 | getCurrentTemplate: state => (id: string) => { 123 | return state.templates.find((template) => template.id === parseInt(id)) 124 | } 125 | } 126 | } 127 | 128 | export default workModule 129 | -------------------------------------------------------------------------------- /src/test/unit/Foo.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /src/test/unit/IconSwitch.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import IconSwitch from '../../components/IconSwitch.vue' 3 | import Foo from './Foo.vue' 4 | 5 | describe('IconSwitch.vue', () => { 6 | const props = { 7 | iconName: 'BoldOutlined', 8 | checked: false, 9 | tip: 'tip' 10 | } 11 | const wrapper = mount(IconSwitch, { 12 | props, 13 | global: { 14 | components: { 15 | 'a-button': Foo, 16 | 'a-tooltip': { 17 | template: `
18 | 19 | 20 | 21 |
` 22 | } 23 | }, 24 | stubs: { 25 | BoldOutlined: { 26 | template: '' 27 | } 28 | } 29 | } 30 | }) 31 | it('inital render', () => { 32 | console.log(wrapper.html()) 33 | // should contain the tips 34 | expect(wrapper.find('.mock-tooltip').text()).toContain(props.tip) 35 | // should contain the button and type should be empty 36 | expect((wrapper.find('.stub-foo').attributes() as any).type).toBe('') 37 | // should contain the icon 38 | expect(wrapper.find('.icon').exists()).toBeTruthy() 39 | }) 40 | it('change the props should render different layout', async () => { 41 | await wrapper.setProps({ checked: true }) 42 | // type should be primary 43 | expect((wrapper.find('.stub-foo').attributes() as any).type).toBe('primary') 44 | }) 45 | it('click the element should emit the right event', () => { 46 | wrapper.trigger('click') 47 | console.log(wrapper.emitted()) 48 | expect(wrapper.emitted()).toHaveProperty('change') 49 | expect(wrapper.emitted().change[0]).toEqual([false]) 50 | }) 51 | }) -------------------------------------------------------------------------------- /src/views/ChannelForm.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 212 | 213 | 246 | -------------------------------------------------------------------------------- /src/views/Editor.vue: -------------------------------------------------------------------------------- 1 | 151 | 152 | 407 | 408 | 527 | -------------------------------------------------------------------------------- /src/views/HistoryArea.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 86 | 87 | 120 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 132 | 133 | 253 | -------------------------------------------------------------------------------- /src/views/Index.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 108 | 109 | 222 | -------------------------------------------------------------------------------- /src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 48 | 142 | 206 | -------------------------------------------------------------------------------- /src/views/MyWork.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 274 | 275 | 292 | -------------------------------------------------------------------------------- /src/views/PublishForm.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 146 | 147 | 159 | -------------------------------------------------------------------------------- /src/views/Setting.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 183 | 184 | 198 | -------------------------------------------------------------------------------- /src/views/TemplateDetail.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 102 | 103 | 123 | -------------------------------------------------------------------------------- /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 CompressionWebpackPlugin = require('compression-webpack-plugin') 3 | // 在vue-config.js 中加入 4 | // 开启gzip压缩 5 | // 判断开发环境 6 | const isStaging = !!process.env.VUE_APP_IS_STAGING 7 | const isProduction = process.env.NODE_ENV === 'production' 8 | 9 | module.exports = { 10 | publicPath: (isProduction && !isStaging) ? 'https://oss.imooc-lego.com/editor' : '/', 11 | css: { 12 | loaderOptions: { 13 | less: { 14 | lessOptions: { 15 | modifyVars: { 16 | 'primary-color': '#3E7FFF', 17 | 'border-radius-base': '20px', 18 | 'border-radius-sm': '10px' 19 | }, 20 | javascriptEnabled: true 21 | } 22 | } 23 | } 24 | }, 25 | configureWebpack: config => { 26 | // 开启gzip压缩 27 | if (isProduction) { 28 | config.plugins.push(new CompressionWebpackPlugin({ 29 | algorithm: 'gzip', 30 | test: /\.js$|\.html$|\.json$|\.css/, 31 | threshold: 10240, 32 | minRatio: 0.8 33 | })) 34 | // 开启分离js 35 | config.optimization = { 36 | runtimeChunk: 'single', 37 | splitChunks: { 38 | chunks: 'all', 39 | maxInitialRequests: Infinity, 40 | minSize: 500000, 41 | cacheGroups: { 42 | vendor: { 43 | test: /[\\/]node_modules[\\/]/, 44 | name (module) { 45 | // get the name. E.g. node_modules/packageName/not/this/part.js 46 | // or node_modules/packageName 47 | const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1] 48 | // npm package names are URL-safe, but some servers don't like @ symbols 49 | return `npm.${packageName.replace('@', '')}` 50 | } 51 | } 52 | } 53 | } 54 | } 55 | config.stats = { 56 | warnings: false 57 | } 58 | } 59 | }, 60 | chainWebpack: config => { 61 | config 62 | .plugin('html') 63 | .tap(args => { 64 | args[0].title = '慕课乐高' 65 | return args 66 | }) 67 | }, 68 | // 打包时不生成.map文件 69 | productionSourceMap: false 70 | } 71 | --------------------------------------------------------------------------------