├── .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 |
2 |
3 |
4 |
5 |
6 |
7 |
9 |
--------------------------------------------------------------------------------
/src/assets/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/imooc-lego/editor/0ab42ccb7f2f5e72531c6a36430cd3c1d0f6d48b/src/assets/background.png
--------------------------------------------------------------------------------
/src/assets/loading.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------
/src/components/BackgroundProcesser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
56 |
57 |
66 |
--------------------------------------------------------------------------------
/src/components/ColorPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
18 |
42 |
43 |
86 |
--------------------------------------------------------------------------------
/src/components/ComponentsList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 文本
9 |
10 |
11 |
15 |
16 |
17 | {{item.text}}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | 图片
26 |
27 |
28 | { handleFileUploaded(uploaded) }"
31 | :beforeUpload="commonUploadCheck"
32 | >
33 |
34 |
35 |
上传图片
36 |
37 |
38 |
39 |
40 |
上传中
41 |
42 |
43 |
44 |
45 |
46 |
上传图片
47 |
48 |
49 |
50 |
60 |
61 |
62 |
63 |
64 |
65 | 形状
66 |
67 |
68 |
76 |
77 |
78 |
79 |
80 |
81 |
300 |
301 |
360 |
--------------------------------------------------------------------------------
/src/components/ContextMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
65 |
66 |
84 |
--------------------------------------------------------------------------------
/src/components/EditGroup.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
93 |
94 |
97 |
--------------------------------------------------------------------------------
/src/components/EditWrapper.vue:
--------------------------------------------------------------------------------
1 |
2 |
18 |
19 |
20 |
193 |
194 |
243 |
--------------------------------------------------------------------------------
/src/components/IconSwitch.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{tip}}
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
47 |
48 |
51 |
--------------------------------------------------------------------------------
/src/components/ImageProcess.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
![]()
13 |
14 |
15 |
16 |
17 |
18 |
23 |
28 |
29 |
34 |
35 |
36 |
37 | 更换图片
38 |
39 |
40 |
41 |
42 | 裁剪图片
43 |
44 |
45 | 删除图片
46 |
47 |
48 |
49 |
50 |
51 |
136 |
137 |
164 |
--------------------------------------------------------------------------------
/src/components/InputEdit.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
80 |
81 |
95 |
--------------------------------------------------------------------------------
/src/components/LayerList.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | { handleChange(item.id, 'layerName', value)}"
30 | >
31 | {{item.layerName}}
32 |
33 |
34 |
35 |
37 |
38 |
39 |
40 |
41 |
42 |
88 |
89 |
115 |
--------------------------------------------------------------------------------
/src/components/PropsTable.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 | {{value.text}}:
9 |
10 |
17 |
24 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
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 |
2 |
3 |
4 |
阴影颜色:
5 |
6 | {updateValue(v, 3)}">
7 |
8 |
9 |
10 |
阴影大小:
11 |
12 |
{updateValue(v, [0, 1])}">
14 |
15 |
16 |
17 |
阴影模糊:
18 |
19 |
{updateValue(v, 2)}">
21 |
22 |
23 |
24 |
25 |
26 |
70 |
71 |
84 |
--------------------------------------------------------------------------------
/src/components/StyledUploader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
{{text}}
12 |
13 |
14 |
15 |
16 |
上传中
17 |
18 |
19 |
20 |
21 |
![]()
22 |
23 |
24 |
25 |
26 |
27 |
28 |
61 |
62 |
94 |
--------------------------------------------------------------------------------
/src/components/TemplateList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
{{(type === 'work') ? '编辑该作品': '使用该模版创建'}}
16 |
17 |
18 |
19 |
20 |
21 | 作者:{{item.user.nickName}}
22 | {{item.copiedCount}}
23 |
24 |
25 |
26 |
27 |
28 |
29 | HOT
30 |
31 |
32 | NEW
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
62 |
63 |
158 |
--------------------------------------------------------------------------------
/src/components/TextareaFix.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
49 |
50 |
53 |
--------------------------------------------------------------------------------
/src/components/Uploader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
20 |
21 |
22 |
102 |
107 |
--------------------------------------------------------------------------------
/src/components/UserProfile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | 登录
4 |
5 |
6 |
7 | 创建设计
8 |
9 |
10 | 我的作品
11 |
12 |
13 | {{user.data.nickName}}
14 |
15 |
16 | 我的作品
17 | 个人设置
18 | 登出
19 |
20 |
21 |
22 |
23 |
24 |
25 |
63 |
71 |
--------------------------------------------------------------------------------
/src/components/WorksList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
21 | {{ loading ? '加载中' : '转赠该作品'}}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | showBarcode(item.id, item.barcodeUrl)">
30 |
31 |
32 |
33 |
34 |
35 |
36 |
继续编辑该作品
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 复制
48 |
49 |
50 | 删除
51 |
52 |
53 | 下载图片
54 |
55 |
56 | 转赠
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | 未发布
69 |
70 |
71 | 已发布
72 |
73 |
74 |
75 |
76 |
77 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
3 |
4 |
5 | 封面图
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {{page.title}}
15 | {{page.desc}}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{channel.name}}
26 |
27 |
28 |
29 |
30 |
31 | 复制
32 |
33 |
34 |
35 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
48 | 创建新渠道
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | 模版信息
61 |
62 |
63 |
64 |
65 |
66 | 复制
67 |
68 |
69 |
70 |
71 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
212 |
213 |
246 |
--------------------------------------------------------------------------------
/src/views/Editor.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
{ setActive(id) }"
7 | />
8 |
15 |
22 |
23 |
24 |
25 |
26 |
27 | {{pageState.title}}
28 |
29 |
30 |
34 |
35 |
36 |
37 |
43 |
44 |
45 |
46 |
75 |
76 |
77 |
78 |
82 |
83 |
84 |
85 | 画布区域
86 |
87 |
88 |
89 |
90 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | 该元素被锁定,无法编辑
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | 在画布中选择元素并开始编辑
124 |
125 |
126 |
127 |
128 |
129 | { setActive(id, true) }"
132 | @change="handleChange"
133 | >
134 |
135 |
136 |
137 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
407 |
408 |
527 |
--------------------------------------------------------------------------------
/src/views/HistoryArea.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 | -
11 | {{item.text}} {{item.shortcut}}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | 快捷键提示
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | 撤销
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | 重做
36 |
37 |
38 |
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 |
86 |
87 |
120 |
--------------------------------------------------------------------------------
/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |

