提交 378f7fd5 authored 作者: 王鹏飞's avatar 王鹏飞

chore: 学生端新增营销策划

上级 d91af3e5
......@@ -14,6 +14,7 @@
"@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.0.4",
"@vue-flow/core": "^1.17.4",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.8",
"blueimp-md5": "^2.19.0",
......@@ -21,9 +22,13 @@
"echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.6.3",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.1",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.7",
"pinia": "^2.1.7",
"scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.4.26",
"vue-echarts": "^6.6.9",
"vue-router": "^4.3.2",
......@@ -490,6 +495,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz",
"integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/standalone": {
"version": "7.21.2",
"resolved": "https://registry.npmmirror.com/@babel/standalone/-/standalone-7.21.2.tgz",
......@@ -1657,6 +1673,12 @@
"integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==",
"dev": true
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"optional": true
},
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
......@@ -2274,24 +2296,59 @@
"integrity": "sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==",
"dev": true
},
"node_modules/@vueuse/components": {
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/components/-/components-10.11.0.tgz",
"integrity": "sha512-ZvLZI23d5ZAtva5fGyYh/jQtZO8l+zJ5tAXyYNqHJZkq1o5yWyqZhENvSv5mfDmN5IuAOp4tq02mRmX/ipFGcg==",
"dependencies": {
"@vueuse/core": "10.11.0",
"@vueuse/shared": "10.11.0",
"vue-demi": ">=0.14.8"
}
},
"node_modules/@vueuse/components/node_modules/vue-demi": {
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/core": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
"integrity": "sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==",
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.0.tgz",
"integrity": "sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.9.0",
"@vueuse/shared": "10.9.0",
"vue-demi": ">=0.14.7"
"@vueuse/metadata": "10.11.0",
"@vueuse/shared": "10.11.0",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.7",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz",
"integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==",
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
......@@ -2314,28 +2371,28 @@
}
},
"node_modules/@vueuse/metadata": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.9.0.tgz",
"integrity": "sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==",
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.0.tgz",
"integrity": "sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.9.0.tgz",
"integrity": "sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==",
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.0.tgz",
"integrity": "sha512-fyNoIXEq3PfX1L3NkNhtVQUSRtqYwJtJg+Bp9rIzculIZWHTkKSysujrOk2J+NrRulLTQH9+3gGSfYLWSEWU1A==",
"dependencies": {
"vue-demi": ">=0.14.7"
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.7",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz",
"integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==",
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
......@@ -2852,7 +2909,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"peer": true,
"bin": {
"atob": "bin/atob.js"
},
......@@ -2918,6 +2974,15 @@
"node": ">= 0.4"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
......@@ -3137,6 +3202,17 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/buffer": {
"version": "4.9.2",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
......@@ -3288,6 +3364,31 @@
"optional": true,
"peer": true
},
"node_modules/canvg": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.10.tgz",
"integrity": "sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/canvg/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"optional": true
},
"node_modules/chalk": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.2.0.tgz",
......@@ -3447,6 +3548,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compute-scroll-into-view": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz",
"integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg=="
},
"node_modules/computeds": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz",
......@@ -3548,6 +3654,17 @@
"integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==",
"dev": true
},
"node_modules/core-js": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz",
"integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==",
"hasInstallScript": true,
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
......@@ -3640,6 +3757,15 @@
"node": "*"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
......@@ -3976,6 +4102,12 @@
"npm": ">=1.2"
}
},
"node_modules/dompurify": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.6.tgz",
"integrity": "sha512-zUTaUBO8pY4+iJMPE1B9XlO2tXVYIcEA4SNGtvDELzTSCQO7RzH+j7S180BmhmJId78lqGU2z19vgVx2Sxs/PQ==",
"optional": true
},
"node_modules/dotenv": {
"version": "16.0.3",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.0.3.tgz",
......@@ -4646,6 +4778,11 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="
},
"node_modules/figgy-pudding": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
......@@ -4683,6 +4820,11 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
......@@ -5218,6 +5360,24 @@
"optional": true,
"peer": true
},
"node_modules/html-to-image": {
"version": "1.11.11",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz",
"integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA=="
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/https-browserify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
......@@ -5608,6 +5768,23 @@
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"node_modules/jspdf": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.1.tgz",
"integrity": "sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA==",
"dependencies": {
"@babel/runtime": "^7.14.0",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.4.8"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^2.2.0",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jstoxml": {
"version": "2.2.9",
"resolved": "https://registry.npmmirror.com/jstoxml/-/jstoxml-2.2.9.tgz",
......@@ -6672,6 +6849,12 @@
"node": ">=0.12"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"optional": true
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.0.tgz",
......@@ -7025,6 +7208,15 @@
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
......@@ -7083,6 +7275,11 @@
"node": ">=8.10.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regex-not": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
......@@ -7186,6 +7383,15 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz",
......@@ -7320,6 +7526,14 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
"dependencies": {
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
......@@ -7651,6 +7865,15 @@
"figgy-pudding": "^3.5.1"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/static-extend": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
......@@ -7782,6 +8005,15 @@
"node": ">=8"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tapable": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/tapable/-/tapable-1.1.3.tgz",
......@@ -7917,6 +8149,15 @@
"source-map": "~0.6.1"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz",
......@@ -8662,6 +8903,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.10.tgz",
......
......@@ -21,6 +21,7 @@
"@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.0.4",
"@vue-flow/core": "^1.17.4",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.8",
"blueimp-md5": "^2.19.0",
......@@ -28,9 +29,13 @@
"echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0",
"element-plus": "^2.6.3",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.1",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.7",
"pinia": "^2.1.7",
"scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.4.26",
"vue-echarts": "^6.6.9",
"vue-router": "^4.3.2",
......
......@@ -2,5 +2,49 @@ import httpRequest from '@/utils/axios'
// 获取实验信息
export function getExperiment() {
return httpRequest.get('/api/lab/v1/experiment')
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/experiment')
}
// 获取营销策划完成记录
export function getRecords() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/step-complete-records')
}
// 获取连接列表
export function getConnections() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/connections')
}
// 获取除了固定属性之外的其他用户属性
export function getMemberAttrs() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/member-attrs')
}
// 获取事件列表
export function getEvents() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/events')
}
// 获取当前学员步骤
export function getSteps() {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/steps')
}
// 保存步骤
export function updateStep(data: { type: string; detail: string }) {
return httpRequest.post('/api/lab/v1/experiment/marketing-planning/save-steps', data)
}
// 验证当前步骤是否已经评分
export function checkStep(data: { type: number }) {
return httpRequest.post('/api/lab/v1/experiment/marketing-planning/check-step', data)
}
// 获取用户属性数据分析
export function getMemberAttrAnalysis(params: { attr_id: string; attr_type: string }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/member-attr-analysis', { params })
}
// 获取用户事件数据分析
export function getMemberEventAnalysis(params: { event_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/marketing-planning/member-event-analysis', { params })
}
<script setup>
import { toBlob, toCanvas } from 'html-to-image'
import { jsPDF } from 'jspdf'
// import { saveAs } from 'file-saver'
import { upload } from '@/utils/upload'
import Flow from './flow/Flow.vue'
import { useMarketStore } from '../stores/market'
import { vElementVisibility } from '@vueuse/components'
import scrollIntoView from 'scroll-into-view-if-needed'
const props = defineProps({
step: { type: Number, default: 1 },
experimentName: { type: String },
studentName: { type: String },
teacherName: { type: String },
detail: { type: Object }
})
const { objectiveStore, connectionStore, memberStore, labelStore, groupStore, tripStore, materialStore } = useMarketStore()
const step = ref(props.step)
const steps = [
{ name: '营销背景', step: 1 },
{ name: '营销渠道', step: 2 },
{ name: '用户分析', step: 3 },
{ name: '用户标签', step: 4 },
{ name: '用户分群', step: 5 },
{ name: '自动化旅程', step: 6 },
{ name: '营销物料', step: 7 }
]
const listOptions = computed(() => {
return {
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '一级流程节点', prop: 'node1' },
{ label: '二级流程节点', prop: 'node2' },
{ label: '营销物料类型', prop: 'type' },
{ label: '物料风格', prop: 'style' },
{ label: '物料侧重点', prop: 'desc' },
{ label: '物料更新频率', prop: 'update_rule' }
],
data: materialStore.materials
}
})
const reportRef = ref()
// 生成图片
async function generateImage() {
const blob = await toBlob(reportRef.value, { width: 1000 })
const url = await upload(blob)
return url
}
// 生成PDF
async function generatePdf() {
// const blob = await toBlob(reportRef.value, { width: 1000 })
// saveAs(blob, '营销策划报告.png')
const canvas = await toCanvas(reportRef.value)
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF('p', 'px', 'a4')
const pdfWidth = pdf.internal.pageSize.getWidth()
const pdfHeight = pdf.internal.pageSize.getHeight()
const canvasWidth = canvas.width
const canvasHeight = canvas.height
const imgWidth = pdfWidth
const imgHeight = (pdfWidth / canvasWidth) * canvasHeight
const totalPages = Math.ceil(imgHeight / pdfHeight)
for (let i = 0; i < totalPages; i++) {
if (i > 0) {
pdf.addPage()
}
const position = -i * pdfHeight
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
}
pdf.save('营销策划报告.pdf')
}
function onElementVisibility(state, stepIndex) {
if (state) step.value = stepIndex
}
function handleClick(item) {
const node = document.getElementById(`step${item.step}`)
scrollIntoView(node, {
behavior: 'smooth',
block: 'start'
})
step.value = item.step
}
function numberToChinese(num) {
// 简单的中文数字映射
const chineseNums = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
const chineseUnits = ['', '十', '百', '千', '万', '十', '百', '千', '亿']
if (num === 0) return chineseNums[0]
let str = ''
let unitIndex = 0
while (num > 0) {
let digit = num % 10 // 取出个位数字
if (digit !== 0) {
// 如果不为零,则添加中文数字和单位
str = chineseNums[digit] + chineseUnits[unitIndex] + str
} else if (str.length > 0 && str[0] !== chineseNums[0]) {
// 如果当前数字为零且上一个数字不为零,添加零
str = chineseNums[0] + str
}
num = Math.floor(num / 10)
unitIndex++
// 当到万位时,重置单位索引(因为接下来是亿)
if (unitIndex === 5) {
unitIndex = 1
}
}
// 去除开头的零(如果有的话)
if (str.startsWith(chineseNums[0])) {
str = str.slice(1)
}
return str
}
defineExpose({ generateImage, generatePdf })
</script>
<template>
<div class="market-report-wrapper">
<div class="market-report" ref="reportRef">
<div class="market-report-header">
<h1>{{ experimentName }}”实验<br />营销策划报告</h1>
<ul>
<li>策划人:{{ studentName }}</li>
<li>指导教师:{{ teacherName }}</li>
</ul>
</div>
<section id="step1" class="section" :class="{ hide: step !== 1 }" v-element-visibility="state => onElementVisibility(state, 1)">
<h2>一、营销背景</h2>
<h3>(一)当前业务面临的问题及挑战</h3>
<template v-for="(item, index) in objectiveStore.problems" :key="item.id">
<p>问题与挑战{{ index + 1 }}{{ item.content }}</p>
</template>
<h3>(二)业务部门营销目标</h3>
<template v-for="(item, index) in objectiveStore.objectives" :key="item.id">
<p>营销目标{{ index + 1 }}{{ item.content }}</p>
</template>
</section>
<section id="step2" class="section" :class="{ hide: step !== 2 }" v-element-visibility="state => onElementVisibility(state, 2)">
<h2>二、营销渠道</h2>
<p>本次营销选择的主要渠道为:</p>
<template v-for="(item, index) in connectionStore.activeConnections" :key="item.id">
<h3>{{ numberToChinese(index + 1) }}{{ item.type_name }}</h3>
<p>当前渠道拥有的用户数为:{{ item.member_count }}人,拥有的用户事件数量为:{{ item.event_count }}</p>
<p>选择该渠道的原因为:{{ item.content }}</p>
</template>
</section>
<section id="step3" class="section" :class="{ hide: step !== 3 }" v-element-visibility="state => onElementVisibility(state, 3)">
<h2>三、用户分析</h2>
<h3>(一)用户性别分析</h3>
<p>{{ memberStore.member.sex }}</p>
<div style="text-align: center">
<img :src="memberStore.member.sex_file" />
</div>
<h3>(二)用户数据来源分析</h3>
<p>{{ memberStore.member.source }}</p>
<div style="text-align: center">
<img :src="memberStore.member.source_file" />
</div>
</section>
<section id="step4" class="section" :class="{ hide: step !== 4 }" v-element-visibility="state => onElementVisibility(state, 4)">
<h2>四、用户标签体系设计</h2>
<template v-for="(item, index) in labelStore.treeLabels" :key="item.id">
<h3>{{ numberToChinese(index + 1) }}{{ item.name }}</h3>
<p>本项目设计如下{{ item.name }}</p>
<template v-for="(label, index) in item.children" :key="label.id">
<p>{{ index + 1 }}{{ label.name }}</p>
<p>该标签关联“{{ label.data_type }}”,关联字段为:{{ label.attr_name || label.event_name }}</p>
<p>该标签的设置规则为:{{ label.desc }}</p>
</template>
</template>
</section>
<section id="step5" class="section" :class="{ hide: step !== 5 }" v-element-visibility="state => onElementVisibility(state, 5)">
<h2>五、用户精准分群设计</h2>
<h3>(一)静态群组</h3>
<p>本项目设计如下静态群组:</p>
<template v-for="(item, index) in groupStore.staticGroups" :key="item.id">
<h3>{{ index + 1 }}{{ item.name }}</h3>
<p>该群组的加入规则为:{{ item.join_rule }}</p>
<p>该群组的移除规则为:{{ item.remove_rule }}</p>
<p>设计该群组的原因是:{{ item.reason }}</p>
</template>
<h3>(二)动态群组</h3>
<p>本项目设计如下动态群组:</p>
<template v-for="(item, index) in groupStore.dynamicGroups" :key="item.id">
<h3>{{ index + 1 }}{{ item.name }}</h3>
<p>该群组的加入规则为:{{ item.join_rule }}</p>
<p>该群组的移除规则为:{{ item.remove_rule }}</p>
<p>设计该群组的原因是:{{ item.reason }}</p>
</template>
</section>
<section id="step6" class="section" :class="{ hide: step !== 6 }" v-element-visibility="state => onElementVisibility(state, 6)">
<h2>六、自动化营销旅程设计</h2>
<h3>(一)一级流程</h3>
<p>本项目设计一级流程图如下。</p>
<Flow :nodes="tripStore.nodes" :edges="tripStore.edges" :nodes-draggable="false" :nodes-connectable="false" style="height: 200px"></Flow>
<p>相关节点设计说明如下:</p>
<template v-for="(item, index) in tripStore.nodes" :key="item.id">
<p>{{ index + 1 }}{{ item.data.label || item.label }}节点</p>
<p v-if="item.data.desc">节点说明:{{ item.data.desc }}</p>
<p>
节点类型:
<template v-if="item.type === 'start'">开始节点</template>
<template v-else-if="item.type === 'end'">结束节点</template>
<template v-else>业务节点</template>
</p>
<p v-if="item.type === 'start'">节点配置:触发时机为“{{ item.data.time }}”,触发条件为“{{ item.data.condition }}”。</p>
</template>
<h3>(二)二级流程</h3>
<p>本项目如下一级流程节点设计了二级流程。</p>
<template v-for="(item, index) in tripStore.node1List" :key="item.id">
<p>{{ index + 1 }}{{ item.data.label || item.label }}节点。该节点设计的二级流程图如下:</p>
<Flow :process="2" :nodes="item.data.nodes" :edges="item.data.edges" :nodes-draggable="false" :nodes-connectable="false" style="height: 200px"></Flow>
<p>该二级流程图节点说明如下:</p>
<template v-for="(item, index) in item.data.nodes" :key="item.id">
<p>{{ index + 1 }}{{ item.data.label || item.label }}节点</p>
<p v-if="item.data.desc">节点说明:{{ item.data.desc }}</p>
<p>
节点类型:
<template v-if="item.type === 'start'">开始节点</template>
<template v-else-if="item.type === 'end'">结束节点</template>
<template v-else>业务节点</template>
</p>
<p v-if="item.type === 'start'">节点配置:触发时机为“{{ item.data.time }}”,触发条件为“{{ item.data.condition }}”。</p>
<p v-if="item.type === 'custom'">是否用到营销物料:{{ item.use_material }}</p>
<p v-if="item.type === 'custom'">营销物料类型:{{ item.material_type }}</p>
</template>
</template>
</section>
<section id="step7" class="section" :class="{ hide: step !== 7 }" v-element-visibility="state => onElementVisibility(state, 7)">
<h2>七、营销物料设计</h2>
<p>本项目设计如下营销物料。</p>
<AppList v-bind="listOptions"></AppList>
</section>
<ul class="market-report-step">
<li v-for="(item, index) in steps" :key="index" :class="{ 'is-active': index + 1 === step }" @click="handleClick(item)">{{ item.name }}</li>
</ul>
</div>
</div>
</template>
<style lang="scss" scoped>
.market-report-wrapper {
max-width: 1000px;
margin: 0 auto;
}
.market-report {
padding: 0 40px;
background-color: #fff;
position: relative;
.market-report-header {
padding: 40px 0;
margin-bottom: 40px;
border-bottom: 1px solid #eee;
text-align: center;
h1 {
color: rgba(16, 16, 16, 1);
font-size: 18px;
font-weight: 700;
line-height: 25px;
}
ul {
margin-top: 40px;
display: flex;
align-items: center;
justify-content: space-evenly;
}
li {
color: rgba(118, 117, 117, 1);
font-weight: 400;
letter-spacing: 1px;
}
}
section {
margin-bottom: 20px;
// &.hide {
// display: none;
// }
}
h2 {
padding: 10px 0;
font-size: 20px;
}
h3 {
margin-top: 10px;
padding: 10px 0;
font-size: 16px;
}
p {
margin-left: 20px;
font-size: 14px;
line-height: 24px;
strong {
font-weight: bold;
}
}
img {
max-width: 100%;
}
.market-report-step {
position: fixed;
right: 50px;
top: 50%;
transform: translateY(-50%);
li {
margin-top: 10px;
width: 80px;
height: 58px;
line-height: 58px;
text-align: center;
border-radius: 10px;
border: 1px solid rgb(187, 187, 187);
box-sizing: border-box;
cursor: pointer;
background-color: #fff;
&.is-active {
background-color: rgb(189, 248, 180);
border: none;
}
}
}
}
</style>
<script setup>
import { ElMessage } from 'element-plus'
import { Plus, Minus } from '@element-plus/icons-vue'
import { useObjectiveStore } from '../stores/objective'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(1)
const objectiveStore = useObjectiveStore()
const { addProblem, removeProblem, addObjective, removeObjective } = objectiveStore
const emit = defineEmits(['submit', 'next'])
async function handleValidate() {
const { problems, objectives } = objectiveStore
const problemLength = problems.filter(item => !item.content).length
const objectiveLength = objectives.filter(item => !item.content).length
if (problemLength || objectiveLength) {
ElMessage.error('请填写完整')
return Promise.reject()
}
}
function genFormData() {
const { problems, objectives } = objectiveStore
return { type: 1, detail: { step1: { problems, objectives } } }
}
async function handleSubmit() {
await handleValidate()
emit('submit', genFormData())
}
async function handleNext() {
await handleValidate()
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div class="step-wrapper">
<h2 class="h2-title">营销背景及营销目标</h2>
<div class="market-step1">
<div class="market-step1-box">
<h4>当前业务面临问题/挑战</h4>
<ul>
<li v-for="(item, index) in objectiveStore.problems" :key="item.id">
<p>问题/挑战:</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="isCheck"></el-input>
<el-button
type="primary"
:icon="Plus"
@click="addProblem({ content: '' })"
:disabled="isCheck"
v-if="index === objectiveStore.problems.length - 1"></el-button>
<el-button type="primary" :icon="Minus" @click="removeProblem(item.id)" :disabled="isCheck" v-else></el-button>
</li>
</ul>
</div>
<div class="market-step1-box">
<h4>业务部门营销目标</h4>
<ul>
<li v-for="(item, index) in objectiveStore.objectives" :key="item.id">
<p>营销目标:</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="isCheck"></el-input>
<el-button
type="primary"
:icon="Plus"
@click="addObjective({ content: '' })"
:disabled="isCheck"
v-if="index === objectiveStore.objectives.length - 1"></el-button>
<el-button type="primary" :icon="Minus" @click="removeObjective(item.id)" :disabled="isCheck" v-else></el-button>
</li>
</ul>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.market-step1 {
display: flex;
gap: 20px;
}
.market-step1-box {
flex: 1;
padding: 20px;
border-radius: 10px;
background-color: #eef2f6;
h4 {
text-align: center;
}
ul {
width: 100%;
flex: 1;
}
li {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: center;
column-gap: 20px;
white-space: nowrap;
}
}
</style>
<script setup>
import { ElMessage } from 'element-plus'
import { Select } from '@element-plus/icons-vue'
import Icon from '@/components/ConnectionIcon.vue'
import { useConnectionStore } from '../stores/connection'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(2)
const connectionStore = useConnectionStore()
const emit = defineEmits(['submit', 'next'])
function handleClick(item) {
if (isCheck.value) return
item.active = !item.active
}
async function handleValidate() {
const listLength = connectionStore.currentConnections.filter(item => item.active && !item.content).length
if (listLength) {
ElMessage.error('请填写完整')
return Promise.reject()
}
}
function genFormData() {
const { connections, activeConnections } = connectionStore
return { type: 2, detail: { step2: { connections, activeConnections } } }
}
async function handleSubmit() {
await handleValidate()
emit('submit', genFormData())
}
async function handleNext() {
await handleValidate()
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div class="step-wrapper">
<div class="h2-title">
<h2>营销渠道选择</h2>
<el-button type="primary"><router-link :to="`/user?experiment_id=${$route.query.experiment_id}`" target="_blank">维护用户数据</router-link></el-button>
</div>
<div class="connect-list">
<div class="connect-list-item" v-for="item in connectionStore.currentConnections" :key="item.id" :class="{ 'is-active': item.active }">
<div class="connect-box connect-box__icon" @click="handleClick(item)">
<el-icon v-show="item.active"><Select /></el-icon>
<Icon w="40" h="40" :multiColor="true" class="svg" :name="item.type == 15 ? 'mall' : item.type"></Icon>
<p>{{ item.type_name }}</p>
</div>
<div class="connect-box connect-box__total">
<p>用户数据量:{{ item.member_count }}</p>
<p>用户事件数据量:{{ item.event_count }}</p>
</div>
<div v-show="item.active">
<p>*选择原因</p>
<el-input
type="textarea"
v-model="item.content"
show-word-limit
maxlength="100"
:autosize="{ minRows: 3, maxRows: 3 }"
:disabled="isCheck"></el-input>
</div>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.connect-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.connect-list-item {
width: 300px;
line-height: 1.4;
&.is-active {
color: var(--main-color);
.connect-box__total {
color: #fff;
background-color: var(--main-color);
}
}
}
.connect-box {
padding: 10px;
}
.connect-box__icon {
position: relative;
height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
.el-icon {
position: absolute;
left: 10px;
top: 10px;
}
}
.connect-box__total {
margin: 5px 0;
text-align: center;
color: var(--main-color);
}
.connect-box {
border: 1px dashed #ccc;
}
</style>
<script setup>
import { Plus, Minus } from '@element-plus/icons-vue'
import AppUpload from '@/components/base/AppUpload.vue'
import { useMemberAttrs } from '../composables/useData'
import { useMemberStore } from '../stores/member'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(3)
const memberStore = useMemberStore()
const { memberAttrs } = useMemberAttrs()
const emit = defineEmits(['submit', 'next'])
const rules = reactive({
sex: [{ required: true, message: '请输入', trigger: 'blur' }],
source: [{ required: true, message: '请输入', trigger: 'blur' }]
})
function handleAdd() {
memberStore.addAttr({ attr_id: '', attr_content: '', attr_file: '' })
}
function handleRemove(item) {
memberStore.removeAttr(item.id)
}
const formRef = ref(null)
async function handleValidate() {
return formRef.value.validate()
}
function genFormData() {
return { type: 3, detail: { step3: { ...memberStore.member } } }
}
async function handleSubmit() {
await handleValidate()
emit('submit', genFormData())
}
async function handleNext() {
await handleValidate()
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<div class="h2-title">
<h2>用户分析</h2>
<div>
<el-button type="primary"><router-link :to="`/user?experiment_id=${$route.query.experiment_id}`" target="_blank">用户个人画像</router-link></el-button>
<el-button type="primary">
<router-link :to="`/analyze/user?experiment_id=${$route.query.experiment_id}`" target="_blank">用户整体画像</router-link>
</el-button>
</div>
</div>
<el-form label-width="150" label-suffix=":" :model="memberStore.member" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="用户性别分析" prop="sex">
<div style="width: 100%">
<el-input type="textarea" v-model="memberStore.member.sex" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<AppUpload v-model="memberStore.member.sex_file" style="margin-top: 20px"></AppUpload>
</div>
</el-form-item>
<el-form-item label="用户数据来源分析" prop="source">
<div style="width: 100%">
<el-input type="textarea" v-model="memberStore.member.source" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
<AppUpload v-model="memberStore.member.source_file" style="margin-top: 20px"></AppUpload>
</div>
</el-form-item>
<el-form-item label="用户属性分析">
<ul>
<li v-for="(item, index) in memberStore.member.attrs" :key="index">
<div style="flex: 1">
<el-select-v2 v-model="item.attr_id" :options="memberAttrs" :props="{ label: 'name', value: 'id' }"></el-select-v2>
<el-input
type="textarea"
v-model="item.attr_content"
show-word-limit
maxlength="200"
:autosize="{ minRows: 3, maxRows: 6 }"
style="margin: 10px 0">
</el-input>
<AppUpload v-model="item.attr_file"></AppUpload>
</div>
<el-button type="primary" :icon="Minus" @click="handleRemove(item)"></el-button>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd"></el-button>
</el-form-item>
</el-form>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.el-card {
background-color: #eef2f6;
}
h4 {
text-align: center;
}
ul {
width: 100%;
}
li {
border-bottom: 1px solid #eef2f6;
padding-bottom: 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
column-gap: 20px;
}
</style>
<script setup lang="ts">
import { Plus, Minus } from '@element-plus/icons-vue'
import FormDialog from './Step4Form.vue'
import { useLabelStore } from '../stores/label'
import type { TypeState, LabelState } from '../stores/label'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(4)
const labelStore = useLabelStore()
const emit = defineEmits(['submit', 'next'])
const dialogVisible = ref(false)
const currentRow = ref()
// 添加
function handleAdd(item: TypeState) {
currentRow.value = { type_id: item.id, type_name: item.name }
dialogVisible.value = true
}
// 查看
function handleView(item: LabelState) {
currentRow.value = item
dialogVisible.value = true
}
// 删除
function handleRemove(item: LabelState) {
labelStore.removeLabel(item.id)
}
function genFormData() {
const { types, labels, treeLabels } = labelStore
return { type: 4, detail: { step4: { types, labels, treeLabels } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<h2 class="h2-title">标签体系设计</h2>
<div class="market-label">
<div class="market-label-box" v-for="item in labelStore.treeLabels" :key="item.name">
<h4>{{ item.name }}</h4>
<ul>
<li v-for="child in item.children" :key="child.id">
<p @click="handleView(child)">{{ child.name }}</p>
<!-- <el-button type="primary" text size="small" @click="handleView(child)">查看</el-button> -->
<el-button :icon="Minus" size="small" @click="handleRemove(child)" :disabled="isCheck"></el-button>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd(item)" :disabled="isCheck"></el-button>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<FormDialog v-model="dialogVisible" :data="currentRow" v-if="dialogVisible" />
</div>
</template>
<style lang="scss" scoped>
.market-label {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 20px;
}
.market-label-box {
padding: 20px;
border-radius: 20px;
background-color: #eef2f6;
min-height: 400px;
display: flex;
flex-direction: column;
align-items: center;
ul {
width: 100%;
flex: 1;
}
li {
margin: 20px 0;
display: flex;
align-items: center;
justify-content: center;
column-gap: 10px;
cursor: pointer;
p {
flex: 1;
line-height: 40px;
text-align: center;
background-color: #fff;
border-radius: 10px;
border: 1px solid #bbb;
}
}
}
</style>
<script setup>
import { nanoid } from 'nanoid'
import Step4FormAttr from './Step4FormAttr.vue'
import Step4FormEvent from './Step4FormEvent.vue'
import { useMemberAttrs, useEvents } from '../composables/useData'
import { useLabelStore } from '../stores/label'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(4)
const labelStore = useLabelStore()
const { addLabel, updateLabel } = labelStore
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data'])
const form = reactive({
id: nanoid(4),
type_id: '',
type_name: '',
name: '',
data_type: '用户属性',
attr_id: '',
attr_name: '',
attr_type: '',
event_id: '',
event_name: '',
desc: ''
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
name: [{ required: true, message: '请输入', trigger: 'blur' }],
desc: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const { memberAttrs } = useMemberAttrs()
const handleAttrChange = value => {
const found = memberAttrs.value.find(item => item.id === value)
form.attr_name = found.name
form.attr_type = found.type
form.attr_type_name = found.type_name
}
const { events } = useEvents()
const handleEventChange = value => {
const found = events.value.find(item => item.id === value)
form.event_name = found.name
}
const handleSubmit = async () => {
await formRef.value.validate()
props.data.id ? updateLabel(toRaw(form)) : addLabel(toRaw(form))
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog title="设计标签" width="800" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="160" :model="form" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="标签所属目录">
<el-select-v2 v-model="form.type_name" :options="labelStore.types" :props="{ label: 'name', value: 'name' }" disabled></el-select-v2>
</el-form-item>
<el-form-item label="标签名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="关联数据类型" prop="data_type">
<el-radio-group v-model="form.data_type">
<el-radio label="用户属性" value="用户属性"></el-radio>
<el-radio label="用户行为事件" value="用户行为事件"></el-radio>
<el-radio label="其他" value="其他"></el-radio>
</el-radio-group>
</el-form-item>
<!-- 用户属性 -->
<template v-if="form.data_type == '用户属性'">
<el-form-item label="用户属性字段">
<el-select-v2 v-model="form.attr_id" :options="memberAttrs" :props="{ label: 'name', value: 'id' }" @change="handleAttrChange"></el-select-v2>
</el-form-item>
<el-form-item label="字段类型">{{ form.attr_type_name }}</el-form-item>
<Step4FormAttr :attrId="form.attr_id" :attrType="form.attr_type"></Step4FormAttr>
</template>
<!-- 用户行为事件 -->
<template v-if="form.data_type == '用户行为事件'">
<el-form-item label="用户行为事件">
<el-select-v2 v-model="form.event_id" :options="events" :props="{ label: 'name', value: 'id' }" @change="handleEventChange"></el-select-v2>
</el-form-item>
<Step4FormEvent :eventId="form.event_id"></Step4FormEvent>
</template>
<el-form-item label="标签设置规则及说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit" :disabled="isCheck">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import { getMemberAttrAnalysis } from '../api'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, TitleComponent, TooltipComponent, LegendComponent])
const props = defineProps(['attrId', 'attrType'])
const detail = ref({
str_analysis: {
items: []
},
num_analysis: {
avg: '0',
min: '0',
max: '0',
first_quarter: '0',
median: '0',
three_quarters: '0'
}
})
async function fetchInfo() {
const res = await getMemberAttrAnalysis({ attr_id: props.attrId, attr_type: props.attrType })
detail.value = res.data
}
watch(() => props.attrId, fetchInfo)
const options = computed(() => {
return {
color: ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c', '#8b8920', '#a25a6d'],
tooltip: { trigger: 'item', formatter: '{b}: {c}<br />{d}%' },
series: [
{
type: 'pie',
label: { formatter: '{b}\n{d}%' },
itemStyle: { borderRadius: 6 },
radius: [0, '70%'],
data: detail.value.str_analysis.items.map(item => {
return { name: item.group_name, value: item.total }
})
}
]
}
})
</script>
<template>
<div>
<el-form-item label="字段值分布">
<template v-if="attrType == 2">
<div>
<el-form-item label-width="auto" label="平均值">{{ detail.num_analysis.avg }}</el-form-item>
<el-form-item label-width="auto" label="最大值">{{ detail.num_analysis.max }}</el-form-item>
<el-form-item label-width="auto" label="最小值">{{ detail.num_analysis.min }}</el-form-item>
</div>
<div style="margin-left: 100px">
<el-form-item label-width="auto" label="1/4位数">{{ detail.num_analysis.first_quarter }}</el-form-item>
<el-form-item label-width="auto" label="中位数">{{ detail.num_analysis.median }}</el-form-item>
<el-form-item label-width="auto" label="3/4位数">{{ detail.num_analysis.three_quarters }}</el-form-item>
</div>
</template>
<template v-else>
<v-chart class="chart" :option="options" autoresize ref="chart" style="height: 200px" v-if="detail.str_analysis.items.length" />
<el-empty description="暂无数据" v-else />
</template>
</el-form-item>
</div>
</template>
<script setup>
import { getMemberEventAnalysis } from '../api'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const props = defineProps(['eventId'])
const detail = ref({
event_total: '0',
member_total: '0',
member_avg_total: '0',
start_time: '',
end_time: '',
items: []
})
async function fetchInfo() {
const res = await getMemberEventAnalysis({ event_id: props.eventId })
detail.value = res.data
}
watch(() => props.eventId, fetchInfo)
const options = computed(() => {
return {
grid: { left: '5%', top: '10%', right: '5%', bottom: '15%', containLabel: true },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
boundaryGap: ['20%', '20%'],
data: detail.value.items.map(item => item.group_name)
},
yAxis: { type: 'value' },
series: [
{
data: detail.value.items.map(item => item.total),
type: 'line',
smooth: true
}
]
}
})
</script>
<template>
<div>
<el-form-item label="事件数据总量">
{{ detail.event_total }}
<el-form-item label="事件人数">{{ detail.member_total }}</el-form-item>
<el-form-item label="人均事件数量">{{ detail.member_avg_total }}</el-form-item>
</el-form-item>
<el-form-item label="事件发生开始时间">
{{ detail.start_time }}
<el-form-item label="事件发生结束时间">{{ detail.end_time }} </el-form-item>
</el-form-item>
<el-form-item label="事件走势图">
<v-chart class="chart" :option="options" autoresize ref="chart" style="height: 200px" v-if="detail.items.length" />
<el-empty description="暂无数据" v-else />
</el-form-item>
</div>
</template>
<script setup>
import { Plus, CircleClose } from '@element-plus/icons-vue'
import FormDialog from './Step5Form.vue'
import { useGroupStore } from '../stores/group'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(5)
const groupStore = useGroupStore()
const emit = defineEmits(['submit', 'next'])
const dialogVisible = ref(false)
const currentRow = ref()
// 添加
function handleAdd(item) {
currentRow.value = item
dialogVisible.value = true
}
// 查看
function handleView(item) {
currentRow.value = item
dialogVisible.value = true
}
// 删除
function handleRemove(item) {
groupStore.removeGroup(item.id)
}
function genFormData() {
const { groups, staticGroups, dynamicGroups } = groupStore
return { type: 5, detail: { step5: { groups, staticGroups, dynamicGroups } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<div class="h2-title">
<h2>用户精准分群设计</h2>
<div>
<el-button type="primary"><router-link :to="`/group?experiment_id=${$route.query.experiment_id}`" target="_blank">维护用户群组</router-link></el-button>
</div>
</div>
<div class="market-group">
<div class="market-group-box">
<h4>静态用户群组</h4>
<ul>
<li v-for="(item, index) in groupStore.staticGroups" :key="index" @click="handleView(item)">
<p>{{ item.name }}</p>
<el-icon @click.stop="handleRemove(item)" class="remove" v-if="!isCheck"><CircleClose /></el-icon>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd({ type: 1, type_name: '静态群组' })" :disabled="isCheck"></el-button>
</div>
<div class="market-group-box">
<h4>动态用户群组</h4>
<ul>
<li v-for="(item, index) in groupStore.dynamicGroups" :key="index" @click="handleView(item)">
<p>{{ item.name }}</p>
<el-icon @click.stop="handleRemove(item)" class="remove" v-if="!isCheck"><CircleClose /></el-icon>
</li>
</ul>
<el-button type="primary" :icon="Plus" @click="handleAdd({ type: 2, type_name: '动态群组' })" :disabled="isCheck"></el-button>
</div>
</div>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<FormDialog v-model="dialogVisible" :data="currentRow" v-if="dialogVisible" />
</div>
</template>
<style lang="scss" scoped>
.market-group-box {
margin: 20px 0;
padding: 20px;
border-radius: 20px;
background-color: #eef2f6;
min-height: 200px;
ul {
margin: 40px 0;
display: flex;
flex-wrap: wrap;
gap: 20px;
}
li {
position: relative;
width: 128px;
height: 128px;
background-color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid #ccc;
.remove {
display: none;
position: absolute;
color: var(--main-color);
font-size: 30px;
top: 5px;
right: 5px;
}
&:hover {
.remove {
display: block;
}
}
}
}
</style>
<script setup>
import { useGroupStore } from '../stores/group'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(5)
const groupStore = useGroupStore()
const { addGroup, updateGroup } = groupStore
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data'])
const form = reactive({
type: 1,
name: '',
join_rule: '',
remove_rule: '',
reason: ''
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
name: [{ required: true, message: '请输入', trigger: 'blur' }],
join_rule: [{ required: true, message: '请输入', trigger: 'blur' }],
remove_rule: [{ required: true, message: '请输入', trigger: 'blur' }],
reason: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const handleSubmit = async () => {
await formRef.value.validate()
props.data.id ? updateGroup(toRaw(form)) : addGroup(toRaw(form))
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog title="用户群组设计" width="600" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="120" :model="form" :rules="rules" ref="formRef" :disabled="isCheck">
<el-form-item label="用户群组类型">
<el-radio-group v-model="form.type" disabled>
<el-radio label="静态群组" :value="1"></el-radio>
<el-radio label="动态群组" :value="2"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="群组名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="群组加入规则" prop="join_rule">
<el-input type="textarea" v-model="form.join_rule" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
<el-form-item label="群组移除规则" prop="remove_rule">
<el-input type="textarea" v-model="form.remove_rule" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
<el-form-item label="设计群组原因" prop="reason">
<el-input type="textarea" v-model="form.reason" show-word-limit maxlength="100" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit" :disabled="isCheck">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import Flow from './flow/Flow.vue'
import { useTripStore } from '../stores/trip'
import { useObjectiveStore } from '../stores/objective'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(6)
const tripStore = useTripStore()
const objectiveStore = useObjectiveStore()
const emit = defineEmits(['submit', 'next'])
function genFormData() {
const { nodes, edges } = tripStore
return { type: 6, detail: { step6: { nodes, edges } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
const problemDialogVisible = ref(false)
const objectiveDialogVisible = ref(false)
</script>
<template>
<div class="market-trip">
<div class="h2-title">
<h2>自动化营销旅程设计</h2>
<div>
<el-button type="primary">
<router-link :to="`/trip/my?experiment_id=${$route.query.experiment_id}`" target="_blank">维护自动化营销旅程</router-link>
</el-button>
</div>
</div>
<el-row justify="center">
<el-space :size="100">
<el-button type="primary" size="large" @click="problemDialogVisible = true">当前面临的问题与挑战</el-button>
<el-button type="primary" size="large" @click="objectiveDialogVisible = true">业务部门的营销目标</el-button>
</el-space>
</el-row>
<Flow v-model:nodes="tripStore.nodes" v-model:edges="tripStore.edges" style="height: 60vh"></Flow>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<el-dialog title="当前面临的问题与挑战" v-model="problemDialogVisible" width="600">
<ul>
<li v-for="item in objectiveStore.problems" :key="item.id">{{ item.content }}</li>
</ul>
</el-dialog>
<el-dialog title="业务部门的营销目标" v-model="objectiveDialogVisible" width="600">
<ul>
<li v-for="item in objectiveStore.objectives" :key="item.id">{{ item.content }}</li>
</ul>
</el-dialog>
</div>
</template>
<script setup>
import FormDialog from './Step7Form.vue'
import { useMaterialStore } from '../stores/material'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(7)
const materialStore = useMaterialStore()
const listOptions = computed(() => {
return {
data: materialStore.materials,
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '一级流程节点', prop: 'node1' },
{ label: '二级流程节点', prop: 'node2' },
{ label: '营销物料类型', prop: 'type' },
{ label: '物料风格', prop: 'style' },
{ label: '物料侧重点', prop: 'desc' },
{ label: '物料更新频率', prop: 'update_rule' },
{ label: '操作', slots: 'table-x', width: 140 }
]
}
})
const emit = defineEmits(['submit', 'next'])
const dialogVisible = ref(false)
const currentRow = ref()
// 添加
function handleAdd() {
currentRow.value = null
dialogVisible.value = true
}
// 查看
function handleView(item) {
currentRow.value = item
dialogVisible.value = true
}
// 删除
function handleRemove(item) {
materialStore.removeMaterial(item.id)
}
function genFormData() {
const { materials } = materialStore
return { type: 7, detail: { step7: { materials } } }
}
async function handleSubmit() {
emit('submit', genFormData())
}
async function handleNext() {
emit('next', genFormData(), isCheck.value)
}
</script>
<template>
<div>
<div class="h2-title">
<h2>营销物料设计</h2>
<div>
<el-button type="primary" @click="handleAdd" :disabled="isCheck">新建</el-button>
</div>
</div>
<AppList v-bind="listOptions">
<template #table-x="{ row }">
<el-button text type="primary" @click="handleView(row)" :disabled="isCheck">编辑</el-button>
<el-button text type="danger" @click="handleRemove(row)" :disabled="isCheck">删除</el-button>
</template>
</AppList>
<div class="market-step-footer">
<el-button @click="handleSubmit" :disabled="isCheck">保存</el-button>
<el-button type="primary" @click="handleNext">下一步</el-button>
</div>
<FormDialog v-model="dialogVisible" :data="currentRow" v-if="dialogVisible" />
</div>
</template>
<script setup>
import { useMaterialStore } from '../stores/material'
import { useTripStore } from '../stores/trip'
import { useMapStore } from '@/stores/map'
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const tripStore = useTripStore()
const materialStore = useMaterialStore()
const { addMaterial, updateMaterial } = materialStore
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data'])
const form = reactive({
node1: '',
node2: '',
type: '',
style: '',
desc: '',
update_rule: '低'
})
watchEffect(() => {
Object.assign(form, props.data)
})
const formRef = ref(null)
const rules = reactive({
node1: [{ required: true, message: '请选择', trigger: 'blur' }],
node2: [{ required: true, message: '请选择', trigger: 'blur' }],
type: [{ required: true, message: '请选择', trigger: 'blur' }],
style: [{ required: true, message: '请选择', trigger: 'blur' }],
desc: [{ required: true, message: '请输入', trigger: 'blur' }],
update_rule: [{ required: true, message: '请选择', trigger: 'blur' }]
})
const handleSubmit = async () => {
await formRef.value.validate()
props.data?.id ? updateMaterial(toRaw(form)) : addMaterial(toRaw(form))
emit('update:modelValue', false)
}
const styleList = ['专业权威', '详细深入', '时尚年轻', '种草分享', '网红推荐', '生动有趣']
</script>
<template>
<el-dialog title="自动化营销旅程设计-营销物料" width="600" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="140" :model="form" :rules="rules" ref="formRef">
<el-form-item label="一级流程节点" prop="node1">
<el-select-v2 v-model="form.node1" :options="tripStore.node1List" :props="{ label: 'label', value: 'label' }"></el-select-v2>
</el-form-item>
<el-form-item label="二级流程节点" prop="node2">
<el-select-v2 v-model="form.node2" :options="tripStore.node2List" :props="{ label: 'label', value: 'label' }"></el-select-v2>
</el-form-item>
<el-form-item label="营销物料类型" prop="type">
<el-select-v2 v-model="form.type" :options="materialTypeList" :props="{ label: 'label', value: 'label' }"></el-select-v2>
</el-form-item>
<el-form-item label="营销物料风格" prop="style">
<el-radio-group v-model="form.style">
<el-radio v-for="item in styleList" :label="item" :value="item" :key="item"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="营销物料侧重点" prop="desc">
<el-input v-model="form.desc"></el-input>
</el-form-item>
<el-form-item label="营销物料更新频率" prop="update_rule">
<el-radio-group v-model="form.update_rule">
<el-radio label="低" value="低"></el-radio>
<el-radio label="中" value="中"></el-radio>
<el-radio label="高" value="高"></el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import Report from './Report.vue'
import { useUserStore } from '@/stores/user'
import { useExperiment } from '../composables/useData'
import { useCheckStep } from '../composables/useData'
const { isCheck } = useCheckStep(8)
const { experiment } = useExperiment()
const userStore = useUserStore()
const emit = defineEmits(['submit'])
const reportRef = ref(null)
// 导出图片
function handleExport() {
// reportRef.value.generateImage()
reportRef.value.generatePdf()
}
async function genFormData() {
const report = await reportRef.value.generateImage()
return { type: 8, detail: { step8: { report } } }
}
async function handleSubmit() {
emit('submit', await genFormData())
}
</script>
<template>
<div>
<div class="h2-title">
<h2>营销策划报告</h2>
<el-button type="primary" @click="handleExport">导出</el-button>
</div>
<Report
:experimentName="experiment.name"
:teacherName="experiment.teacher_name"
:studentName="userStore.user.name || userStore.user.username"
ref="reportRef" />
<div class="market-step-footer">
<el-button type="primary" @click="handleSubmit" :disabled="isCheck">保存</el-button>
</div>
</div>
</template>
<script setup>
import { StepEdge, EdgeLabelRenderer, getSmoothStepPath, useVueFlow } from '@vue-flow/core'
import { Plus, Close } from '@element-plus/icons-vue'
import { nanoid } from 'nanoid'
const props = defineProps({
id: { type: String, required: true },
sourceX: { type: Number, required: true },
sourceY: { type: Number, required: true },
targetX: { type: Number, required: true },
targetY: { type: Number, required: true },
sourcePosition: { type: String, required: true },
targetPosition: { type: String, required: true },
data: { type: Object, required: false },
markerEnd: { type: String, required: false },
style: { type: Object, required: false },
selected: { type: Boolean, required: false }
})
const path = computed(() => getSmoothStepPath(props))
const { addNodes, removeEdges } = useVueFlow()
function onAdd() {
const position = { x: 0, y: 0 }
const newNode = {
id: nanoid(4),
type: 'custom',
label: `旅程节点`,
data: { label: '旅程节点' },
position
}
addNodes([newNode])
}
</script>
<script>
export default {
inheritAttrs: false
}
</script>
<template>
<StepEdge :id="id" :style="style" :path="path[0]" :marker-end="markerEnd" :interactionWidth="30"></StepEdge>
<EdgeLabelRenderer>
<div
:style="{
pointerEvents: 'all',
position: 'absolute',
transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2]}px)`
}">
<el-button-group>
<el-button :icon="Plus" circle @click="onAdd"></el-button>
<el-button :icon="Close" circle @click="removeEdges([id])"></el-button>
</el-button-group>
</div>
</EdgeLabelRenderer>
</template>
<script setup>
import { VueFlow, useVueFlow, MarkerType, ConnectionLineType } from '@vue-flow/core'
import NodeStart from './NodeStart.vue'
import NodeEnd from './NodeEnd.vue'
import NodeCustom from './NodeCustom.vue'
import EdgeCustom from './EdgeCustom.vue'
import { nanoid } from 'nanoid'
defineProps({
process: { type: Number, default: 1 }
})
const id = nanoid()
const { onConnect, addEdges } = useVueFlow(id)
onConnect(params => {
addEdges([
{
...params,
type: 'custom',
animated: true,
markerEnd: MarkerType.ArrowClosed
}
])
})
</script>
<template>
<VueFlow
:id="id"
fit-view-on-init
:zoom-on-scroll="false"
:prevent-scrolling="false"
:connection-radius="30"
snap-to-grid
:snap-grid="[180, 180]"
:connection-line-options="{ markerEnd: MarkerType.ArrowClosed, type: ConnectionLineType.Straight }">
<template #node-start="props">
<NodeStart :process="process" v-bind="props" />
</template>
<template #node-end="props">
<NodeEnd :process="process" v-bind="props" />
</template>
<template #node-custom="props">
<NodeCustom :process="process" v-bind="props" />
</template>
<template #edge-custom="props">
<EdgeCustom :process="process" v-bind="props" />
</template>
</VueFlow>
</template>
<style>
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
</style>
<script setup>
import { Position, Handle, useVueFlow } from '@vue-flow/core'
import { CircleCloseFilled } from '@element-plus/icons-vue'
import NodeCustomForm from './NodeCustomForm.vue'
const Flow = defineAsyncComponent(() => import('./Flow.vue'))
const props = defineProps(['label', 'data', 'process', 'selected', 'id'])
const dialogVisible = ref(false)
const flowDialogVisible = ref(false)
const nodes = ref([])
const edges = ref([])
watch(
() => props.data,
() => {
nodes.value = props.data.nodes || [
{
id: 'start',
type: 'start',
label: '子流程入口',
data: { label: '子流程入口' },
position: { x: 0, y: 0 }
},
{
id: '1',
type: 'custom',
label: '二级旅程节点',
data: { label: '二级旅程节点' },
position: { x: 360, y: 0 }
},
{
id: 'end',
type: 'end',
label: '子流程出口',
data: { label: '子流程出口' },
position: { x: 720, y: 0 }
}
]
edges.value = props.data.edges || [
{
id: 'start->1',
type: 'custom',
source: 'start',
target: '1',
animated: true,
markerEnd: 'arrowclosed'
},
{
id: '1->end',
type: 'custom',
source: '1',
target: 'end',
animated: true,
markerEnd: 'arrowclosed'
}
]
},
{ immediate: true }
)
const { removeNodes } = useVueFlow()
const isCompleted = computed(() => {
return !!props.data.label
})
const handleSubmit = async () => {
Object.assign(props.data, { nodes: nodes.value, edges: edges.value })
flowDialogVisible.value = false
}
</script>
<template>
<div class="flow-node flow-node-custom" :class="{ 'is-completed': isCompleted }">
<el-icon class="flow-node-custom__remove" @click="removeNodes([id])" v-if="selected"><CircleCloseFilled /></el-icon>
<Handle type="target" :position="Position.Left" />
<div class="flow-node-custom__inner">
<el-button type="primary" size="small" @click="dialogVisible = true">编辑</el-button>
<el-button type="primary" size="small" @click="flowDialogVisible = true" v-if="process != 2">子流程</el-button>
<div class="flow-node__label">{{ data.label || label }}</div>
</div>
<Handle type="source" :position="Position.Right" />
<NodeCustomForm :data="data" :process="process" v-model="dialogVisible" v-if="dialogVisible"></NodeCustomForm>
<el-dialog title="自动化营销旅程设计-二级流程" append-to-body width="1000" v-model="flowDialogVisible">
<Flow v-model:nodes="nodes" v-model:edges="edges" :process="2" style="height: 500px"></Flow>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="flowDialogVisible = false">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</div>
</template>
<style lang="scss">
@import './style.css';
.flow-node-custom {
&.is-completed {
color: #fff;
background-color: rgba(104, 187, 196, 0.52);
}
}
.flow-node-custom__remove {
position: absolute;
right: 0;
top: 0;
}
</style>
<script setup>
import { useMapStore } from '@/stores/map'
const materialTypeList = useMapStore().getMapValuesByKey('experiment_marketing_material_type')
const emit = defineEmits(['update:modelValue'])
const props = defineProps(['data', 'process'])
const title = computed(() => {
const subTitle = props.process == 2 ? '二级流程节点' : '一级流程节点'
return `自动化营销旅程设计-${subTitle}`
})
const formRef = ref(null)
const form = reactive({
label: '',
desc: '',
use_material: '是',
material_type: ''
})
onMounted(() => {
Object.assign(form, props.data)
})
const rules = reactive({
label: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const handleSubmit = async () => {
await formRef.value?.validate()
Object.assign(props.data, { ...form })
emit('update:modelValue', false)
}
</script>
<template>
<el-dialog :title="title" append-to-body width="600" @closed="$emit('update:modelValue', false)">
<el-form label-suffix=":" label-width="140" :model="form" :rules="rules" ref="formRef">
<el-form-item label="节点名称" prop="label">
<el-input v-model="form.label"></el-input>
</el-form-item>
<el-form-item label="流程节点说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
<template v-if="process == 2">
<el-form-item label="是否用到营销物料" prop="use_material">
<el-radio-group v-model="form.use_material">
<el-radio label="是" value="是"></el-radio>
<el-radio label="否" value="否"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="营销物料类型" prop="material_type">
<el-select-v2 v-model="form.type" :options="materialTypeList" :props="{ label: 'label', value: 'label' }" clearable></el-select-v2>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="$emit('update:modelValue', false)">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</template>
<script setup>
import { Position, Handle } from '@vue-flow/core'
defineProps(['label', 'process'])
</script>
<template>
<div class="flow-node flow-node-end">
<div class="flow-node__label">{{ label }}</div>
<Handle type="target" :position="Position.Left" />
<p class="flow-node-tips" v-if="process == 1">自动化旅程的结束不需要维护</p>
</div>
</template>
<style>
@import './style.css';
</style>
<script setup>
import { Position, Handle } from '@vue-flow/core'
const props = defineProps(['label', 'data', 'process'])
const dialogVisible = ref(false)
const formRef = ref(null)
const form = reactive({
time: '一次性触发',
condition: '无条件触发',
desc: ''
})
watch(dialogVisible, value => {
if (value) {
Object.assign(form, props.data)
} else {
formRef.value?.resetFields()
}
})
const rules = reactive({
time: [{ required: true, message: '请选择', trigger: 'blur' }],
condition: [{ required: true, message: '请选择', trigger: 'blur' }],
desc: [{ required: true, message: '请输入', trigger: 'blur' }]
})
const timeList = ['一次性触发', '周期性触发']
const conditionList = ['无条件触发', '固定条件触发', '动态条件触发']
const handleSubmit = async () => {
await formRef.value?.validate()
Object.assign(props.data, { ...form })
dialogVisible.value = false
}
</script>
<template>
<div class="flow-node flow-node-start" @click="dialogVisible = true">
<div class="flow-node__label">{{ label }}</div>
<Handle type="source" :position="Position.Right" />
<p class="flow-node-tips" v-if="process == 1">点击节点维护自动化营销旅程的触发条件</p>
<el-dialog v-model="dialogVisible" title="自动化营销旅程设计-旅程触发" append-to-body width="600">
<el-form label-suffix=":" label-width="140" :model="form" :rules="rules" ref="formRef">
<el-form-item label="旅程触发时机" prop="time">
<el-radio-group v-model="form.time">
<el-radio v-for="item in timeList" :key="item" :label="item" :value="item"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="旅程触发条件" prop="condition">
<el-radio-group v-model="form.condition">
<el-radio v-for="item in conditionList" :key="item" :label="item" :value="item"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="旅程触发条件说明" prop="desc">
<el-input type="textarea" v-model="form.desc" show-word-limit maxlength="200" :autosize="{ minRows: 3, maxRows: 6 }"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space @click="dialogVisible = false">关闭</el-button>
<el-button type="primary" auto-insert-space @click="handleSubmit">保存</el-button>
</el-row>
</template>
</el-dialog>
</div>
</template>
<style>
@import './style.css';
</style>
.flow-node {
position: relative;
width: 180px;
height: 160px;
border: 1px solid #bbb;
display: flex;
align-items: center;
justify-content: center;
}
.flow-node-start {
border-radius: 30px;
background-color: #bdf8b4;
}
.flow-node-end {
border-radius: 30px;
background-color: rgba(255, 2, 2, 0.32);
}
.flow-node-custom__inner {
text-align: center;
}
.flow-node__label {
padding: 10px 0;
text-align: center;
}
.is-computed {
color: #fff;
background-color: rgba(104, 187, 196, 0.52);
}
.flow-node-tips {
position: absolute;
left: 50%;
bottom: -30px;
transform: translateX(-50%);
font-size: 12px;
text-align: center;
color: #a6a6a6;
white-space: nowrap;
}
import { getExperiment, getConnections, getMemberAttrs, getEvents, checkStep } from '../api'
import { getNameByValue } from '@/utils/dictionary'
import { useMapStore } from '@/stores/map'
const connectionTypeList = useMapStore().getMapValuesByKey('experiment_connection_type')
const attrTypeList = useMapStore().getMapValuesByKey('experiment_attribute_type')
// 实验信息
export interface Experiment {
id: number
name: string
length: string
course_name: string
teacher_name: string
}
const experiment = ref<Partial<Experiment>>({})
export function useExperiment() {
async function fetchInfo() {
const res = await getExperiment()
const detail = res.data.detail
const course = detail.courses?.map((item: any) => item.name) || []
const teacher = detail.teachers?.map((item: any) => item.name) || []
experiment.value = { ...detail, course_name: course.join('、'), teacher_name: teacher.join('、') }
}
onMounted(() => {
fetchInfo()
})
return { experiment }
}
// 链接
export interface Connection {
id: string
type: string
type_name: string
member_count: string
event_count: string
}
const connections = ref<Connection[]>([])
export function useConnection() {
async function fetchInfo() {
const res = await getConnections()
connections.value = res.data.items.map((item: any) => {
return { ...item, type_name: getNameByValue(item.type, connectionTypeList) }
})
}
onMounted(() => {
fetchInfo()
})
return { connections }
}
// 用户属性
export interface MemberAttr {
id: string
name: string
english_name: string
type: string
format: string
}
const memberAttrs = ref<MemberAttr[]>([])
export function useMemberAttrs() {
async function fetchInfo() {
const res = await getMemberAttrs()
memberAttrs.value = res.data.items.map((item: any) => {
return { ...item, type_name: getNameByValue(item.type, attrTypeList) }
})
}
onMounted(() => {
fetchInfo()
})
return { memberAttrs }
}
// 事件
export interface EventAttr {
id: string
name: string
english_name: string
}
const events = ref<EventAttr[]>([])
export function useEvents() {
async function fetchInfo() {
const res = await getEvents()
events.value = res.data.items
}
onMounted(() => {
fetchInfo()
})
return { events }
}
export function useCheckStep(type: number) {
const isCheck = ref(false)
onMounted(async () => {
const res = await checkStep({ type })
isCheck.value = res.data.is_check
})
return { isCheck }
}
......@@ -5,7 +5,10 @@ const routes: RouteRecordRaw[] = [
{
path: '/market/my',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: 'update', component: () => import('./views/Update.vue') }
]
}
]
......
import { defineStore } from 'pinia'
import type { Connection } from '../composables/useData'
import { useConnection } from '../composables/useData'
export interface State {
connections: ConnectionState[]
}
export interface ConnectionState {
id: string
content: string
}
export interface CurrentConnection extends Connection {
active: boolean
content: string
}
export const useConnectionStore = defineStore('connection', () => {
const { connections: rawConnections } = useConnection()
const connections = ref<ConnectionState[]>([])
const currentConnections = ref<CurrentConnection[]>([])
const activeConnections = computed(() => {
return currentConnections.value.filter(item => item.active) || []
})
watch(
rawConnections,
() => {
setConnections(connections.value)
},
{ once: true }
)
watch(
currentConnections,
() => {
connections.value = currentConnections.value
.filter(item => item.active)
.map(item => {
return { id: item.id, content: item.content }
})
},
{ immediate: true, deep: true }
)
function setConnections(list: ConnectionState[]) {
connections.value = list
if (rawConnections.value.length === 0) return
currentConnections.value = rawConnections.value.map(item => {
const found = list?.find(({ id }) => id === item.id)
return found ? { ...item, active: true, content: found.content } : { ...item, active: false, content: '' }
})
}
function setData(data: State) {
if (!data?.connections) return
setConnections(data.connections)
}
return { connections, currentConnections, activeConnections, rawConnections, setConnections, setData }
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
groups: GroupState[]
}
export interface GroupState {
id: string
name: string
type: number
}
export const useGroupStore = defineStore('group', {
state: (): State => {
return {
groups: []
}
},
getters: {
staticGroups(state) {
return state.groups.filter(item => item.type == 1)
},
dynamicGroups(state) {
return state.groups.filter(item => item.type == 2)
}
},
actions: {
setData(data: State) {
if (!data?.groups) return
this.setGroups(data.groups)
},
setGroups(list: GroupState[]) {
this.groups = list
},
addGroup(group: Omit<GroupState, 'id'>) {
this.groups.push({ id: nanoid(4), ...group })
},
updateGroup(group: GroupState) {
const index = this.groups.findIndex(item => item.id === group.id)
this.groups[index] = group
},
removeGroup(id: string) {
this.groups = this.groups.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
types: TypeState[]
labels: LabelState[]
}
export interface TypeState {
id: string
name: string
}
export interface LabelState {
id: string
type_name: string
name: string
}
export const useLabelStore = defineStore('label', {
state: (): State => {
return {
types: [
{ id: '1', name: '基础标签' },
{ id: '2', name: '行为标签' },
{ id: '3', name: '业务标签' },
{ id: '4', name: '订单标签' },
{ id: '5', name: '渠道标签' }
],
labels: []
}
},
getters: {
treeLabels: state => {
return state.types.map(item => {
const children = state.labels.filter(child => child.type_name === item.name)
return { ...item, children }
})
}
},
actions: {
setData(data?: State) {
if (!data?.labels) return
this.setLabels(data.labels)
},
setLabels(list: LabelState[]) {
this.labels = list
},
addLabel(label: Omit<LabelState, 'id'>) {
this.labels.push({ id: nanoid(4), ...label })
},
updateLabel(label: LabelState) {
const index = this.labels.findIndex(item => item.id === label.id)
this.labels[index] = label
},
removeLabel(id: string) {
this.labels = this.labels.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { useObjectiveStore } from './objective'
import { useConnectionStore } from './connection'
import { useMemberStore } from './member'
import { useLabelStore } from './label'
import { useGroupStore } from './group'
import { useTripStore } from './trip'
import { useMaterialStore } from './material'
export const useMarketStore = defineStore('market', () => {
const objectiveStore = useObjectiveStore()
const connectionStore = useConnectionStore()
const memberStore = useMemberStore()
const labelStore = useLabelStore()
const groupStore = useGroupStore()
const tripStore = useTripStore()
const materialStore = useMaterialStore()
function setData(detail: any) {
objectiveStore.setData(detail.step1)
connectionStore.setData(detail.step2)
memberStore.setData(detail.step3)
labelStore.setData(detail.step4)
groupStore.setData(detail.step5)
tripStore.setData(detail.step6)
materialStore.setData(detail.step7)
}
return {
setData,
objectiveStore,
connectionStore,
memberStore,
labelStore,
groupStore,
tripStore,
materialStore
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
materials: MaterialState[]
}
export interface MaterialState {
id: string
node1: string
node2: string
type: string
style: string
update_rule: string
desc: string
}
export const useMaterialStore = defineStore('material', {
state: (): State => {
return {
materials: []
}
},
actions: {
setData(data?: State) {
if (!data?.materials) return
this.setMaterials(data.materials)
},
setMaterials(list: MaterialState[]) {
this.materials = list
},
addMaterial(data: Omit<MaterialState, 'id'>) {
this.materials.push({ id: nanoid(4), ...data })
},
updateMaterial(data: MaterialState) {
const index = this.materials.findIndex(item => item.id === data.id)
this.materials[index] = data
},
removeMaterial(id: string) {
this.materials = this.materials.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
member: MemberState
}
export interface MemberState {
sex: string
sex_file: string
source: string
source_file: string
attrs: MemberAttr[]
}
export interface MemberAttr {
id: string
attr_id: string
attr_content: string
attr_file: string
}
export const useMemberStore = defineStore('member', {
state: (): State => {
return {
member: {
sex: '',
sex_file: '',
source: '',
source_file: '',
attrs: []
}
}
},
actions: {
setData(data?: MemberState) {
if (!data) return
this.setMember(data)
},
setMember(data: MemberState) {
this.member = data
},
addAttr(data: Omit<MemberAttr, 'id'>) {
this.member.attrs.push({ id: nanoid(4), ...data })
},
removeAttr(id: string) {
this.member.attrs = this.member.attrs.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import { nanoid } from 'nanoid'
export interface State {
problems: ProblemState[]
objectives: ObjectiveState[]
}
export interface ProblemState {
id: string
content: string
}
export interface ObjectiveState {
id: string
content: string
}
export const useObjectiveStore = defineStore('objective', {
state: (): State => {
return {
problems: [{ id: nanoid(4), content: '' }],
objectives: [{ id: nanoid(4), content: '' }]
}
},
actions: {
setData(data?: State) {
if (!data?.problems) return
this.setProblems(data.problems)
this.setObjectives(data.objectives)
},
setProblems(list: ProblemState[]) {
this.problems = list
},
addProblem(data: Omit<ProblemState, 'id'>) {
this.problems.push({ id: nanoid(4), ...data })
},
updateProblem(data: ProblemState) {
const index = this.problems.findIndex(item => item.id === data.id)
this.problems[index] = data
},
removeProblem(id: string) {
this.problems = this.problems.filter(item => item.id !== id)
},
setObjectives(list: ObjectiveState[]) {
this.objectives = list
},
addObjective(data: Omit<ObjectiveState, 'id'>) {
this.objectives.push({ id: nanoid(4), ...data })
},
updateObjective(data: ObjectiveState) {
const index = this.objectives.findIndex(item => item.id === data.id)
this.objectives[index] = data
},
removeObjective(id: string) {
this.objectives = this.objectives.filter(item => item.id !== id)
}
}
})
import { defineStore } from 'pinia'
import type { Node, Edge } from '@vue-flow/core'
export interface State {
nodes: Node[]
edges: Edge[]
}
export interface ElementState {
id: string
name: string
type: string
}
export const useTripStore = defineStore('trip', {
state: (): State => {
return {
nodes: [
{
id: 'start',
type: 'start',
label: 'Start',
data: { label: 'Start' },
position: { x: 0, y: 0 }
},
{
id: '1',
type: 'custom',
label: '一级旅程节点',
data: { label: '一级旅程节点' },
position: { x: 360, y: 0 }
},
{
id: 'end',
type: 'end',
label: 'End',
data: { label: 'End' },
position: { x: 720, y: 0 }
}
],
edges: [
{
id: 'start->1',
type: 'custom',
source: 'start',
target: '1',
animated: true,
markerEnd: 'arrowclosed'
},
{
id: '1->end',
type: 'custom',
source: '1',
target: 'end',
animated: true,
markerEnd: 'arrowclosed'
}
]
}
},
getters: {
// 一级节点
node1List(state) {
return state.nodes.filter(node => node.type === 'custom')
},
// 二级节点
node2List() {
return this.node1List.reduce((list: Node[], node: Node) => {
const nodes = node.data.nodes?.filter((node: Node) => node.type === 'custom') || []
return list.concat(nodes)
}, [])
}
},
actions: {
setData(data?: State) {
if (!data?.nodes) return
this.setNodes(data.nodes)
this.setEdges(data.edges)
},
setNodes(list: Node[]) {
this.nodes = list
},
setEdges(list: Edge[]) {
this.edges = list
}
}
})
<script setup lang="ts">
<script setup>
import { Select } from '@element-plus/icons-vue'
import { useExperiment } from '../composables/useData'
const { experiment } = useExperiment()
import { getRecords } from '../api'
const typeArr = ['营销背景分析', '营销渠道选择', '用户分析', '用户标签体系设计', '用户精准分群', '自动化营销旅程设计', '营销物料设计', '营销策划报告']
// 列表配置
const listOptions = computed(() => {
return {
data: [{}, {}],
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '步骤名称', prop: 'class_name' },
{ label: '步骤状态', prop: 'name' },
{ label: '开始时间', prop: 'id' },
{ label: '更新时间', prop: 'id' },
{ label: '评分', prop: 'id' },
{ label: '操作', slots: 'table-x', width: 140 }
]
}
})
const listOptions = {
remote: {
httpRequest: getRecords,
callback({ items }) {
return { list: items }
}
},
columns: [
{ label: '序号', type: 'index', width: 60 },
{
label: '步骤名称',
prop: 'type',
computed({ row }) {
return typeArr[row.type - 1]
}
},
{ label: '步骤状态', prop: 'is_complete', slots: 'table-complete' },
{ label: '开始时间', prop: 'start_time' },
{ label: '更新时间', prop: 'update_time' },
{ label: '评分', prop: 'score' },
{ label: '操作', slots: 'table-x', width: 140 }
]
}
const currentRow = ref()
const dialogVisible = ref(false)
// 查看评语
const handleView = row => {
currentRow.value = row
dialogVisible.value = true
}
</script>
<template>
<AppCard>
<el-form label-suffix=":" inline class="info">
<el-form-item label="实验名称">信用卡数字营销</el-form-item>
<el-form-item label="课程名称">数字营销实训课程</el-form-item>
<el-form-item label="指导教师">张三疯</el-form-item>
<el-form-item label="实验学时">16学时</el-form-item>
<el-form-item label="实验名称">{{ experiment.name }}</el-form-item>
<el-form-item label="课程名称">{{ experiment.course_name }}</el-form-item>
<el-form-item label="指导教师">{{ experiment.teacher_name }}</el-form-item>
<el-form-item label="实验学时">{{ experiment.length }}学时</el-form-item>
</el-form>
<el-divider />
<h2 class="h2-title">营销策划</h2>
<AppList v-bind="listOptions">
<template>
<el-button text type="primary">编辑</el-button>
<el-button text type="primary">查看评语</el-button>
<template #table-complete="{ row }">
<el-icon v-if="row.is_complete"><Select color="green" /></el-icon>
</template>
<template #table-x="{ row }">
<el-button text type="primary">
<router-link :to="{ path: '/market/my/update', query: { experiment_id: $route.query.experiment_id, step: row.type } }">编辑</router-link>
</el-button>
<el-button text type="primary" @click="handleView(row)" v-if="row.comment">查看评语</el-button>
</template>
</AppList>
<el-dialog v-model="dialogVisible" title="查看评语" width="600">
<div v-html="currentRow.comment"></div>
<template #footer>
<el-row justify="center">
<el-button plain auto-insert-space round @click="dialogVisible = false">关闭</el-button>
</el-row>
</template>
</el-dialog>
</AppCard>
</template>
......
<script setup>
import { ElMessage } from 'element-plus'
import { getSteps, updateStep } from '../api'
import { useMarketStore } from '../stores/market'
import { useExperiment } from '../composables/useData'
const { experiment } = useExperiment()
const { setData } = useMarketStore()
const Step1 = defineAsyncComponent(() => import('../components/Step1.vue'))
const Step2 = defineAsyncComponent(() => import('../components/Step2.vue'))
const Step3 = defineAsyncComponent(() => import('../components/Step3.vue'))
const Step4 = defineAsyncComponent(() => import('../components/Step4.vue'))
const Step5 = defineAsyncComponent(() => import('../components/Step5.vue'))
const Step6 = defineAsyncComponent(() => import('../components/Step6.vue'))
const Step7 = defineAsyncComponent(() => import('../components/Step7.vue'))
const Step8 = defineAsyncComponent(() => import('../components/Step8.vue'))
const route = useRoute()
const step = route.query.step ? parseInt(route.query.step) : 1
const activeTab = ref(step)
const detail = reactive({ step1: {}, step2: {}, step3: {}, step4: {}, step5: {}, step6: {}, step7: {}, step8: {} })
async function fetchInfo() {
const res = await getSteps()
try {
const details = res.data.detail.details
Object.assign(detail, JSON.parse(details))
setData(detail)
} catch (error) {
console.log(error)
}
}
onMounted(() => {
fetchInfo()
})
// 提交
async function handleSubmit(data) {
Object.assign(detail, data.detail)
await updateStep({ type: data.type, detail: JSON.stringify(detail) })
ElMessage.success('保存成功')
}
// 下一步
async function handleNext(data, isCheck = false) {
if (!isCheck) await handleSubmit(data)
activeTab.value++
}
</script>
<template>
<AppCard full class="market">
<el-form label-suffix=":" inline class="info">
<el-form-item label="实验名称">{{ experiment.name }}</el-form-item>
<el-form-item label="课程名称">{{ experiment.course_name }}</el-form-item>
<el-form-item label="指导教师">{{ experiment.teacher_name }}</el-form-item>
<el-form-item label="实验学时">{{ experiment.length }}学时</el-form-item>
</el-form>
<el-divider />
<el-tabs v-model="activeTab" stretch class="market-tabs">
<el-tab-pane lazy label="第1步" :name="1">
<Step1 :data="detail.step1" @submit="handleSubmit" @next="handleNext"></Step1>
</el-tab-pane>
<el-tab-pane lazy label="第2步" :name="2">
<Step2 :data="detail.step2" @submit="handleSubmit" @next="handleNext"></Step2>
</el-tab-pane>
<el-tab-pane lazy label="第3步" :name="3">
<Step3 :data="detail.step3" @submit="handleSubmit" @next="handleNext"></Step3>
</el-tab-pane>
<el-tab-pane lazy label="第4步" :name="4">
<Step4 :data="detail.step4" @submit="handleSubmit" @next="handleNext"></Step4>
</el-tab-pane>
<el-tab-pane lazy label="第5步" :name="5">
<Step5 :data="detail.step5" @submit="handleSubmit" @next="handleNext"></Step5>
</el-tab-pane>
<el-tab-pane lazy label="第6步" :name="6">
<Step6 :data="detail.step6" @submit="handleSubmit" @next="handleNext"></Step6>
</el-tab-pane>
<el-tab-pane lazy label="第7步" :name="7">
<Step7 :data="detail.step7" :detail="detail" @submit="handleSubmit" @next="handleNext"></Step7>
</el-tab-pane>
<el-tab-pane lazy label="第8步" :name="8">
<Step8 :data="detail.step8" @submit="handleSubmit" @next="handleNext"></Step8>
</el-tab-pane>
</el-tabs>
</AppCard>
</template>
<style lang="scss">
.market {
.info {
display: flex;
justify-content: space-between;
}
}
.market-tabs {
.el-tabs__header {
margin: 0 auto;
max-width: 1000px;
}
.el-tabs__nav-wrap::after {
display: none;
}
}
.market-step-footer {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 60px 0;
gap: 100px;
.el-button {
width: 100px;
margin: 0;
}
}
</style>
......@@ -42,11 +42,11 @@ const studentMenus: IMenuItem[] = [
}
]
},
// {
// name: '营销策划',
// path: '/market/my',
// icon: markRaw(IconMarket)
// },
{
name: '营销策划',
path: '/market/my',
icon: markRaw(IconMarket)
},
{
name: '用户画像',
path: '/user',
......@@ -170,11 +170,11 @@ const adminMenus: IMenuItem[] = [
}
]
},
// {
// name: '营销策划',
// path: '/market/review',
// icon: markRaw(IconMarket)
// },
{
name: '营销策划',
path: '/market/review',
icon: markRaw(IconMarket)
},
{
name: '用户画像',
path: '/user',
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论