5 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | 专注H5 始终如一
21 | 三年保持行业领先
22 |
23 |
24 |
25 | 海量 H5 模版
26 | 一键生成,一分钟轻松制作
27 |
28 |
29 |
30 | 极致体验
31 | 用户的一致选择
32 |
33 |
34 |
35 |
36 |
37 |
38 | {{currentSearchText}}的结果
39 |
44 | ×
45 |
46 |
47 |
热门海报
48 |
只需替换文字和图片,一键自动生成H5
49 |
50 |
51 |
52 |
53 |
54 |
55 | 没找到任何海报 换个关键词试试
56 |
57 |
58 |
59 |
60 |
61 |
62 | 加载更多
63 |
64 |
65 |
66 |
67 | 我的作品
68 | 查看我的所有作品
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
132 |
133 |
253 |
--------------------------------------------------------------------------------
/src/views/Index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
67 |
74 |
75 |
76 |
77 |
78 |
79 |
108 |
109 |
222 |
--------------------------------------------------------------------------------
/src/views/Login.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
这是我用过的最好的建站工具
10 |
王铁锤, Google
11 |
12 |
13 |
14 |
18 | 欢迎回来
19 | 使用手机号码和验证码登录到慕课乐高
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
34 | {{ status.loading ? '加载中' : '登录'}}
35 |
36 |
40 | {{ counter === 60 ? '获取验证码' : `${counter}秒后重发` }}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
142 |
206 |
--------------------------------------------------------------------------------
/src/views/MyWork.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
14 |
17 |
18 |
19 |
20 |
21 |
22 |
{{currentSearchText}}的结果
23 |
28 | ×
29 |
30 |
31 | 我的作品和模版
32 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 还没有任何作品
48 |
49 |
50 | 创建你的第一个设计 🎉
51 |
52 |
53 |
54 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
274 |
275 |
292 |
--------------------------------------------------------------------------------
/src/views/PublishForm.vue:
--------------------------------------------------------------------------------
1 |
2 |
50 |
51 |
52 |
146 |
147 |
159 |
--------------------------------------------------------------------------------
/src/views/Setting.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 个人中心👤
6 |
7 |
8 |
9 |
10 | 你可以在这里修改昵称和头像
11 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | 男
30 |
31 |
32 | 女
33 |
34 |
35 |
36 |
37 |
40 | {{ status.loading ? '加载中' : '更新个人资料'}}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
53 |
54 |
55 |
56 | 恢复该作品
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
183 |
184 |
198 |
--------------------------------------------------------------------------------
/src/views/TemplateDetail.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{currentTemplate.title}}
9 | {{currentTemplate.desc}}
10 |
11 |
12 |
13 |
14 |
15 |
16 | 该模版由
{{currentTemplate.user.nickName}} 创作
17 |
18 |
22 |
38 |
39 |
40 |
41 |
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 |
--------------------------------------------------------------------------------